Thomas Zhang 3 місяців тому
батько
коміт
3a1961e440

+ 102 - 0
src/lib/components/kbs/card/card.svelte

@@ -0,0 +1,102 @@
+<script lang="ts">
+	import { File } from '@lucide/svelte';
+	import type { ClassValue } from 'svelte/elements';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Tags } from '$lib/components/common/tags';
+	import type { KnowledgeBase } from '$lib/types/entities';
+	import { cn } from '$lib/utils';
+
+	let {
+		kb,
+		selectedTags,
+		class: className = '',
+		onselect,
+	}: {
+		kb: KnowledgeBase;
+		selectedTags?: Set<string>;
+		class?: ClassValue;
+		onselect?: () => void;
+	} = $props();
+</script>
+
+{#snippet content()}
+	<GlassPane
+		class={cn(
+			'flex h-64 flex-col gap-2 overflow-hidden rounded-xl p-4 hover:border-white/20 hover:bg-white/5',
+			className,
+		)}
+		reactive
+	>
+		<!-- header -->
+		<div class="flex shrink-0 items-center gap-2">
+			<Icon icon={kb.icon} size={36} />
+			<div
+				class="text-md line-clamp-2 flex-1 truncate leading-none font-medium text-wrap text-white/75 group-hover:text-white"
+			>
+				{kb.name}
+			</div>
+			{#if kb.status}
+				<StatusBadge class="shrink-0" status={kb.status} />
+			{/if}
+		</div>
+
+		{#if kb.description}
+			<div class="line-clamp-3 shrink-0 text-sm text-white/50">
+				{kb.description}
+			</div>
+		{:else}
+			<div class="text-xs text-white/25">&lt;No description&gt;</div>
+		{/if}
+
+		<div class="mb-2 overflow-hidden">
+			{#if kb.tags.length > 0}
+				<Tags activeTags={selectedTags} tags={kb.tags} />
+			{/if}
+		</div>
+
+		<div class="mt-auto grid shrink-0 grid-cols-2 gap-2">
+			{#if kb.documents.length > 0}
+				{@const visible = kb.documents.slice(0, 5)}
+				{@const overflow = kb.documents.length - visible.length}
+				{#each visible as doc (doc.id)}
+					<div class="flex items-center gap-1">
+						<File class="shrink-0 text-primary/75" size={16} strokeWidth={2} />
+						<span class="truncate text-xs text-white/50">{doc.name}</span>
+					</div>
+				{/each}
+				{#if overflow > 0}
+					<div class="flex items-center gap-1">
+						<File class="shrink-0 text-primary/70" size={16} strokeWidth={2} />
+						<span class="truncate text-xs text-white/50">+{overflow} more</span>
+					</div>
+				{/if}
+			{:else}
+				<div class="flex items-center gap-1">
+					<File class="shrink-0 text-primary/70" size={16} strokeWidth={2} />
+					<span class="truncate text-xs text-white/50">&lt;no documents&gt;</span>
+				</div>
+			{/if}
+		</div>
+	</GlassPane>
+{/snippet}
+
+{#if onselect === undefined}
+	<a class="group block" href={resolve('/workloads/kbs/[id]', { id: kb.id })}>
+		{@render content()}
+	</a>
+{:else}
+	<div
+		class="group block"
+		role="button"
+		tabindex="0"
+		onclick={() => onselect()}
+		onkeydown={(e) => e.key === 'Enter' && onselect()}
+	>
+		{@render content()}
+	</div>
+{/if}

+ 3 - 0
src/lib/components/kbs/card/index.ts

@@ -0,0 +1,3 @@
+import KBCard from './card.svelte';
+
+export { KBCard };

+ 57 - 0
src/lib/components/kbs/form/document.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import * as Select from '$lib/components/controls/select';
+	import { Optional } from '$lib/components/forms/optional';
+
+	import { type KBDocumentFormValue } from './types';
+
+	let {
+		form = $bindable(),
+	}: {
+		form: KBDocumentFormValue;
+	} = $props();
+</script>
+
+<Field.Set>
+	<Field.Field>
+		<Field.Label for="file">File</Field.Label>
+		<Input id="file" multiple type="file" bind:files={form.files} />
+	</Field.Field>
+	<Optional title="Chunking Options">
+		<div class="grid grid-cols-3 gap-4">
+			<Field.Field>
+				<Field.Label for="chunkingStrategy">Strategy</Field.Label>
+				<Select.Root type="single" bind:value={form.chunkingStrategy}>
+					<Select.Trigger id="chunkingStrategy" class="capitalize">
+						{form.chunkingStrategy || 'Select chunking strategy...'}
+					</Select.Trigger>
+					<Select.Content>
+						<Select.Item value="fixed">Fixed</Select.Item>
+						<Select.Item value="recursive">Recursive</Select.Item>
+						<Select.Item value="markdown">Markdown</Select.Item>
+					</Select.Content>
+				</Select.Root>
+			</Field.Field>
+			<Field.Field>
+				<Field.Label for="chunkingSize">Size</Field.Label>
+				<Input id="chunkingSize" min={0} type="number" bind:value={form.chunkingSize} />
+			</Field.Field>
+			<Field.Field>
+				<Field.Label for="chunkingOverlap">Overlap</Field.Label>
+				<Input id="chunkingOverlap" min={0} type="number" bind:value={form.chunkingOverlap} />
+			</Field.Field>
+		</div>
+		{#if form.chunkingStrategy === 'recursive'}
+			<Field.Field>
+				<Field.Label for="chunkingSeparators">Separators</Field.Label>
+				<Input
+					id="chunkingSeparators"
+					placeholder="Enter separators separated by comma, e.g. \n, ., space"
+					type="text"
+					bind:value={form.chunkingSeparators}
+				/>
+			</Field.Field>
+		{/if}
+	</Optional>
+</Field.Set>

+ 231 - 0
src/lib/components/kbs/form/form.svelte

@@ -0,0 +1,231 @@
+<script lang="ts">
+	import { Card } from '$lib/components/common/card';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import * as ToggleGroup from '$lib/components/controls/toggle-group';
+	import { ArrayInput } from '$lib/components/forms/array-input';
+	import { DeploymentForm } from '$lib/components/forms/deployment';
+	import { IconPicker } from '$lib/components/forms/icon-picker';
+	import { Optional } from '$lib/components/forms/optional';
+	import { ModelSelectDialog } from '$lib/components/models/select';
+	import type { Model, ModelCategory } from '$lib/types/entities';
+
+	import { DEFAULT_KB_FORM_VALUE, type KBFormValue } from './types';
+
+	let {
+		workspaceId,
+		disabled = false,
+		submitting = false,
+		value,
+		cancelHref,
+		onsubmit = () => {},
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		submitting?: boolean;
+		value?: KBFormValue;
+		cancelHref: string;
+		onsubmit: (value: KBFormValue) => void;
+	} = $props();
+	const isEdit = $derived(value !== undefined);
+	let form = $state<KBFormValue>({ ...DEFAULT_KB_FORM_VALUE });
+	let errors = $state<{
+		name?: string;
+	}>({});
+	let modelSelectionShown = $state(false);
+	let modelSelectionCategory = $state<ModelCategory | undefined>(undefined);
+	let onModelSelect: ((model: Model) => void) | undefined = undefined;
+
+	const initForm = (value?: KBFormValue) => {
+		if (!value) {
+			form = { ...DEFAULT_KB_FORM_VALUE };
+		} else {
+			form = {
+				...$state.snapshot(value),
+			};
+		}
+	};
+
+	$effect(() => initForm(value));
+
+	const selectEmbeddingModel = () => {
+		onModelSelect = (model) => {
+			form.embedding = { type: model.mode, id: model.id, name: model.name, icon: model.icon };
+			modelSelectionShown = false;
+		};
+		modelSelectionCategory = 'embedding';
+		modelSelectionShown = true;
+	};
+
+	const selectRerankerModel = () => {
+		onModelSelect = (model) => {
+			form.reranker = { type: model.mode, id: model.id, name: model.name, icon: model.icon };
+			modelSelectionShown = false;
+		};
+		modelSelectionCategory = 'reranker';
+		modelSelectionShown = true;
+	};
+
+	const handleSubmit = () => {
+		onsubmit(form);
+	};
+</script>
+
+{#snippet panel()}
+	<div class="flex items-center gap-2">
+		<Button href={cancelHref} size="sm" variant="outline">Cancel</Button>
+		<Button disabled={submitting} size="sm" onclick={handleSubmit}>
+			{#if submitting}
+				<Spinner />
+			{/if}
+			{isEdit ? 'Save Changes' : 'Create'}
+		</Button>
+	</div>
+{/snippet}
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div>
+			<h1 class="text-xl font-semibold text-white/90">
+				{isEdit ? 'Edit Knowledge Base' : 'New Knowledge Base'}
+			</h1>
+			{#if isEdit}
+				<div class="text-xs text-white/30 capitalize">{form.mode}</div>
+			{/if}
+		</div>
+		{@render panel()}
+	</div>
+
+	<!-- Basic Information panel -->
+	<Card class="mb-4" title="Basic Information">
+		<div class="mb-4 flex gap-8">
+			<!-- Icon -->
+			<div class="shrink-0">
+				<IconPicker {disabled} bind:value={form.icon} />
+			</div>
+			<!-- Name -->
+			<div class="min-w-0 flex-1 space-y-4">
+				<Field.Set>
+					<Field.Field>
+						<Field.Label for="name">Name</Field.Label>
+						<Input id="name" {disabled} maxlength={64} bind:value={form.name} />
+						<Field.Error>{errors.name}</Field.Error>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="description">Description</Field.Label>
+						<Textarea
+							id="description"
+							{disabled}
+							placeholder="What does this knowledge base contain?"
+							rows={3}
+							bind:value={form.description}
+						/>
+						<Field.Description>
+							Provide a brief description that can also help AI assistant to understand the
+							knowledge base's content.
+						</Field.Description>
+					</Field.Field>
+
+					<Field.Field>
+						<Field.Label for="mode">Mode</Field.Label>
+						<ToggleGroup.Root
+							id="mode"
+							disabled={isEdit || disabled}
+							type="single"
+							variant="outline"
+							bind:value={form.mode}
+						>
+							<ToggleGroup.Item disabled value="external">External</ToggleGroup.Item>
+							<ToggleGroup.Item value="managed">Managed</ToggleGroup.Item>
+						</ToggleGroup.Root>
+					</Field.Field>
+				</Field.Set>
+			</div>
+		</div>
+		<Optional title="Tags">
+			<Field.Field>
+				<ArrayInput {disabled} bind:value={form.tags} />
+				<Field.Description>
+					Tags can be used for filtering and searching in the list view.
+				</Field.Description>
+			</Field.Field>
+		</Optional>
+	</Card>
+
+	<!-- Configuration panel -->
+	<Card class="mb-4" title="Configuration">
+		<Field.Set>
+			<Field.Field>
+				<Field.Label>Embedding Model</Field.Label>
+				<Field.Content>
+					<div class="flex flex-wrap items-center gap-2">
+						<Button size="sm" variant="outline" onclick={() => selectEmbeddingModel()}>
+							{#if form.embedding}
+								<Icon icon={form.embedding.icon || 'embedding'} size={14} />
+								<div>{form.embedding.name || form.embedding.id}</div>
+							{:else}
+								Select model...
+							{/if}
+						</Button>
+					</div>
+				</Field.Content>
+			</Field.Field>
+			<Field.Field>
+				<Field.Label>Reranker Model</Field.Label>
+				<Field.Content>
+					<div class="flex flex-wrap items-center gap-2">
+						<Button size="sm" variant="outline" onclick={() => selectRerankerModel()}>
+							{#if form.reranker}
+								<Icon icon={form.reranker.icon || 'reranker'} size={14} />
+								<div>{form.reranker.name || form.reranker.id}</div>
+							{:else}
+								Select model...
+							{/if}
+						</Button>
+					</div>
+				</Field.Content>
+			</Field.Field>
+			<Optional title="Ingestion Job">
+				<DeploymentForm
+					{disabled}
+					runtimes={[{ value: 'loco-context-ingestion', label: 'loco-context', versions: [] }]}
+					bind:form={form.ingestionJob}
+				/>
+			</Optional>
+			<Optional title="Egestion Job">
+				<DeploymentForm
+					{disabled}
+					runtimes={[{ value: 'loco-context-egestion', label: 'loco-context', versions: [] }]}
+					bind:form={form.egestionJob}
+				/>
+			</Optional>
+		</Field.Set>
+	</Card>
+
+	<Card class="mb-4" title="Deployment">
+		<Optional title="Container Settings">
+			<DeploymentForm
+				{disabled}
+				runtimes={[{ value: 'loco-context', label: 'loco-context', versions: [] }]}
+				bind:form
+			/>
+		</Optional>
+	</Card>
+
+	<!-- Footer actions -->
+	<div class="flex justify-end">
+		{@render panel()}
+	</div>
+</div>
+
+<ModelSelectDialog
+	category={modelSelectionCategory}
+	{workspaceId}
+	bind:open={modelSelectionShown}
+	onselect={(model) => onModelSelect?.($state.snapshot(model))}
+/>

+ 4 - 0
src/lib/components/kbs/form/index.ts

@@ -0,0 +1,4 @@
+import KBDocumentForm from './document.svelte';
+import KBForm from './form.svelte';
+
+export { KBDocumentForm, KBForm };

+ 218 - 0
src/lib/components/kbs/form/types.ts

@@ -0,0 +1,218 @@
+import type { DeploymentFormValue } from '$lib/components/forms/deployment';
+import {
+	fromDeploymentFormValue,
+	toDeploymentFormValue,
+} from '$lib/components/forms/deployment/types';
+import type {
+	ChunkingStrategy,
+	IDRef,
+	KBBase,
+	KBDocument,
+	KBDocumentType,
+	KnowledgeBase,
+	ManagedKB,
+} from '$lib/types/entities';
+
+type KBFormValue = {
+	icon?: string;
+	name: string;
+	description: string;
+	tags: string[];
+	documents: KBDocument[];
+	mode: 'managed' | 'external';
+
+	embedding?: IDRef;
+	reranker?: IDRef;
+
+	ingestionJob: DeploymentFormValue;
+	egestionJob: DeploymentFormValue;
+} & DeploymentFormValue;
+
+type KBDocumentFormValue = {
+	name: string;
+	description: string;
+	tags: string[];
+	files?: FileList;
+	chunkingStrategy: string;
+	chunkingSize: number;
+	chunkingOverlap: number;
+	chunkingSeparators?: string[];
+};
+
+const DEFAULT_KB_FORM_VALUE: KBFormValue = {
+	name: '',
+	description: '',
+	tags: [],
+	documents: [],
+	mode: 'managed',
+	runtime: 'loco-context',
+	ingestionJob: {
+		runtime: 'loco-context-ingestion',
+	},
+	egestionJob: {
+		runtime: 'loco-context-egestion',
+	},
+};
+
+const DEFAULT_KB_DOCUMENT_FORM_VALUE: KBDocumentFormValue = {
+	name: '',
+	description: '',
+	tags: [],
+	chunkingStrategy: 'fixed',
+	chunkingSize: 1000,
+	chunkingOverlap: 200,
+};
+
+const toKBFormValue = (kb: KnowledgeBase): KBFormValue => {
+	let value = {
+		...DEFAULT_KB_FORM_VALUE,
+		icon: kb.icon,
+		name: kb.name,
+		description: kb.description,
+		tags: kb.tags,
+		documents: kb.documents,
+		mode: kb.mode,
+	};
+	if (kb.mode === 'managed') {
+		value = {
+			...value,
+			embedding: kb.embedding,
+			reranker: kb.reranker,
+			...toDeploymentFormValue(kb),
+			...(kb.ingestion ? { ingestionJob: toDeploymentFormValue(kb.ingestion) } : {}),
+			...(kb.egestion ? { egestionJob: toDeploymentFormValue(kb.egestion) } : {}),
+		};
+	}
+	return value;
+};
+
+const toKBDocumentFormValue = (doc: KBDocument): KBDocumentFormValue => {
+	return {
+		name: doc.name,
+		description: doc.description,
+		tags: doc.tags,
+		chunkingStrategy: doc.chunking.strategy,
+		chunkingSize: doc.chunking.size,
+		chunkingOverlap: doc.chunking.overlap,
+		chunkingSeparators: doc.chunking.separators,
+	};
+};
+
+const fromKBFormValue = (value: KBFormValue): KnowledgeBase => {
+	const kbBase: KBBase = {
+		id: '',
+		stack: 'default',
+		icon: value.icon,
+		name: value.name,
+		description: value.description,
+		tags: value.tags,
+		documents: value.documents,
+	};
+	if (value.mode === 'managed') {
+		const managedKB: ManagedKB = {
+			...kbBase,
+			mode: 'managed',
+			embedding: value.embedding,
+			reranker: value.reranker,
+			runtime: fromDeploymentFormValue(value),
+			ingestion: fromDeploymentFormValue(value.ingestionJob),
+			egestion: fromDeploymentFormValue(value.egestionJob),
+		};
+		return managedKB;
+	}
+	throw new Error('Unknown KB mode');
+};
+
+const MIME_TO_DOCUMENT_TYPE: Record<string, KBDocumentType> = {
+	'text/plain': 'text',
+	'text/markdown': 'markdown',
+	'text/x-markdown': 'markdown',
+	'text/html': 'html',
+	'application/xhtml+xml': 'html',
+	'application/pdf': 'pdf',
+	'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+};
+
+const EXTENSION_TO_DOCUMENT_TYPE: Record<string, KBDocumentType> = {
+	txt: 'text',
+	md: 'markdown',
+	markdown: 'markdown',
+	html: 'html',
+	htm: 'html',
+	pdf: 'pdf',
+	docx: 'docx',
+};
+
+const inferDocumentType = (file: File): KBDocumentType | undefined => {
+	const normalizedMime = file.type.toLowerCase().split(';')[0]?.trim();
+	if (normalizedMime && MIME_TO_DOCUMENT_TYPE[normalizedMime]) {
+		return MIME_TO_DOCUMENT_TYPE[normalizedMime];
+	}
+
+	const extension = file.name.split('.').pop()?.toLowerCase();
+	if (!extension) return undefined;
+	return EXTENSION_TO_DOCUMENT_TYPE[extension];
+};
+
+const fromKBDocumentFormValue = (value: KBDocumentFormValue, kbId: string): KBDocument[] => {
+	const chunking: ChunkingStrategy = {
+		strategy: value.chunkingStrategy,
+		size: value.chunkingSize,
+		overlap: value.chunkingOverlap,
+		separators: value.chunkingSeparators,
+	};
+	const documents: KBDocument[] = [];
+	for (const file of value.files || []) {
+		const fileType = inferDocumentType(file);
+		if (fileType === undefined) {
+			continue;
+		}
+		documents.push({
+			id: '',
+			kb: { id: kbId },
+			name: file.name,
+			description: value.description,
+			tags: value.tags,
+			type: fileType,
+			sizeBytes: file.size,
+			source: {
+				type: 'unknown',
+			},
+			chunking,
+		});
+	}
+	return documents;
+};
+
+const validateKBFormValue = (value: KBFormValue): { kb: KnowledgeBase; errors: string[] } => {
+	const errors: string[] = [];
+	if (!value.name.trim()) {
+		errors.push('Name is required');
+	}
+	return { kb: fromKBFormValue(value), errors };
+};
+
+const validateKBDocumentFormValue = (
+	value: KBDocumentFormValue,
+): { documents: KBDocument[]; errors: string[] } => {
+	const errors: string[] = [];
+	if (!value.name.trim()) {
+		errors.push('Name is required');
+	}
+	if (!value.files) {
+		errors.push('Files is required');
+	}
+	return { documents: fromKBDocumentFormValue(value, ''), errors };
+};
+
+export type { KBDocumentFormValue, KBFormValue };
+export {
+	DEFAULT_KB_DOCUMENT_FORM_VALUE,
+	DEFAULT_KB_FORM_VALUE,
+	fromKBDocumentFormValue,
+	fromKBFormValue,
+	toKBDocumentFormValue,
+	toKBFormValue,
+	validateKBDocumentFormValue,
+	validateKBFormValue,
+};

+ 95 - 0
src/lib/components/kbs/select/dialog.svelte

@@ -0,0 +1,95 @@
+<script lang="ts">
+	import { LibraryBig } from '@lucide/svelte';
+	import { untrack } from 'svelte';
+
+	import * as kbsApi from '$lib/api/kbs';
+	import { Empty } from '$lib/components/common/empty';
+	import { SelectDialog } from '$lib/components/common/select-dialog';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { KBCard } from '$lib/components/kbs/card';
+	import type { KnowledgeBase } from '$lib/types/entities';
+
+	let {
+		workspaceId,
+		multiple = true,
+		open = $bindable(false),
+		value = $bindable([]),
+		onselect,
+	}: {
+		workspaceId: string;
+		multiple?: boolean;
+		open?: boolean;
+		value?: KnowledgeBase[];
+		onselect?: (kb: KnowledgeBase[]) => KnowledgeBase[];
+	} = $props();
+
+	let query = $state('');
+	let loading = $state(false);
+	let kbs = $state<KnowledgeBase[]>([]);
+
+	const loadKbs = async () => {
+		if (loading || kbs.length > 0) return;
+		loading = true;
+		try {
+			kbs = await kbsApi.list(workspaceId);
+		} catch (error) {
+			console.error('Failed to load knowledge bases', error);
+		} finally {
+			loading = false;
+		}
+	};
+
+	$effect(() => {
+		if (open) {
+			untrack(() => {
+				loadKbs();
+			});
+		}
+	});
+
+	const select = (kb: KnowledgeBase) => {
+		if (value.includes(kb)) {
+			value = value.filter((t) => t.id !== kb.id);
+		} else {
+			value = [...value, kb];
+		}
+		if (onselect) {
+			value = onselect(value);
+		}
+	};
+</script>
+
+<SelectDialog
+	selected={value.map((kb) => kb.name)}
+	title="Select Knowledge Bases"
+	bind:open
+	bind:query
+	onsave={multiple ? () => (open = false) : undefined}
+>
+	{#snippet content()}
+		{#if loading}
+			<div class="flex h-full w-full items-center justify-center gap-2">
+				<Spinner />
+				<div class="text-sm text-white/50">Loading Knowledge Bases...</div>
+			</div>
+		{:else if kbs.length === 0}
+			<Empty icon={LibraryBig}>
+				{#snippet caption()}
+					<div>No knowledge bases found</div>
+				{/snippet}
+			</Empty>
+		{:else}
+			<div class="grid grid-cols-2 gap-2">
+				{#each kbs as kb (kb.id)}
+					<KBCard
+						class={value.includes(kb)
+							? 'border-primary/50 bg-primary/25 hover:border-primary/75 hover:bg-primary/50'
+							: ''}
+						{kb}
+						onselect={() => select(kb)}
+					/>
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</SelectDialog>

+ 3 - 0
src/lib/components/kbs/select/index.ts

@@ -0,0 +1,3 @@
+import KBSelectDialog from './dialog.svelte';
+
+export { KBSelectDialog };

+ 9 - 0
src/routes/workloads/kbs/+page.server.ts

@@ -0,0 +1,9 @@
+import * as kbsApi from '$lib/api/kbs';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, locals }) => ({
+	kbs: await kbsApi.list(locals.workspaceId!, fetch),
+});
+
+export { load };

+ 93 - 0
src/routes/workloads/kbs/+page.svelte

@@ -0,0 +1,93 @@
+<script lang="ts">
+	import { LibraryBig } from '@lucide/svelte';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	import { resolve } from '$app/paths';
+
+	import { FilterList } from '$lib/components/common/filter-list';
+	import * as Filters from '$lib/components/common/filters';
+	import { KBCard } from '$lib/components/kbs/card';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { kbsStore } from '$lib/stores/kbs.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+	let query = $state('');
+	const allTags = $derived([...new Set(kbsStore.list.flatMap((m) => m.tags ?? []))].sort());
+	const selectedTags = new SvelteSet<string>();
+
+	let filters: Filters.FilterRow[] = $derived([
+		{
+			id: 'mode',
+			type: 'single',
+			label: 'Mode',
+			options: [
+				{ value: 'all', label: 'All' },
+				{ value: 'external', label: 'External' },
+				{ value: 'managed', label: 'Managed' },
+			],
+			selected: 'all',
+			default: 'all',
+		},
+		...(allTags.length > 0
+			? [
+					{
+						id: 'tags',
+						type: 'multi' as const,
+						label: 'Tags',
+						options: allTags.map((t) => ({ value: t, label: t })),
+						selected: selectedTags,
+					},
+				]
+			: []),
+	]);
+
+	const filtered = $derived.by(() => {
+		let list = kbsStore.list;
+		const q = query.trim().toLowerCase();
+		if (q) list = list.filter((kb) => kb.name.toLowerCase().includes(q));
+		for (const filter of filters) {
+			if (filter.id === 'mode') {
+				list = list.filter((kb) =>
+					filter.selected === 'all' ? true : kb.mode === filter.selected,
+				);
+			} else if (filter.id === 'tags' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((kb) => kb.tags?.some((t) => filter.selected.has(t)));
+			}
+		}
+		return list;
+	});
+
+	$effect.root(() => {
+		kbsStore.hydrate(data.kbs);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({ module: 'Knowledge Bases', pages: [] });
+	});
+</script>
+
+<svelte:head>
+	<title>Knowledge Bases - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<FilterList
+		creationLink={resolve('/workloads/kbs/new')}
+		itemCount={kbsStore.list.length}
+		itemIcon={LibraryBig}
+		itemKey={(m) => m.id}
+		itemName="knowledge base"
+		itemNamePlural="knowledge bases"
+		items={filtered}
+		bind:query
+		bind:filters
+	>
+		{#snippet itemRenderer(kb)}
+			<KBCard {kb} {selectedTags} />
+		{/snippet}
+	</FilterList>
+</div>

+ 52 - 0
src/routes/workloads/kbs/[id]/+page.server.ts

@@ -0,0 +1,52 @@
+import path from 'node:path';
+
+import { error, fail } from '@sveltejs/kit';
+
+import * as kbsApi from '$lib/api/kbs';
+import { server } from '$lib/server';
+
+import type { Actions, PageServerLoad } from './$types';
+import { derefKB } from './types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const kb = await kbsApi.get(locals.workspaceId!, params.id, fetch);
+	if (!kb) error(404);
+	const catalog = server.catalog.getWorkspaceCatalog(locals.workspaceId!);
+	return { kb: await derefKB(kb, catalog) };
+};
+
+const actions: Actions = {
+	uploadDocuments: async ({ locals, request, params }) => {
+		const workspaceId = locals.workspaceId!;
+
+		let formData: FormData;
+		try {
+			formData = await request.formData();
+		} catch {
+			return fail(400, { error: 'Invalid form data.' });
+		}
+
+		const files = formData.getAll('files') as File[];
+		if (files.length === 0) {
+			return fail(400, { error: 'No files provided.' });
+		}
+
+		const saveFiles: Record<string, File> = {};
+		const pathToNameMap: Record<string, string> = {};
+		for (const file of files) {
+			if (!(file instanceof File) || file.size === 0) continue;
+			const filePath = path.join(params.id, file.name);
+			saveFiles[filePath] = file;
+			pathToNameMap[filePath] = file.name;
+		}
+		const saved = await server.saveFiles(workspaceId, saveFiles);
+
+		const result: Record<string, string> = {};
+		for (const [filePath, savedPath] of Object.entries(saved)) {
+			result[pathToNameMap[filePath]] = savedPath;
+		}
+		return { saved: result };
+	},
+};
+
+export { actions, load };

+ 353 - 0
src/routes/workloads/kbs/[id]/+page.svelte

@@ -0,0 +1,353 @@
+<script lang="ts">
+	import { Download, Search, Send, SquarePen, Trash, Upload } from '@lucide/svelte';
+	import type { ActionResult } from '@sveltejs/kit';
+	import type { Snippet } from 'svelte';
+
+	import { deserialize } from '$app/forms';
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import * as kbsApi from '$lib/api/kbs';
+	import { Card } from '$lib/components/common/card';
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Icon } from '$lib/components/common/icon';
+	import { MoreMenu } from '$lib/components/common/more-menu';
+	import { PropertyTable, toDeploymentProps } from '$lib/components/common/property-table';
+	import { toInfoProps } from '$lib/components/common/property-table/types';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Button, buttonVariants } from '$lib/components/controls/button';
+	import * as Dialog from '$lib/components/controls/dialog';
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+	import * as InputGroup from '$lib/components/controls/input-group';
+	import * as Table from '$lib/components/controls/table';
+	import { KBDocumentForm } from '$lib/components/kbs/form';
+	import {
+		DEFAULT_KB_DOCUMENT_FORM_VALUE,
+		fromKBDocumentFormValue,
+		toKBDocumentFormValue,
+	} from '$lib/components/kbs/form/types';
+	import { ManagementTabs } from '$lib/components/management';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { documentsStore } from '$lib/stores/documents.svelte';
+	import { kbsStore } from '$lib/stores/kbs.svelte';
+	import { toManagedKBCR } from '$lib/types/custom-resources';
+	import type { KBDocument, ManagedKB } from '$lib/types/entities';
+	import type { Changes, CoreResources } from '$lib/types/workspace';
+	import * as yaml from '$lib/yaml';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		kbsStore.upsert(data.kb);
+		kbsStore.refreshStatus(data.kb.id);
+	});
+
+	const kb = $derived(kbsStore.get(data.kb.id) ?? data.kb);
+	const status = $derived(kbsStore.getStatus(kb.id));
+
+	const infoProps = $derived(toInfoProps(kb));
+	const configProps = $derived.by(() => {
+		const props: Record<string, string | number | string[] | Snippet> = {};
+		if (kb.mode === 'managed') {
+			if (kb.embedding) props['Embedding Model'] = embeddingModelLink;
+			if (kb.reranker) props['Reranker Model'] = rerankerModelLink;
+		}
+		return props;
+	});
+	const deploymentProps = $derived.by(() => {
+		if (kb.mode !== 'managed') return {};
+		const man = kb as ManagedKB;
+		return toDeploymentProps(man.runtime);
+	});
+	const ingestionProps = $derived.by(() => {
+		if (kb.mode !== 'managed') return {};
+		const man = kb as ManagedKB;
+		if (man.ingestion === undefined) return {};
+		return toDeploymentProps(man.ingestion);
+	});
+	const egestionProps = $derived.by(() => {
+		if (kb.mode !== 'managed') return {};
+		const man = kb as ManagedKB;
+		if (man.egestion === undefined) return {};
+		return toDeploymentProps(man.egestion);
+	});
+	const cr = $derived.by(() => {
+		if (kb.mode === 'managed') return yaml.stringify(toManagedKBCR(kb as ManagedKB));
+		else return undefined;
+	});
+	let deleteOpen = $state(false);
+	let query = $state('');
+	let documentFormShown = $state(false);
+	let editingDocument = $state<KBDocument | undefined>(undefined);
+	let documentFormValue = $derived(
+		editingDocument
+			? toKBDocumentFormValue(editingDocument)
+			: { ...DEFAULT_KB_DOCUMENT_FORM_VALUE },
+	);
+	let uploading = $state(false);
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Knowledge Bases',
+			pages: [{ name: kb.name, href: resolve('/workloads/kbs/[id]', { id: kb.id }) }],
+		});
+	});
+
+	const bytesToReadable = (bytes: number) => {
+		const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+		if (bytes === 0) return '0';
+		const i = Math.floor(Math.log(bytes) / Math.log(1024));
+		return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
+	};
+
+	const onTest = () => {
+		goto(resolve(`/utilities/testing/kb?kb=${kb.id}`));
+	};
+
+	const onDeploy = () => {
+		kbsStore.update(kb);
+	};
+
+	const onDelete = async () => {
+		await kbsStore.remove(kb.id);
+		goto(resolve('/workloads/kbs'));
+	};
+
+	const onCreateDocument = () => {
+		editingDocument = undefined;
+		documentFormShown = true;
+	};
+
+	const onSaveDocument = async () => {
+		const formValue = $state.snapshot(documentFormValue);
+		const files = formValue.files || [];
+		if (!files || files.length === 0) return;
+		uploading = true;
+		const fd = new FormData();
+		const fileMap: Record<string, File> = {};
+		for (const file of files) {
+			fileMap[file.name] = file;
+			fd.append('files', file);
+		}
+		try {
+			const resp = await fetch(`?/uploadDocuments`, { method: 'POST', body: fd });
+			const result: ActionResult = deserialize(await resp.text());
+			if (result.type === 'success') {
+				const { saved }: { saved?: Record<string, string> } = result.data || {};
+				if (saved === undefined) {
+					return;
+				}
+				const documents: KBDocument[] = fromKBDocumentFormValue(formValue, kb.id)
+					.filter((doc) => saved[doc.name])
+					.map((doc) => ({
+						...doc,
+						source: {
+							type: 'upload',
+							fileName: saved[doc.name],
+						},
+					}));
+				await documentsStore.createDocuments(documents);
+			}
+			documentFormShown = false;
+		} finally {
+			uploading = false;
+		}
+	};
+
+	const fetchResources = async (): Promise<CoreResources> => {
+		return kbsApi.getResources(data.workspaceId!, kb.id);
+	};
+
+	const fetchChanges = async (): Promise<Changes> => {
+		return kbsApi.getChanges(data.workspaceId!, kb.id);
+	};
+</script>
+
+<svelte:head>
+	<title>{kb.name} - LocoStack</title>
+</svelte:head>
+
+{#snippet embeddingModelLink()}
+	{#if kb.mode === 'managed' && kb.embedding}
+		<Button
+			href={resolve('/workloads/models/[id]', { id: kb.embedding.id })}
+			size="sm"
+			variant="outline"
+		>
+			<Icon icon={kb.embedding.icon} size={14} />
+			<div>{kb.embedding.name}</div>
+		</Button>
+	{/if}
+{/snippet}
+
+{#snippet rerankerModelLink()}
+	{#if kb.mode === 'managed' && kb.reranker}
+		<Button
+			href={resolve('/workloads/models/[id]', { id: kb.reranker.id })}
+			size="sm"
+			variant="outline"
+		>
+			<Icon icon={kb.reranker.icon} size={14} />
+			<div>{kb.reranker.name}</div>
+		</Button>
+	{/if}
+{/snippet}
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div class="flex items-center gap-4">
+			<Icon icon={kb.icon} />
+			<div class="flex flex-1 flex-col overflow-hidden">
+				<div class="truncate text-lg font-medium text-white/75">{kb.name}</div>
+				<div class="text-xs text-white/50">
+					{kb.mode === 'external' ? 'External' : 'Managed'}
+				</div>
+			</div>
+		</div>
+
+		<div class="flex items-center gap-2">
+			<StatusBadge status={kb.status || 'not-deployed'} />
+			<Button href={resolve('/workloads/kbs/[id]/edit', { id: kb.id })} size="sm" variant="outline">
+				<SquarePen size={14} strokeWidth={2} />
+				Edit
+			</Button>
+			<MoreMenu ondelete={() => (deleteOpen = true)}>
+				{#snippet menu()}
+					{#if kb.status}
+						<DropdownMenu.Item onclick={() => onTest()}>
+							<Send size={14} strokeWidth={2} />
+							Test
+						</DropdownMenu.Item>
+					{:else}
+						<DropdownMenu.Item onclick={() => onDeploy()}>
+							<Send size={14} strokeWidth={2} />
+							Deploy
+						</DropdownMenu.Item>
+					{/if}
+				{/snippet}
+			</MoreMenu>
+		</div>
+	</div>
+
+	<Card class="mb-4" title="Basic Information">
+		<PropertyTable properties={infoProps} />
+	</Card>
+	<Card class="mb-4" title="Configuration">
+		<PropertyTable properties={configProps} />
+	</Card>
+
+	<Card class="mb-4" title="Documents">
+		<div class="mb-4 flex items-center justify-between">
+			<InputGroup.Root class="max-w-64 flex-1">
+				<InputGroup.Addon>
+					<Search size={14} strokeWidth={2} />
+				</InputGroup.Addon>
+				<InputGroup.Input placeholder="Search by name…" bind:value={query} />
+			</InputGroup.Root>
+			<Button disabled={uploading} size="sm" variant="outline" onclick={() => onCreateDocument()}>
+				<Upload size={14} strokeWidth={2} />
+				{uploading ? 'Uploading...' : 'Upload'}
+			</Button>
+		</div>
+		<Table.Root>
+			<Table.Header>
+				<Table.Row>
+					<Table.Head>Name</Table.Head>
+					<Table.Head>Size</Table.Head>
+					<Table.Head>Type</Table.Head>
+					<Table.Head class="w-28">Status</Table.Head>
+					<Table.Head></Table.Head>
+				</Table.Row>
+			</Table.Header>
+			<Table.Body>
+				{#each kb.documents as doc (doc.id)}
+					<Table.Row>
+						<Table.Cell>{doc.name}</Table.Cell>
+						<Table.Cell>{bytesToReadable(doc.sizeBytes)}</Table.Cell>
+						<Table.Cell>{doc.type}</Table.Cell>
+						<Table.Cell class="w-28">
+							<StatusBadge status={doc.status || 'pending'} />
+						</Table.Cell>
+						<Table.Cell class="text-end select-none">
+							<Button size="icon-xs" title="Download" variant="ghost">
+								<Download />
+							</Button>
+							<Button size="icon-xs" title="Delete" variant="ghost">
+								<Trash />
+							</Button>
+						</Table.Cell>
+					</Table.Row>
+				{:else}
+					<Table.Row>
+						<Table.Cell class="text-center text-white/50" colspan={5}>No documents</Table.Cell>
+					</Table.Row>
+				{/each}
+			</Table.Body>
+		</Table.Root>
+	</Card>
+
+	<div class="mb-4">
+		{#if kb.mode === 'managed'}
+			<Card class="mb-4" title="Deployment">
+				<PropertyTable properties={deploymentProps} />
+			</Card>
+		{/if}
+		<Card class="mb-4" title="Ingestion">
+			<PropertyTable properties={ingestionProps} />
+		</Card>
+		<Card class="mb-4" title="Egestion">
+			<PropertyTable properties={egestionProps} />
+		</Card>
+	</div>
+
+	<ManagementTabs
+		conditions={status?.conditions}
+		{cr}
+		onloadresources={kb.mode === 'managed' ? fetchResources : undefined}
+		onloadchanges={fetchChanges}
+	/>
+</div>
+
+<!-- Document form dialog -->
+<Dialog.Root bind:open={documentFormShown}>
+	<Dialog.Content class="sm:max-w-xl">
+		<Dialog.Header>
+			{#if editingDocument}
+				<Dialog.Title>Edit document - {editingDocument.name}</Dialog.Title>
+			{:else}
+				<Dialog.Title>Add document</Dialog.Title>
+			{/if}
+		</Dialog.Header>
+		<KBDocumentForm bind:form={documentFormValue} />
+		<Dialog.Footer>
+			<Dialog.Close class={buttonVariants({ variant: 'outline' })} type="button">
+				Cancel
+			</Dialog.Close>
+			<Button type="submit" onclick={() => onSaveDocument()}>
+				{#if editingDocument}
+					Save
+				{:else}
+					Add
+				{/if}
+			</Button>
+		</Dialog.Footer>
+	</Dialog.Content>
+</Dialog.Root>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting…"
+	title="Delete knowledge base?"
+	variant="destructive"
+	onconfirm={() => onDelete()}
+	bind:open={deleteOpen}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{kb.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>

+ 17 - 0
src/routes/workloads/kbs/[id]/edit/+page.server.ts

@@ -0,0 +1,17 @@
+import { error } from '@sveltejs/kit';
+
+import * as kbsApi from '$lib/api/kbs';
+import { server } from '$lib/server';
+
+import { derefKB } from '../types';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const kb = await kbsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!kb) error(404);
+	const catalog = server.catalog.getWorkspaceCatalog(locals.workspaceId!);
+	return { kb: await derefKB(kb, catalog) };
+};
+
+export { load };

+ 54 - 0
src/routes/workloads/kbs/[id]/edit/+page.svelte

@@ -0,0 +1,54 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { KBForm } from '$lib/components/kbs/form';
+	import {
+		type KBFormValue,
+		toKBFormValue,
+		validateKBFormValue,
+	} from '$lib/components/kbs/form/types';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { kbsStore } from '$lib/stores/kbs.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		kbsStore.upsert(data.kb);
+	});
+
+	const kb = $derived(kbsStore.get(data.kb.id) ?? data.kb);
+	const value = $derived(toKBFormValue(kb));
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Knowledge Bases',
+			pages: [
+				{ name: `Edit [${kb.name}]`, href: resolve('/workloads/kbs/[id]/edit', { id: kb.id }) },
+			],
+		});
+	});
+
+	const onSave = async (formValue: KBFormValue) => {
+		const { kb, errors } = validateKBFormValue(formValue);
+		if (errors.length > 0 || kb === undefined) {
+			return;
+		}
+		await kbsStore.update({ ...kb, id: data.kb.id });
+		goto(resolve('/workloads/kbs/[id]', { id: data.kb.id }));
+	};
+</script>
+
+<svelte:head>
+	<title>Edit {kb.name} - LocoStack</title>
+</svelte:head>
+
+<KBForm
+	cancelHref={resolve('/workloads/kbs/[id]', { id: kb.id })}
+	{value}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSave(formValue)}
+/>

+ 27 - 0
src/routes/workloads/kbs/[id]/types.ts

@@ -0,0 +1,27 @@
+import type { WorkspaceCatalog } from '$lib/server/catalog';
+import type { IDRef, KnowledgeBase, Model } from '$lib/types/entities';
+
+const populateModelRef = (models: Model[], modelRef?: IDRef): IDRef | undefined => {
+	if (modelRef === undefined) return undefined;
+	const model = models.find((m) => m.id === modelRef.id);
+	if (model) {
+		return {
+			type: model.mode,
+			id: model.id,
+			name: model.name,
+			icon: model.icon,
+		};
+	}
+	return modelRef;
+};
+
+const derefKB = async (kb: KnowledgeBase, catalog: WorkspaceCatalog): Promise<KnowledgeBase> => {
+	const models = await catalog.models.list();
+	kb.embedding = populateModelRef(models, kb.embedding);
+	kb.reranker = populateModelRef(models, kb.reranker);
+	const documents = await catalog.documents.list();
+	kb.documents = documents.filter((d) => d.kb.id === kb.id);
+	return kb;
+};
+
+export { derefKB };

+ 40 - 0
src/routes/workloads/kbs/new/+page.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { KBForm } from '$lib/components/kbs/form';
+	import { type KBFormValue, validateKBFormValue } from '$lib/components/kbs/form/types';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { kbsStore } from '$lib/stores/kbs.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Knowledge Bases',
+			pages: [{ name: '[New Knowledge Base]', href: resolve('/workloads/kbs/new') }],
+		});
+	});
+
+	const onSubmit = async (formValue: KBFormValue) => {
+		const { kb, errors } = validateKBFormValue(formValue);
+		if (errors.length > 0 || kb === undefined) {
+			return;
+		}
+		await kbsStore.create(kb);
+		goto(resolve('/workloads/kbs'));
+	};
+</script>
+
+<svelte:head>
+	<title>New Knowledge Base - LocoStack</title>
+</svelte:head>
+
+<KBForm
+	cancelHref={resolve('/workloads/kbs')}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSubmit(formValue)}
+/>