Files
lib-knowledge-ui/demo/knowledge.tsx
jules efbf4b2e61 W6: export panel
components-export: ExportPanel (scope picker + request) + ExportJobRow (status,
download on ready, error verbatim; timer lives in the app per the polling
contract). Demo Export tab polls getExport app-side. Lib confirmed
self-contained (react + lucide only); README/tsconfig updated. Typechecks clean.

Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
2026-07-07 10:05:46 +10:00

218 lines
7.8 KiB
TypeScript

// PURPOSE: Standalone demo of @crema/knowledge-ui over MockKnowledgeTransport.
// Drop into a consuming app's route (e.g. routes/knowledge-demo.tsx) to
// exercise every surface with fixtures and no running service.
// ===========================================================================
import { useEffect, useState } from "react";
import {
ClaimsReviewQueue,
CollectionForm,
CollectionList,
ExportPanel,
ObjectList,
ObjectViewer,
MockKnowledgeTransport,
type Claim,
type Collection,
type CollectionInput,
type CollectionPatch,
type ExportJob,
type ExportScope,
type KnowledgeTransport,
type ObjectOutline,
type ObjectSummary,
} from "../src/index";
const transport: KnowledgeTransport = new MockKnowledgeTransport();
type View =
| { kind: "collections" }
| { kind: "collection"; slug: string }
| { kind: "object"; id: string };
export default function KnowledgeDemo() {
const [tab, setTab] = useState<"browse" | "review" | "export">("browse");
const [exports, setExports] = useState<ExportJob[]>([]);
const [exportBusy, setExportBusy] = useState(false);
const [view, setView] = useState<View>({ kind: "collections" });
const [collections, setCollections] = useState<Collection[]>([]);
const [pending, setPending] = useState<Record<string, number>>({});
const [objects, setObjects] = useState<ObjectSummary[]>([]);
const [outline, setOutline] = useState<ObjectOutline | null>(null);
const [claims, setClaims] = useState<Claim[]>([]);
const [busyId, setBusyId] = useState<string | undefined>();
const [creating, setCreating] = useState<null | "account" | "tenant">(null);
const [editing, setEditing] = useState<Collection | null>(null);
const [busy, setBusy] = useState(false);
async function refresh() {
const page = await transport.listCollections();
setCollections(page.collections);
const queue = await transport.reviewQueue();
setClaims(queue);
const counts: Record<string, number> = {};
for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1;
setPending(counts);
}
async function afterClaim(id: string, fn: () => Promise<unknown>) {
setBusyId(id);
await fn();
setBusyId(undefined);
void refresh();
}
// App-side polling of in-flight exports (the timer lives here, not in the lib).
async function requestExport(scope: ExportScope) {
setExportBusy(true);
const job = await transport.createExport(scope);
setExports((prev) => [job, ...prev]);
setExportBusy(false);
poll(job.id);
}
function poll(id: string) {
const tick = async () => {
const job = await transport.getExport(id);
setExports((prev) => prev.map((e) => (e.id === id ? job : e)));
if (job.status === "pending" || job.status === "running") setTimeout(tick, 1500);
};
setTimeout(tick, 800);
}
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
if (view.kind === "collection") void transport.listObjects(view.slug).then((p) => setObjects(p.objects));
if (view.kind === "object") void transport.outline(view.id).then(setOutline);
}, [view]);
async function onCreate(value: CollectionInput | CollectionPatch) {
setBusy(true);
await transport.createCollection(value as CollectionInput);
setBusy(false);
setCreating(null);
void refresh();
}
async function onEdit(value: CollectionInput | CollectionPatch) {
if (!editing) return;
setBusy(true);
await transport.updateCollection(editing.slug, value as CollectionPatch);
setBusy(false);
setEditing(null);
void refresh();
}
const reviewCount = claims.length;
return (
<div className="mx-auto max-w-5xl p-6">
<div className="mb-4 flex items-center gap-2">
<TabBtn active={tab === "browse"} onClick={() => setTab("browse")}>Browse</TabBtn>
<TabBtn active={tab === "review"} onClick={() => setTab("review")}>
Review{reviewCount > 0 ? ` (${reviewCount})` : ""}
</TabBtn>
<TabBtn active={tab === "export"} onClick={() => setTab("export")}>Export</TabBtn>
</div>
{tab === "export" && (
<ExportPanel
collections={collections}
jobs={exports}
busy={exportBusy}
onRequest={requestExport}
onDownload={(job) => job.download_url && window.open(job.download_url)}
/>
)}
{tab === "review" && (
<ClaimsReviewQueue
claims={claims}
busyId={busyId}
onConfirm={(id) => afterClaim(id, () => transport.confirmClaim(id))}
onReject={(id) => afterClaim(id, () => transport.rejectClaim(id))}
onKeep={(winner, supersede) => afterClaim(winner, () => transport.confirmClaim(winner, { supersede }))}
onDismissConflict={(id) => afterClaim(id, () => transport.dismissConflict(id))}
onOpenSource={(objectId) => {
setTab("browse");
setView({ kind: "object", id: objectId });
}}
/>
)}
{tab === "browse" && view.kind !== "collections" && (
<button
type="button"
onClick={() => setView({ kind: "collections" })}
className="mb-4 text-sm text-muted-foreground hover:text-foreground"
>
All corpuses
</button>
)}
{tab === "browse" && view.kind === "collections" && (
<>
{creating && (
<div className="mb-6 rounded-xl border border-border bg-card p-4">
<h2 className="mb-3 font-medium">New corpus</h2>
<CollectionForm mode="create" ownerType={creating} orgName="Acme" canManageTenant busy={busy} onSubmit={onCreate} onCancel={() => setCreating(null)} />
</div>
)}
{editing && (
<div className="mb-6 rounded-xl border border-border bg-card p-4">
<h2 className="mb-3 font-medium">Corpus settings</h2>
<CollectionForm mode="edit" initial={editing} orgName="Acme" canManageTenant busy={busy} onSubmit={onEdit} onCancel={() => setEditing(null)} />
</div>
)}
<CollectionList
collections={collections}
pendingCounts={pending}
orgName="Acme"
canManageTenant
onCreate={(owner) => setCreating(owner)}
onOpen={(c) => setView({ kind: "collection", slug: c.slug })}
/>
</>
)}
{tab === "browse" && view.kind === "collection" && (
<>
<div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">{view.slug}</h1>
<button className="text-sm text-primary" onClick={() => setEditing(collections.find((c) => c.slug === view.slug) ?? null)}>Settings</button>
</div>
<ObjectList objects={objects} onOpen={(o) => setView({ kind: "object", id: o.object_id })} />
</>
)}
{tab === "browse" && view.kind === "object" && outline && (
<ObjectViewer
outline={outline}
readText={(sel) => transport.readText(outline.object_id, sel)}
resolveBlobUrl={() => transport.blobUrl(outline.object_id)}
onOpenObject={(id) => setView({ kind: "object", id })}
actions={{
onVerify: () => transport.updateObject(outline.object_id, { verified_at: new Date().toISOString() }),
onDownload: () => transport.blobUrl(outline.object_id).then((u) => window.open(u)),
}}
/>
)}
</div>
);
}
function TabBtn({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={
"rounded-lg px-3 py-1.5 text-sm font-medium transition " +
(active ? "bg-primary/10 text-primary" : "text-muted-foreground hover:bg-muted")
}
>
{children}
</button>
);
}