components-claims: ClaimsReviewQueue (proposed → conflicts → unreviewed per claims-spec §2.3), ClaimCard (confirm/reject/reopen tombstone), ConflictPair (both sides raw, three resolutions: keep-with-supersede, dismiss-both, reject-newcomer; human_confirmed can't be rejected). Tiers raw, no scores. Demo Review tab wired. Typechecks clean. Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
185 lines
6.7 KiB
TypeScript
185 lines
6.7 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,
|
|
ObjectList,
|
|
ObjectViewer,
|
|
MockKnowledgeTransport,
|
|
type Claim,
|
|
type Collection,
|
|
type CollectionInput,
|
|
type CollectionPatch,
|
|
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">("browse");
|
|
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();
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
|
|
{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>
|
|
);
|
|
}
|