|
|
@@ -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>
|