W3: lib-knowledge-ui scaffold + collections
@crema/knowledge-ui: headless KB management components over an injected KnowledgeTransport. This commit: package + types (full API surface) + transport interface + MockKnowledgeTransport (fixtures for every surface) + _internal (cn, badges, formatters) + components-collections (CollectionList grouped by owner, CollectionCard, CollectionForm with org/read-only states) + demo + README + tsconfig.check.json. Typechecks clean. Co-Authored-By: Claude Fable 5 (build) <noreply@anthropic.com>
This commit is contained in:
82
README.md
Normal file
82
README.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# @crema/knowledge-ui
|
||||||
|
|
||||||
|
Management UI for **arcadia-knowledge** — the hosted knowledge base. Ships the
|
||||||
|
collection browser, object viewer (card + provenance + supersession + citations),
|
||||||
|
claims review queue, and export flow as **pure, headless components**: shapes and
|
||||||
|
UI, never data fetching. The app injects a `KnowledgeTransport`; auth stays closed
|
||||||
|
over inside it, so components never see a token.
|
||||||
|
|
||||||
|
Built to `arcadia-knowledge-ui-spec.md`. Two consumers today: the Knowledge area
|
||||||
|
in `arcadia-personal-cloud-web` (me.sky-ai.com) and the standalone
|
||||||
|
`skyai-knowledge-web`. Same lib, different shells.
|
||||||
|
|
||||||
|
## Install (polyrepo — source alias, not npm)
|
||||||
|
|
||||||
|
Like every `@crema/*` lib, this is a path-aliased source folder. A consumer wires
|
||||||
|
it in three places:
|
||||||
|
|
||||||
|
1. **`vite.config.ts`** — resolve alias:
|
||||||
|
```ts
|
||||||
|
"@crema/knowledge-ui": libSrc("knowledge-ui") + "/index.tsx",
|
||||||
|
"@crema/knowledge-ui/": libSrc("knowledge-ui") + "/",
|
||||||
|
```
|
||||||
|
The lib also imports `@crema/file-ui` (for uploads/previews), so alias that too
|
||||||
|
if not already, and make sure `lucide-react` is in your shared-dep dedupe list.
|
||||||
|
|
||||||
|
2. **`tsconfig.json`** — paths:
|
||||||
|
```json
|
||||||
|
"@crema/knowledge-ui": ["../lib-knowledge-ui/src/index.tsx"],
|
||||||
|
"@crema/knowledge-ui/*": ["../lib-knowledge-ui/src/*"]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **`app/app.css`** — Tailwind source scan:
|
||||||
|
```css
|
||||||
|
@source "../../lib-knowledge-ui/src";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wiring the transport
|
||||||
|
|
||||||
|
The lib defines `KnowledgeTransport`; the app implements it over its KB client
|
||||||
|
(a `kb.ts` factory that closes over the session token and talks to
|
||||||
|
arcadia-knowledge `:4025` directly). See `arcadia-knowledge-ui-spec.md` §4.2.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CollectionList, type KnowledgeTransport } from "@crema/knowledge-ui";
|
||||||
|
|
||||||
|
const transport: KnowledgeTransport = kbTransport(session.token);
|
||||||
|
const { collections } = await transport.listCollections();
|
||||||
|
|
||||||
|
<CollectionList collections={collections} onOpen={(c) => navigate(`/knowledge/${c.slug}`)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
For development without a service, use `MockKnowledgeTransport` — it backs
|
||||||
|
`demo/knowledge.tsx` with fixtures covering every surface (personal + org
|
||||||
|
corpuses, documents, an image, a superseded doc, a vault doc, an open claim
|
||||||
|
conflict, a proposed claim, a rejected tombstone, an export job).
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Tailwind theme tokens only** — no hex. Sensitivity/tier/status/curation
|
||||||
|
colours use `--warning` / `--success` / `--destructive` / `--info` (falls back
|
||||||
|
to `--primary`). Works in any Crema theme.
|
||||||
|
- **Props in, callbacks out.** No global state, no context, no fetching.
|
||||||
|
- **`data-action` attributes** on interactive elements (command-bus contract).
|
||||||
|
- **Trust is surfaced raw** — assertion tier, `card_model`, `extraction_model`
|
||||||
|
are shown as first-class facts, never collapsed into a score.
|
||||||
|
|
||||||
|
## Typecheck
|
||||||
|
|
||||||
|
```bash
|
||||||
|
../arcadia-personal-cloud-web/node_modules/.bin/tsc -p tsconfig.check.json
|
||||||
|
```
|
||||||
|
(Borrows a consumer's React types; the lib has no node_modules of its own.)
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
| Module | Exports |
|
||||||
|
|---|---|
|
||||||
|
| `components-collections` | `CollectionList`, `CollectionCard`, `CollectionForm` |
|
||||||
|
| `components-object` | `ObjectList`, `ObjectViewer`, `CatalogCard`, `ProvenancePanel`, `SupersessionBanner`, `OutlineNav`, `TextReader`, `CitationLink` |
|
||||||
|
| `components-claims` | `ClaimsReviewQueue`, `ClaimCard`, `ConflictPair` |
|
||||||
|
| `components-export` | `ExportPanel`, `ExportJobRow` |
|
||||||
|
| `_internal` (re-exported) | badges + `cn`, `formatBytes`, `formatDate`, `formatRelative`, `renderValue` |
|
||||||
86
demo/knowledge.tsx
Normal file
86
demo/knowledge.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// 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 {
|
||||||
|
CollectionForm,
|
||||||
|
CollectionList,
|
||||||
|
MockKnowledgeTransport,
|
||||||
|
type Collection,
|
||||||
|
type CollectionInput,
|
||||||
|
type CollectionPatch,
|
||||||
|
type KnowledgeTransport,
|
||||||
|
} from "../src/index";
|
||||||
|
|
||||||
|
const transport: KnowledgeTransport = new MockKnowledgeTransport();
|
||||||
|
|
||||||
|
export default function KnowledgeDemo() {
|
||||||
|
const [collections, setCollections] = useState<Collection[]>([]);
|
||||||
|
const [pending, setPending] = useState<Record<string, number>>({});
|
||||||
|
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();
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
for (const c of queue) counts[c.collection] = (counts[c.collection] ?? 0) + 1;
|
||||||
|
setPending(counts);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6">
|
||||||
|
<header className="mb-6">
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Knowledge</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Your corpuses — browse, organise, and review.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{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) => setEditing(c)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@crema/knowledge-ui",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Knowledge-base management components (collections, object viewer, claims review, export) for the Crema design system. Headless: ships shapes + pure UI, the app injects a KnowledgeTransport.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./src/index.tsx",
|
||||||
|
"types": "./src/index.tsx",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.tsx"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"sideEffects": false,
|
||||||
|
"peerDependencies": {
|
||||||
|
"lucide-react": "^1.8.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
188
src/_internal.tsx
Normal file
188
src/_internal.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// PURPOSE: Shared internals for @crema/knowledge-ui — cn(), Spinner, formatters,
|
||||||
|
// and the KB-domain badges (sensitivity / curation / status / tier).
|
||||||
|
// Tailwind theme tokens only, never hex. Self-contained (no clsx/twMerge
|
||||||
|
// dep) so a fresh consumer needs only this lib's alias.
|
||||||
|
// ===========================================================================
|
||||||
|
import type { FC, ReactNode } from "react";
|
||||||
|
import { Lock, ShieldAlert, Globe, Sparkles, UserCheck, FileText } from "lucide-react";
|
||||||
|
import type { AssertionTier, Curation, ObjectStatus, Sensitivity } from "./types";
|
||||||
|
|
||||||
|
export function cn(...parts: (string | false | null | undefined)[]): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Spinner: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-4 animate-spin rounded-full border-2 border-current border-t-transparent opacity-60",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- formatters -----------------------------------------------------------
|
||||||
|
|
||||||
|
export function formatBytes(bytes?: number | null, decimals = 1): string {
|
||||||
|
if (bytes == null || bytes <= 0) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : decimals)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(d?: string | number | Date | null): string {
|
||||||
|
if (!d) return "—";
|
||||||
|
const date = new Date(d);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
return date.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelative(d?: string | number | Date | null): string {
|
||||||
|
if (!d) return "—";
|
||||||
|
const date = new Date(d);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const diff = Date.now() - date.getTime();
|
||||||
|
const mins = Math.round(diff / 60000);
|
||||||
|
if (Math.abs(mins) < 60) return rel(mins, "minute");
|
||||||
|
const hrs = Math.round(mins / 60);
|
||||||
|
if (Math.abs(hrs) < 24) return rel(hrs, "hour");
|
||||||
|
const days = Math.round(hrs / 24);
|
||||||
|
if (Math.abs(days) < 30) return rel(days, "day");
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rel(n: number, unit: string): string {
|
||||||
|
const abs = Math.abs(n);
|
||||||
|
const u = abs === 1 ? unit : `${unit}s`;
|
||||||
|
return n <= 0 ? `${abs} ${u} ago` : `in ${abs} ${u}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a claim/JSON value compactly for cards and one-liners. */
|
||||||
|
export function renderValue(value: unknown): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const o = value as Record<string, unknown>;
|
||||||
|
if ("amount" in o && "currency" in o) return `${o.currency} ${o.amount}`;
|
||||||
|
return Object.entries(o)
|
||||||
|
.map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageMime(mime?: string | null): boolean {
|
||||||
|
return !!mime && mime.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- badges ---------------------------------------------------------------
|
||||||
|
|
||||||
|
const badgeBase =
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium leading-none";
|
||||||
|
|
||||||
|
export const Badge: FC<{ tone?: string; icon?: ReactNode; children: ReactNode; className?: string; title?: string }> = ({
|
||||||
|
tone,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
}) => (
|
||||||
|
<span className={cn(badgeBase, tone, className)} title={title}>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SENSITIVITY: Record<Sensitivity, { label: string; tone: string; icon: ReactNode; title: string }> = {
|
||||||
|
open: {
|
||||||
|
label: "Open",
|
||||||
|
tone: "bg-muted text-muted-foreground",
|
||||||
|
icon: <Globe className="size-3" />,
|
||||||
|
title: "Open — names and structure may go to any AI model.",
|
||||||
|
},
|
||||||
|
restricted: {
|
||||||
|
label: "Restricted",
|
||||||
|
tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]",
|
||||||
|
icon: <ShieldAlert className="size-3" />,
|
||||||
|
title: "Restricted — amounts and personal detail; approved AI destinations only, via redaction.",
|
||||||
|
},
|
||||||
|
vault: {
|
||||||
|
label: "Vault",
|
||||||
|
tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive",
|
||||||
|
icon: <Lock className="size-3" />,
|
||||||
|
title: "Vault — identity/medical grade; never sent to any AI model.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SensitivityBadge: FC<{ level: Sensitivity; className?: string }> = ({ level, className }) => {
|
||||||
|
const s = SENSITIVITY[level];
|
||||||
|
return (
|
||||||
|
<Badge tone={s.tone} icon={s.icon} className={className} title={s.title}>
|
||||||
|
{s.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => {
|
||||||
|
const gated = curation === "gated";
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
tone={gated ? "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]" : "bg-muted text-muted-foreground"}
|
||||||
|
className={className}
|
||||||
|
title={gated ? "Gated — an agent's claims stay hidden until you confirm them." : "Live — an agent's claims appear immediately, labelled by tier."}
|
||||||
|
>
|
||||||
|
{gated ? "Gated" : "Live"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS: Record<ObjectStatus, { label: string; tone: string }> = {
|
||||||
|
active: { label: "Active", tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]" },
|
||||||
|
ingesting: { label: "Processing", tone: "bg-muted text-muted-foreground" },
|
||||||
|
proposed: { label: "Proposed", tone: "bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" },
|
||||||
|
superseded: { label: "Superseded", tone: "bg-muted text-muted-foreground" },
|
||||||
|
archived: { label: "Archived", tone: "bg-muted text-muted-foreground" },
|
||||||
|
failed: { label: "Failed", tone: "bg-[color-mix(in_oklab,var(--destructive)_15%,transparent)] text-destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusBadge: FC<{ status: ObjectStatus; className?: string }> = ({ status, className }) => {
|
||||||
|
const s = STATUS[status] ?? STATUS.active;
|
||||||
|
return (
|
||||||
|
<Badge tone={s.tone} className={className}>
|
||||||
|
{s.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER: Record<AssertionTier, { label: string; tone: string; icon: ReactNode; title: string }> = {
|
||||||
|
extracted: {
|
||||||
|
label: "Extracted",
|
||||||
|
tone: "bg-muted text-muted-foreground",
|
||||||
|
icon: <FileText className="size-3" />,
|
||||||
|
title: "Extracted by a model from a source document.",
|
||||||
|
},
|
||||||
|
agent_asserted: {
|
||||||
|
label: "Agent-asserted",
|
||||||
|
tone: "bg-[color-mix(in_oklab,var(--info,var(--primary))_16%,transparent)] text-[var(--info,var(--primary))]",
|
||||||
|
icon: <Sparkles className="size-3" />,
|
||||||
|
title: "Asserted by an agent — not yet confirmed by a person.",
|
||||||
|
},
|
||||||
|
human_confirmed: {
|
||||||
|
label: "You confirmed",
|
||||||
|
tone: "bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-[var(--success)]",
|
||||||
|
icon: <UserCheck className="size-3" />,
|
||||||
|
title: "Confirmed by a person.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The trust surface — categorical, never a score ([[feedback_trust_is_human]]). */
|
||||||
|
export const TierBadge: FC<{ tier: AssertionTier; className?: string }> = ({ tier, className }) => {
|
||||||
|
const t = TIER[tier];
|
||||||
|
return (
|
||||||
|
<Badge tone={t.tone} icon={t.icon} className={className} title={t.title}>
|
||||||
|
{t.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
src/components-claims.tsx
Normal file
2
src/components-claims.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Placeholder — filled in its workstream (W4/W5/W6).
|
||||||
|
export {};
|
||||||
351
src/components-collections.tsx
Normal file
351
src/components-collections.tsx
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// PURPOSE: Collection browser + create/settings form (spec §3.4). Grouped by
|
||||||
|
// owner — "Your corpuses" (account) then each organisation (tenant).
|
||||||
|
// Props in, callbacks out; the app owns fetching + routing.
|
||||||
|
// ===========================================================================
|
||||||
|
import { useState, type FC, type ReactNode } from "react";
|
||||||
|
import { Plus, Users, User, ChevronRight, Loader2 } from "lucide-react";
|
||||||
|
import type { Collection, CollectionInput, CollectionPatch, Curation, Sensitivity } from "./types";
|
||||||
|
import { Badge, CurationBadge, SensitivityBadge, cn } from "./_internal";
|
||||||
|
|
||||||
|
const SENS_ORDER: Sensitivity[] = ["open", "restricted", "vault"];
|
||||||
|
|
||||||
|
// ---- CollectionCard -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CollectionCardProps {
|
||||||
|
collection: Collection;
|
||||||
|
pendingCount?: number;
|
||||||
|
onOpen?: (collection: Collection) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionCard: FC<CollectionCardProps> = ({ collection, pendingCount, onOpen, className }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-open-collection"
|
||||||
|
data-slug={collection.slug}
|
||||||
|
onClick={() => onOpen?.(collection)}
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full flex-col gap-3 rounded-xl border border-border bg-card p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate font-medium text-card-foreground">{collection.name}</h3>
|
||||||
|
{collection.description && (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{collection.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="mt-1 size-4 shrink-0 text-muted-foreground transition group-hover:translate-x-0.5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<SensitivityBadge level={collection.sensitivity} />
|
||||||
|
<CurationBadge curation={collection.curation} />
|
||||||
|
{collection.claim_extraction === "auto" && (
|
||||||
|
<Badge tone="bg-muted text-muted-foreground" title="New documents are scanned for claims automatically.">
|
||||||
|
Auto-claims
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{collection.object_count ?? 0} {collection.object_count === 1 ? "item" : "items"}
|
||||||
|
</span>
|
||||||
|
{pendingCount != null && pendingCount > 0 && (
|
||||||
|
<Badge tone="bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[var(--warning)]" title="Claims awaiting your review.">
|
||||||
|
{pendingCount} to review
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- CollectionList (grouped by owner) ------------------------------------
|
||||||
|
|
||||||
|
export interface CollectionListProps {
|
||||||
|
collections: Collection[];
|
||||||
|
/** Pending review counts by collection slug, from the app's reviewQueue call. */
|
||||||
|
pendingCounts?: Record<string, number>;
|
||||||
|
/** Heading for the tenant-owned group (the current organisation's name). */
|
||||||
|
orgName?: string;
|
||||||
|
/** Show a "New corpus" affordance (personal always; org only if admin). */
|
||||||
|
onCreate?: (ownerType: "account" | "tenant") => void;
|
||||||
|
canManageTenant?: boolean;
|
||||||
|
onOpen?: (collection: Collection) => void;
|
||||||
|
emptyState?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionList: FC<CollectionListProps> = ({
|
||||||
|
collections,
|
||||||
|
pendingCounts,
|
||||||
|
orgName = "Organisation",
|
||||||
|
onCreate,
|
||||||
|
canManageTenant = false,
|
||||||
|
onOpen,
|
||||||
|
emptyState,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const personal = collections.filter((c) => c.owner_type === "account");
|
||||||
|
const org = collections.filter((c) => c.owner_type === "tenant");
|
||||||
|
|
||||||
|
if (collections.length === 0 && emptyState) return <>{emptyState}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-8", className)}>
|
||||||
|
<Group
|
||||||
|
title="Your corpuses"
|
||||||
|
icon={<User className="size-4" />}
|
||||||
|
collections={personal}
|
||||||
|
pendingCounts={pendingCounts}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onCreate={onCreate ? () => onCreate("account") : undefined}
|
||||||
|
createLabel="New corpus"
|
||||||
|
/>
|
||||||
|
{(org.length > 0 || canManageTenant) && (
|
||||||
|
<Group
|
||||||
|
title={orgName}
|
||||||
|
icon={<Users className="size-4" />}
|
||||||
|
collections={org}
|
||||||
|
pendingCounts={pendingCounts}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onCreate={canManageTenant && onCreate ? () => onCreate("tenant") : undefined}
|
||||||
|
createLabel="New org corpus"
|
||||||
|
emptyHint={org.length === 0 ? "No shared corpuses yet." : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Group: FC<{
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
collections: Collection[];
|
||||||
|
pendingCounts?: Record<string, number>;
|
||||||
|
onOpen?: (c: Collection) => void;
|
||||||
|
onCreate?: () => void;
|
||||||
|
createLabel: string;
|
||||||
|
emptyHint?: string;
|
||||||
|
}> = ({ title, icon, collections, pendingCounts, onOpen, onCreate, createLabel, emptyHint }) => (
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
<span className="text-xs text-muted-foreground">({collections.length})</span>
|
||||||
|
{onCreate && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="knowledge-new-corpus"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="ml-auto inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-primary transition hover:bg-primary/10"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
{createLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collections.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{emptyHint ?? "Nothing here yet."}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{collections.map((c) => (
|
||||||
|
<CollectionCard key={c.slug} collection={c} pendingCount={pendingCounts?.[c.slug]} onOpen={onOpen} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- CollectionForm (create + settings) -----------------------------------
|
||||||
|
|
||||||
|
export interface CollectionFormProps {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
/** In edit mode, the collection being configured. */
|
||||||
|
initial?: Collection;
|
||||||
|
/** Fixed owner for a create form opened from a specific group. */
|
||||||
|
ownerType?: "account" | "tenant";
|
||||||
|
ownerId?: string;
|
||||||
|
orgName?: string;
|
||||||
|
canManageTenant?: boolean;
|
||||||
|
busy?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onSubmit: (value: CollectionInput | CollectionPatch) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollectionForm: FC<CollectionFormProps> = ({
|
||||||
|
mode,
|
||||||
|
initial,
|
||||||
|
ownerType = "account",
|
||||||
|
ownerId,
|
||||||
|
orgName = "Organisation",
|
||||||
|
canManageTenant = false,
|
||||||
|
busy = false,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const readOnly = isEdit && initial?.owner_type === "tenant" && !canManageTenant;
|
||||||
|
|
||||||
|
const [slug, setSlug] = useState(initial?.slug ?? "");
|
||||||
|
const [name, setName] = useState(initial?.name ?? "");
|
||||||
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
|
const [sensitivity, setSensitivity] = useState<Sensitivity>(initial?.sensitivity ?? "open");
|
||||||
|
const [curation, setCuration] = useState<Curation>(initial?.curation ?? "live");
|
||||||
|
const [claimAuto, setClaimAuto] = useState((initial?.claim_extraction ?? "off") === "auto");
|
||||||
|
const [owner, setOwner] = useState<"account" | "tenant">(ownerType);
|
||||||
|
|
||||||
|
// Sensitivity is stricter-only on edit: you may raise, never lower.
|
||||||
|
const minIdx = isEdit ? SENS_ORDER.indexOf(initial?.sensitivity ?? "open") : 0;
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
if (isEdit) {
|
||||||
|
const patch: CollectionPatch = {
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
sensitivity,
|
||||||
|
curation,
|
||||||
|
claim_extraction: claimAuto ? "auto" : "off",
|
||||||
|
};
|
||||||
|
onSubmit(patch);
|
||||||
|
} else {
|
||||||
|
const input: CollectionInput = {
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
sensitivity,
|
||||||
|
curation,
|
||||||
|
claim_extraction: claimAuto ? "auto" : "off",
|
||||||
|
...(owner === "tenant" ? { owner_type: "tenant", owner_id: ownerId } : {}),
|
||||||
|
};
|
||||||
|
onSubmit(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className={cn("flex flex-col gap-5", className)}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!readOnly) submit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!isEdit && canManageTenant && (
|
||||||
|
<Field label="Owner" hint="Personal corpuses are yours alone; organisation corpuses are shared with your team.">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<OwnerToggle active={owner === "account"} onClick={() => setOwner("account")} icon={<User className="size-3.5" />} label="Personal" />
|
||||||
|
<OwnerToggle active={owner === "tenant"} onClick={() => setOwner("tenant")} icon={<Users className="size-3.5" />} label={orgName} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isEdit && (
|
||||||
|
<Field label="Slug" hint="Lower-case, hyphenated. Can't be changed later.">
|
||||||
|
<input
|
||||||
|
data-action="knowledge-corpus-slug"
|
||||||
|
className={inputCls}
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))}
|
||||||
|
placeholder="supplier-docs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Name">
|
||||||
|
<input className={inputCls} value={name} onChange={(e) => setName(e.target.value)} disabled={readOnly} required />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Description" hint="What belongs in this corpus?">
|
||||||
|
<textarea
|
||||||
|
className={cn(inputCls, "min-h-[64px] resize-y")}
|
||||||
|
value={description ?? ""}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
disabled={readOnly}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Sensitivity" hint="Controls what may leave for an AI model. You can raise this later, never lower it.">
|
||||||
|
<select
|
||||||
|
className={inputCls}
|
||||||
|
value={sensitivity}
|
||||||
|
onChange={(e) => setSensitivity(e.target.value as Sensitivity)}
|
||||||
|
disabled={readOnly}
|
||||||
|
>
|
||||||
|
{SENS_ORDER.map((s, i) => (
|
||||||
|
<option key={s} value={s} disabled={i < minIdx}>
|
||||||
|
{s === "open" ? "Open — anywhere" : s === "restricted" ? "Restricted — approved AI only" : "Vault — never sent to AI"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Claim curation" hint="Gated: an agent's claims stay hidden until you confirm them. Live: they appear immediately, labelled by tier.">
|
||||||
|
<select className={inputCls} value={curation} onChange={(e) => setCuration(e.target.value as Curation)} disabled={readOnly}>
|
||||||
|
<option value="live">Live</option>
|
||||||
|
<option value="gated">Gated</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className={cn("flex items-center gap-2 text-sm", readOnly && "opacity-60")}>
|
||||||
|
<input type="checkbox" checked={claimAuto} onChange={(e) => setClaimAuto(e.target.checked)} disabled={readOnly} />
|
||||||
|
<span>Scan new documents for claims automatically</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{readOnly && (
|
||||||
|
<p className="rounded-lg bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||||
|
Only a {orgName} admin can change these settings.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{onCancel && (
|
||||||
|
<button type="button" onClick={onCancel} className="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
data-action="knowledge-save-corpus"
|
||||||
|
disabled={busy}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{isEdit ? "Save changes" : "Create corpus"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none transition focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:opacity-60";
|
||||||
|
|
||||||
|
const Field: FC<{ label: string; hint?: string; children: ReactNode }> = ({ label, hint, children }) => (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const OwnerToggle: FC<{ active: boolean; onClick: () => void; icon: ReactNode; label: string }> = ({ active, onClick, icon, label }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition",
|
||||||
|
active ? "border-primary bg-primary/10 text-primary" : "border-border text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
2
src/components-export.tsx
Normal file
2
src/components-export.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Placeholder — filled in its workstream (W4/W5/W6).
|
||||||
|
export {};
|
||||||
2
src/components-object.tsx
Normal file
2
src/components-object.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Placeholder — filled in its workstream (W4/W5/W6).
|
||||||
|
export {};
|
||||||
37
src/index.tsx
Normal file
37
src/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// PURPOSE: @crema/knowledge-ui barrel — knowledge-base management components
|
||||||
|
// (collections, object viewer, claims review, export) over an injected
|
||||||
|
// KnowledgeTransport. Headless: shapes + pure UI, no data fetching.
|
||||||
|
// ===========================================================================
|
||||||
|
// EXPORTS
|
||||||
|
// Types + transport: all of ./types, KnowledgeTransport, MockKnowledgeTransport
|
||||||
|
// Badges/formatters: SensitivityBadge, CurationBadge, StatusBadge, TierBadge,
|
||||||
|
// Badge, Spinner, cn, formatBytes, formatDate, formatRelative,
|
||||||
|
// renderValue, isImageMime
|
||||||
|
// Collections (W3): CollectionList, CollectionCard, CollectionForm
|
||||||
|
// Object viewer (W4):ObjectList, ObjectViewer, CatalogCard, ProvenancePanel,
|
||||||
|
// SupersessionBanner, OutlineNav, TextReader, CitationLink
|
||||||
|
// Claims (W5): ClaimsReviewQueue, ClaimCard, ConflictPair
|
||||||
|
// Export (W6): ExportPanel, ExportJobRow
|
||||||
|
// ===========================================================================
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./transport";
|
||||||
|
export {
|
||||||
|
cn,
|
||||||
|
Spinner,
|
||||||
|
Badge,
|
||||||
|
SensitivityBadge,
|
||||||
|
CurationBadge,
|
||||||
|
StatusBadge,
|
||||||
|
TierBadge,
|
||||||
|
formatBytes,
|
||||||
|
formatDate,
|
||||||
|
formatRelative,
|
||||||
|
renderValue,
|
||||||
|
isImageMime,
|
||||||
|
} from "./_internal";
|
||||||
|
export * from "./components-collections";
|
||||||
|
export * from "./components-object";
|
||||||
|
export * from "./components-claims";
|
||||||
|
export * from "./components-export";
|
||||||
651
src/transport.ts
Normal file
651
src/transport.ts
Normal file
@@ -0,0 +1,651 @@
|
|||||||
|
// PURPOSE: The KnowledgeTransport seam (à la @crema/agent-dock-ui's transport).
|
||||||
|
// The lib DEFINES this interface; the app IMPLEMENTS it over its KB API
|
||||||
|
// client, closing auth over inside — components never see a token.
|
||||||
|
// MockKnowledgeTransport gives the demo + component dev a service-free
|
||||||
|
// fixture set covering every surface.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
import type {
|
||||||
|
Claim,
|
||||||
|
ClaimFilter,
|
||||||
|
Collection,
|
||||||
|
CollectionInput,
|
||||||
|
CollectionPage,
|
||||||
|
CollectionPatch,
|
||||||
|
ExportJob,
|
||||||
|
ExportScope,
|
||||||
|
ObjectOutline,
|
||||||
|
ObjectPage,
|
||||||
|
ObjectPatch,
|
||||||
|
ObjectRef,
|
||||||
|
ResolveAction,
|
||||||
|
SearchInput,
|
||||||
|
SearchPage,
|
||||||
|
TextSlice,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export interface KnowledgeTransport {
|
||||||
|
// collections
|
||||||
|
listCollections(cursor?: string): Promise<CollectionPage>;
|
||||||
|
createCollection(input: CollectionInput): Promise<Collection>;
|
||||||
|
updateCollection(slug: string, patch: CollectionPatch): Promise<Collection>;
|
||||||
|
// objects
|
||||||
|
listObjects(collection: string, cursor?: string): Promise<ObjectPage>;
|
||||||
|
ingest(collection: string, file: File, meta?: { title?: string }): Promise<ObjectRef>;
|
||||||
|
search(q: SearchInput): Promise<SearchPage>;
|
||||||
|
outline(objectId: string): Promise<ObjectOutline>;
|
||||||
|
readText(
|
||||||
|
objectId: string,
|
||||||
|
sel?: { section?: string; start?: number; end?: number },
|
||||||
|
): Promise<TextSlice>;
|
||||||
|
/** Resolve a browser-openable URL for the original blob (spec §6.2). */
|
||||||
|
blobUrl(objectId: string): Promise<string>;
|
||||||
|
archiveObject(objectId: string): Promise<void>;
|
||||||
|
updateObject(objectId: string, patch: ObjectPatch): Promise<ObjectRef>;
|
||||||
|
supersedeObject(objectId: string, replacementId: string): Promise<void>;
|
||||||
|
extractClaims(objectId: string): Promise<{ extracted: number }>;
|
||||||
|
// claims
|
||||||
|
reviewQueue(collection?: string): Promise<Claim[]>;
|
||||||
|
listClaims(filter: ClaimFilter): Promise<Claim[]>;
|
||||||
|
confirmClaim(id: string, resolve?: ResolveAction): Promise<Claim>;
|
||||||
|
rejectClaim(id: string): Promise<Claim>;
|
||||||
|
dismissConflict(id: string): Promise<Claim>;
|
||||||
|
// exports
|
||||||
|
createExport(scope: ExportScope): Promise<ExportJob>;
|
||||||
|
getExport(id: string): Promise<ExportJob>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// MockKnowledgeTransport — fixtures for the demo + component development.
|
||||||
|
// Stateful in-memory so mutations (create/confirm/reject/export) are visible.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
const wait = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
function seedCollections(): Collection[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
slug: "my-documents",
|
||||||
|
name: "My Documents",
|
||||||
|
description: "Everything you promote from your file store.",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
owner_type: "account",
|
||||||
|
curation: "live",
|
||||||
|
claim_extraction: "off",
|
||||||
|
object_count: 4,
|
||||||
|
updated_at: "2026-07-06T10:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "reading-list",
|
||||||
|
name: "Reading List",
|
||||||
|
description: "Articles and references worth keeping.",
|
||||||
|
sensitivity: "open",
|
||||||
|
owner_type: "account",
|
||||||
|
curation: "live",
|
||||||
|
claim_extraction: "off",
|
||||||
|
object_count: 1,
|
||||||
|
updated_at: "2026-07-05T08:30:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "acme-supplier-docs",
|
||||||
|
name: "Supplier Docs",
|
||||||
|
description: "Contracts and terms shared across the Acme team.",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
owner_type: "tenant",
|
||||||
|
curation: "gated",
|
||||||
|
claim_extraction: "auto",
|
||||||
|
object_count: 3,
|
||||||
|
updated_at: "2026-07-06T12:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockObject = { summary: import("./types").ObjectSummary; outline: ObjectOutline; text: string };
|
||||||
|
|
||||||
|
function seedObjects(): Record<string, MockObject[]> {
|
||||||
|
const doc: MockObject = {
|
||||||
|
summary: {
|
||||||
|
object_id: "obj-terms",
|
||||||
|
kind: "document",
|
||||||
|
title: "Acme master services agreement",
|
||||||
|
status: "active",
|
||||||
|
updated_at: "2026-07-06T10:00:00Z",
|
||||||
|
effective_at: "2026-01-01",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
object_id: "obj-terms",
|
||||||
|
title: "Acme master services agreement",
|
||||||
|
kind: "document",
|
||||||
|
collection: "my-documents",
|
||||||
|
status: "active",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
summary:
|
||||||
|
"Master services agreement with Acme Pty Ltd. Net-30 payment terms, 12-month term with auto-renewal, liability capped at fees paid in the prior 12 months.",
|
||||||
|
entities: [
|
||||||
|
{ name: "Acme Pty Ltd", type: "organization" },
|
||||||
|
{ name: "net-30", type: "term" },
|
||||||
|
],
|
||||||
|
tags: ["contract", "supplier", "acme"],
|
||||||
|
language: "en",
|
||||||
|
mime: "application/pdf",
|
||||||
|
byte_size: 184_320,
|
||||||
|
has_blob: true,
|
||||||
|
outline: [
|
||||||
|
{ id: "s1", heading: "1. Definitions", level: 1, start: 0, end: 220 },
|
||||||
|
{ id: "s2", heading: "2. Payment terms", level: 1, start: 220, end: 480 },
|
||||||
|
{ id: "s3", heading: "3. Term & termination", level: 1, start: 480, end: 720 },
|
||||||
|
],
|
||||||
|
provenance: {
|
||||||
|
source_type: "upload",
|
||||||
|
source_ref: "file:contract.pdf",
|
||||||
|
created_by: "account:you",
|
||||||
|
inserted_at: "2026-07-06T10:00:00Z",
|
||||||
|
effective_at: "2026-01-01",
|
||||||
|
verified_at: "2026-07-06T10:05:00Z",
|
||||||
|
card_model: "claude-haiku-4-5",
|
||||||
|
extraction_model: null,
|
||||||
|
},
|
||||||
|
supersedes: null,
|
||||||
|
superseded_by: null,
|
||||||
|
extracted_chars: 720,
|
||||||
|
cite: "kb://obj-terms#0-720",
|
||||||
|
kb_abi_version: 2,
|
||||||
|
},
|
||||||
|
text:
|
||||||
|
"1. Definitions\nThis agreement (the “Agreement”) is between Acme Pty Ltd (“Supplier”) and the Customer. Capitalised terms have the meanings given below.\n\n" +
|
||||||
|
"2. Payment terms\nThe Customer shall pay all undisputed invoices within thirty (30) days of the invoice date (net-30). Late amounts accrue interest at 1.5% per month.\n\n" +
|
||||||
|
"3. Term & termination\nThis Agreement runs for twelve (12) months from the Effective Date and renews automatically for successive 12-month terms unless either party gives 60 days’ notice.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const superseded: MockObject = {
|
||||||
|
summary: {
|
||||||
|
object_id: "obj-terms-old",
|
||||||
|
kind: "document",
|
||||||
|
title: "Acme MSA (2025 edition)",
|
||||||
|
status: "superseded",
|
||||||
|
updated_at: "2026-01-01T00:00:00Z",
|
||||||
|
effective_at: "2025-01-01",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
object_id: "obj-terms-old",
|
||||||
|
title: "Acme MSA (2025 edition)",
|
||||||
|
kind: "document",
|
||||||
|
collection: "my-documents",
|
||||||
|
status: "superseded",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
summary: "Prior year's master services agreement. Net-45 terms. Superseded by the 2026 edition.",
|
||||||
|
entities: [{ name: "Acme Pty Ltd", type: "organization" }],
|
||||||
|
tags: ["contract", "supplier", "acme", "archive"],
|
||||||
|
language: "en",
|
||||||
|
mime: "application/pdf",
|
||||||
|
byte_size: 180_000,
|
||||||
|
has_blob: true,
|
||||||
|
outline: [{ id: "s1", heading: "Payment terms", level: 1, start: 0, end: 200 }],
|
||||||
|
provenance: {
|
||||||
|
source_type: "upload",
|
||||||
|
created_by: "account:you",
|
||||||
|
inserted_at: "2025-01-01T00:00:00Z",
|
||||||
|
effective_at: "2025-01-01",
|
||||||
|
card_model: "claude-haiku-4-5",
|
||||||
|
extraction_model: null,
|
||||||
|
},
|
||||||
|
supersedes: null,
|
||||||
|
superseded_by: "obj-terms",
|
||||||
|
extracted_chars: 200,
|
||||||
|
cite: "kb://obj-terms-old#0-200",
|
||||||
|
kb_abi_version: 2,
|
||||||
|
},
|
||||||
|
text: "Payment terms\nThe Customer shall pay all undisputed invoices within forty-five (45) days (net-45).",
|
||||||
|
};
|
||||||
|
|
||||||
|
const image: MockObject = {
|
||||||
|
summary: {
|
||||||
|
object_id: "obj-receipt",
|
||||||
|
kind: "document",
|
||||||
|
title: "Office supplies receipt (June)",
|
||||||
|
status: "active",
|
||||||
|
updated_at: "2026-06-30T00:00:00Z",
|
||||||
|
effective_at: "2026-06-28",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
object_id: "obj-receipt",
|
||||||
|
title: "Office supplies receipt (June)",
|
||||||
|
kind: "document",
|
||||||
|
collection: "my-documents",
|
||||||
|
status: "active",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
summary:
|
||||||
|
"Photographed receipt from Officeworks, 28 June 2026. Total AUD 142.60 incl. GST. Line items: printer paper, pens, a desk lamp.",
|
||||||
|
entities: [
|
||||||
|
{ name: "Officeworks", type: "organization" },
|
||||||
|
{ name: "AUD 142.60", type: "amount" },
|
||||||
|
],
|
||||||
|
tags: ["receipt", "expense", "june"],
|
||||||
|
language: "en",
|
||||||
|
mime: "image/jpeg",
|
||||||
|
byte_size: 512_000,
|
||||||
|
has_blob: true,
|
||||||
|
outline: [{ id: "s1", heading: "Transcription", level: 1, start: 0, end: 260 }],
|
||||||
|
provenance: {
|
||||||
|
source_type: "upload",
|
||||||
|
created_by: "account:you",
|
||||||
|
inserted_at: "2026-06-30T00:00:00Z",
|
||||||
|
effective_at: "2026-06-28",
|
||||||
|
card_model: "claude-haiku-4-5",
|
||||||
|
extraction_model: "claude-haiku-4-5-vision",
|
||||||
|
},
|
||||||
|
supersedes: null,
|
||||||
|
superseded_by: null,
|
||||||
|
extracted_chars: 260,
|
||||||
|
cite: "kb://obj-receipt#0-260",
|
||||||
|
kb_abi_version: 2,
|
||||||
|
},
|
||||||
|
text:
|
||||||
|
"OFFICEWORKS — 28/06/2026\nPrinter paper A4 x2 ........ 24.00\nGel pens (pack) ........... 8.60\nDesk lamp LED ............ 110.00\nSubtotal ................. 129.64\nGST (10%) ................ 12.96\nTOTAL AUD ................ 142.60",
|
||||||
|
};
|
||||||
|
|
||||||
|
const vault: MockObject = {
|
||||||
|
summary: {
|
||||||
|
object_id: "obj-passport",
|
||||||
|
kind: "document",
|
||||||
|
title: "Passport scan",
|
||||||
|
status: "active",
|
||||||
|
updated_at: "2026-05-01T00:00:00Z",
|
||||||
|
effective_at: null,
|
||||||
|
sensitivity: "vault",
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
object_id: "obj-passport",
|
||||||
|
title: "Passport scan",
|
||||||
|
kind: "document",
|
||||||
|
collection: "my-documents",
|
||||||
|
status: "active",
|
||||||
|
sensitivity: "vault",
|
||||||
|
summary: null,
|
||||||
|
entities: [],
|
||||||
|
tags: ["identity"],
|
||||||
|
language: null,
|
||||||
|
mime: "image/png",
|
||||||
|
byte_size: 1_240_000,
|
||||||
|
has_blob: true,
|
||||||
|
outline: [],
|
||||||
|
provenance: {
|
||||||
|
source_type: "upload",
|
||||||
|
created_by: "account:you",
|
||||||
|
inserted_at: "2026-05-01T00:00:00Z",
|
||||||
|
card_model: null,
|
||||||
|
extraction_model: null,
|
||||||
|
},
|
||||||
|
supersedes: null,
|
||||||
|
superseded_by: null,
|
||||||
|
extracted_chars: 0,
|
||||||
|
cite: "kb://obj-passport#0-0",
|
||||||
|
kb_abi_version: 2,
|
||||||
|
},
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const article: MockObject = {
|
||||||
|
summary: {
|
||||||
|
object_id: "obj-article",
|
||||||
|
kind: "document",
|
||||||
|
title: "The case for boring technology",
|
||||||
|
status: "active",
|
||||||
|
updated_at: "2026-07-05T08:30:00Z",
|
||||||
|
effective_at: null,
|
||||||
|
sensitivity: "open",
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
object_id: "obj-article",
|
||||||
|
title: "The case for boring technology",
|
||||||
|
kind: "document",
|
||||||
|
collection: "reading-list",
|
||||||
|
status: "active",
|
||||||
|
sensitivity: "open",
|
||||||
|
summary: "An argument for choosing well-understood tools over novel ones, to spend innovation budget wisely.",
|
||||||
|
entities: [],
|
||||||
|
tags: ["engineering", "essay"],
|
||||||
|
language: "en",
|
||||||
|
mime: "text/html",
|
||||||
|
byte_size: 24_000,
|
||||||
|
has_blob: false,
|
||||||
|
outline: [{ id: "s1", heading: "Innovation tokens", level: 1, start: 0, end: 300 }],
|
||||||
|
provenance: {
|
||||||
|
source_type: "url",
|
||||||
|
source_ref: "https://example.com/boring",
|
||||||
|
created_by: "account:you",
|
||||||
|
inserted_at: "2026-07-05T08:30:00Z",
|
||||||
|
card_model: "claude-haiku-4-5",
|
||||||
|
extraction_model: null,
|
||||||
|
},
|
||||||
|
supersedes: null,
|
||||||
|
superseded_by: null,
|
||||||
|
extracted_chars: 300,
|
||||||
|
cite: "kb://obj-article#0-300",
|
||||||
|
kb_abi_version: 2,
|
||||||
|
},
|
||||||
|
text: "Innovation tokens\nEvery company gets a small number of them. Spend them where they matter, and pick boring, proven technology everywhere else.",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
"my-documents": [doc, superseded, image, vault],
|
||||||
|
"reading-list": [article],
|
||||||
|
"acme-supplier-docs": [doc],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedClaims(): Claim[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
claim_id: "claim-terms",
|
||||||
|
subject: "supplier:acme",
|
||||||
|
predicate: "payment_terms",
|
||||||
|
value: { days: 30 },
|
||||||
|
assertion_tier: "human_confirmed",
|
||||||
|
status: "active",
|
||||||
|
review_state: "confirmed",
|
||||||
|
conflict_state: "none",
|
||||||
|
source: { object_id: "obj-terms", span: { start: 220, end: 480 }, cite: "kb://obj-terms#220-480" },
|
||||||
|
collection: "acme-supplier-docs",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
effective_at: "2026-01-01",
|
||||||
|
verified_at: "2026-07-06T10:05:00Z",
|
||||||
|
verified_by: "account:you",
|
||||||
|
created_by: "agent:bookkeeper",
|
||||||
|
updated_at: "2026-07-06T10:05:00Z",
|
||||||
|
cite: "kb://claim-terms",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claim_id: "claim-proposed",
|
||||||
|
subject: "supplier:acme",
|
||||||
|
predicate: "delivery_window",
|
||||||
|
value: { business_days: 5 },
|
||||||
|
assertion_tier: "agent_asserted",
|
||||||
|
status: "proposed",
|
||||||
|
review_state: "unreviewed",
|
||||||
|
conflict_state: "none",
|
||||||
|
source: { object_id: "obj-terms", span: { start: 480, end: 720 }, cite: "kb://obj-terms#480-720" },
|
||||||
|
collection: "acme-supplier-docs",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
created_by: "agent:bookkeeper",
|
||||||
|
updated_at: "2026-07-06T11:00:00Z",
|
||||||
|
cite: "kb://claim-proposed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claim_id: "claim-conflict-a",
|
||||||
|
subject: "supplier:acme",
|
||||||
|
predicate: "late_fee_rate",
|
||||||
|
value: { percent_per_month: 1.5 },
|
||||||
|
assertion_tier: "human_confirmed",
|
||||||
|
status: "active",
|
||||||
|
review_state: "confirmed",
|
||||||
|
conflict_state: "open",
|
||||||
|
conflicts_with: ["claim-conflict-b"],
|
||||||
|
source: { object_id: "obj-terms", span: { start: 220, end: 480 } },
|
||||||
|
collection: "acme-supplier-docs",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
verified_by: "account:you",
|
||||||
|
created_by: "account:you",
|
||||||
|
updated_at: "2026-07-02T00:00:00Z",
|
||||||
|
cite: "kb://claim-conflict-a",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claim_id: "claim-conflict-b",
|
||||||
|
subject: "supplier:acme",
|
||||||
|
predicate: "late_fee_rate",
|
||||||
|
value: { percent_per_month: 2.0 },
|
||||||
|
assertion_tier: "extracted",
|
||||||
|
status: "active",
|
||||||
|
review_state: "unreviewed",
|
||||||
|
conflict_state: "open",
|
||||||
|
conflicts_with: ["claim-conflict-a"],
|
||||||
|
source: { object_id: "obj-terms", span: { start: 220, end: 480 }, cite: "kb://obj-terms#220-480" },
|
||||||
|
collection: "acme-supplier-docs",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
created_by: "agent:extractor",
|
||||||
|
updated_at: "2026-07-06T09:00:00Z",
|
||||||
|
cite: "kb://claim-conflict-b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
claim_id: "claim-rejected",
|
||||||
|
subject: "supplier:acme",
|
||||||
|
predicate: "contact_email",
|
||||||
|
value: "sales@acme.example",
|
||||||
|
assertion_tier: "agent_asserted",
|
||||||
|
status: "archived",
|
||||||
|
review_state: "rejected",
|
||||||
|
conflict_state: "none",
|
||||||
|
collection: "acme-supplier-docs",
|
||||||
|
sensitivity: "restricted",
|
||||||
|
created_by: "agent:bookkeeper",
|
||||||
|
updated_at: "2026-07-04T00:00:00Z",
|
||||||
|
cite: "kb://claim-rejected",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockKnowledgeTransport implements KnowledgeTransport {
|
||||||
|
private collections = seedCollections();
|
||||||
|
private objects = seedObjects();
|
||||||
|
private claims = seedClaims();
|
||||||
|
private exports: ExportJob[] = [];
|
||||||
|
private exportSeq = 0;
|
||||||
|
|
||||||
|
async listCollections(): Promise<CollectionPage> {
|
||||||
|
await wait();
|
||||||
|
return { collections: this.collections, next_cursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCollection(input: CollectionInput): Promise<Collection> {
|
||||||
|
await wait();
|
||||||
|
const created: Collection = {
|
||||||
|
slug: input.slug,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? null,
|
||||||
|
sensitivity: input.sensitivity ?? "open",
|
||||||
|
owner_type: input.owner_type ?? "account",
|
||||||
|
curation: input.curation ?? "live",
|
||||||
|
claim_extraction: input.claim_extraction ?? "off",
|
||||||
|
object_count: 0,
|
||||||
|
updated_at: "2026-07-07T00:00:00Z",
|
||||||
|
};
|
||||||
|
this.collections = [...this.collections, created];
|
||||||
|
this.objects[created.slug] = [];
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCollection(slug: string, patch: CollectionPatch): Promise<Collection> {
|
||||||
|
await wait();
|
||||||
|
this.collections = this.collections.map((c) => (c.slug === slug ? { ...c, ...patch } : c));
|
||||||
|
const found = this.collections.find((c) => c.slug === slug);
|
||||||
|
if (!found) throw { code: "not_found", status: 404 };
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listObjects(collection: string): Promise<ObjectPage> {
|
||||||
|
await wait();
|
||||||
|
const rows = (this.objects[collection] ?? []).map((o) => o.summary);
|
||||||
|
return { objects: rows, next_cursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(collection: string, file: File): Promise<ObjectRef> {
|
||||||
|
await wait();
|
||||||
|
const id = `obj-upload-${++this.exportSeq}`;
|
||||||
|
const ref: ObjectRef = { object_id: id, kind: "document", status: "ingesting", title: file.name };
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(q: SearchInput): Promise<SearchPage> {
|
||||||
|
await wait();
|
||||||
|
const term = q.query.toLowerCase();
|
||||||
|
const all = Object.values(this.objects).flat();
|
||||||
|
const hits = all
|
||||||
|
.filter((o) => o.summary.title.toLowerCase().includes(term) || (o.outline.summary ?? "").toLowerCase().includes(term))
|
||||||
|
.map((o) => ({
|
||||||
|
object_id: o.summary.object_id,
|
||||||
|
kind: o.summary.kind,
|
||||||
|
title: o.summary.title,
|
||||||
|
collection: o.outline.collection,
|
||||||
|
snippet: o.outline.summary ?? undefined,
|
||||||
|
score: 1,
|
||||||
|
updated_at: o.summary.updated_at,
|
||||||
|
effective_at: o.summary.effective_at,
|
||||||
|
sensitivity: o.summary.sensitivity,
|
||||||
|
cite: o.outline.cite,
|
||||||
|
}));
|
||||||
|
return { hits, next_cursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
private find(objectId: string): MockObject | undefined {
|
||||||
|
return Object.values(this.objects).flat().find((o) => o.summary.object_id === objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async outline(objectId: string): Promise<ObjectOutline> {
|
||||||
|
await wait();
|
||||||
|
const o = this.find(objectId);
|
||||||
|
if (!o) throw { code: "not_found", status: 404 };
|
||||||
|
return o.outline;
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(objectId: string, sel?: { section?: string; start?: number; end?: number }): Promise<TextSlice> {
|
||||||
|
await wait();
|
||||||
|
const o = this.find(objectId);
|
||||||
|
if (!o) throw { code: "not_found", status: 404 };
|
||||||
|
let start = sel?.start ?? 0;
|
||||||
|
let end = sel?.end ?? o.text.length;
|
||||||
|
if (sel?.section) {
|
||||||
|
const sec = o.outline.outline.find((s) => s.id === sel.section);
|
||||||
|
if (sec) {
|
||||||
|
start = sec.start;
|
||||||
|
end = sec.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = o.text.slice(start, Math.min(end, start + 20_000));
|
||||||
|
return {
|
||||||
|
object_id: objectId,
|
||||||
|
text,
|
||||||
|
range: { start, end: start + text.length },
|
||||||
|
truncated: end - start > 20_000,
|
||||||
|
cite: `kb://${objectId}#${start}-${start + text.length}`,
|
||||||
|
sensitivity: o.summary.sensitivity,
|
||||||
|
superseded_by: o.outline.superseded_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async blobUrl(objectId: string): Promise<string> {
|
||||||
|
await wait();
|
||||||
|
// A stand-in image so the viewer's image path is exercisable in the demo.
|
||||||
|
return `https://placehold.co/600x400?text=${encodeURIComponent(objectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async archiveObject(): Promise<void> {
|
||||||
|
await wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateObject(objectId: string, patch: ObjectPatch): Promise<ObjectRef> {
|
||||||
|
await wait();
|
||||||
|
const o = this.find(objectId);
|
||||||
|
if (!o) throw { code: "not_found", status: 404 };
|
||||||
|
if (patch.title) {
|
||||||
|
o.summary.title = patch.title;
|
||||||
|
o.outline.title = patch.title;
|
||||||
|
}
|
||||||
|
if (patch.tags) o.outline.tags = patch.tags;
|
||||||
|
if (patch.verified_at) o.outline.provenance.verified_at = patch.verified_at;
|
||||||
|
return { object_id: objectId, kind: o.summary.kind, status: o.summary.status, title: o.summary.title };
|
||||||
|
}
|
||||||
|
|
||||||
|
async supersedeObject(): Promise<void> {
|
||||||
|
await wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractClaims(): Promise<{ extracted: number }> {
|
||||||
|
await wait();
|
||||||
|
return { extracted: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async reviewQueue(collection?: string): Promise<Claim[]> {
|
||||||
|
await wait();
|
||||||
|
const queueable = this.claims.filter(
|
||||||
|
(c) =>
|
||||||
|
c.status === "proposed" ||
|
||||||
|
c.conflict_state === "open" ||
|
||||||
|
(c.status === "active" && c.review_state === "unreviewed"),
|
||||||
|
);
|
||||||
|
return collection ? queueable.filter((c) => c.collection === collection) : queueable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listClaims(filter: ClaimFilter): Promise<Claim[]> {
|
||||||
|
await wait();
|
||||||
|
return this.claims.filter((c) => {
|
||||||
|
if (filter.collection && c.collection !== filter.collection) return false;
|
||||||
|
if (filter.subject && c.subject !== filter.subject) return false;
|
||||||
|
if (filter.predicate && c.predicate !== filter.predicate) return false;
|
||||||
|
if (filter.status && c.status !== filter.status) return false;
|
||||||
|
if (filter.review && c.review_state !== filter.review) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmClaim(id: string, resolve?: ResolveAction): Promise<Claim> {
|
||||||
|
await wait();
|
||||||
|
this.claims = this.claims.map((c) => {
|
||||||
|
if (c.claim_id === id) {
|
||||||
|
return { ...c, status: "active", review_state: "confirmed", assertion_tier: "human_confirmed", conflict_state: resolve ? "dismissed" : c.conflict_state };
|
||||||
|
}
|
||||||
|
if (resolve && "supersede" in resolve && resolve.supersede.includes(c.claim_id)) {
|
||||||
|
return { ...c, status: "superseded", conflict_state: "none" };
|
||||||
|
}
|
||||||
|
if (resolve && "dismiss" in resolve && c.conflicts_with?.includes(id)) {
|
||||||
|
return { ...c, conflict_state: "dismissed" };
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
return this.claims.find((c) => c.claim_id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectClaim(id: string): Promise<Claim> {
|
||||||
|
await wait();
|
||||||
|
this.claims = this.claims.map((c) =>
|
||||||
|
c.claim_id === id ? { ...c, status: "archived", review_state: "rejected" } : c,
|
||||||
|
);
|
||||||
|
return this.claims.find((c) => c.claim_id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async dismissConflict(id: string): Promise<Claim> {
|
||||||
|
await wait();
|
||||||
|
this.claims = this.claims.map((c) =>
|
||||||
|
c.claim_id === id || c.conflicts_with?.includes(id) ? { ...c, conflict_state: "dismissed" } : c,
|
||||||
|
);
|
||||||
|
return this.claims.find((c) => c.claim_id === id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createExport(scope: ExportScope): Promise<ExportJob> {
|
||||||
|
await wait();
|
||||||
|
const job: ExportJob = {
|
||||||
|
id: `exp-${++this.exportSeq}`,
|
||||||
|
status: "pending",
|
||||||
|
scope,
|
||||||
|
inserted_at: "2026-07-07T00:00:00Z",
|
||||||
|
updated_at: "2026-07-07T00:00:00Z",
|
||||||
|
};
|
||||||
|
this.exports = [job, ...this.exports];
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExport(id: string): Promise<ExportJob> {
|
||||||
|
await wait();
|
||||||
|
// Advance pending → ready on first poll so the demo shows a download.
|
||||||
|
this.exports = this.exports.map((e) =>
|
||||||
|
e.id === id && e.status !== "ready"
|
||||||
|
? { ...e, status: "ready", object_count: 8, byte_size: 2_400_000, download_url: `/api/v1/exports/${id}/download` }
|
||||||
|
: e,
|
||||||
|
);
|
||||||
|
const found = this.exports.find((e) => e.id === id);
|
||||||
|
if (!found) throw { code: "not_found", status: 404 };
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
283
src/types.ts
Normal file
283
src/types.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// PURPOSE: TypeScript shapes mirroring the arcadia-knowledge HTTP/tool API
|
||||||
|
// (arcadia-knowledge-spec §4/§5, claims-spec §1/§4). Each type notes the
|
||||||
|
// endpoint it mirrors. The lib is headless — these are the contract the
|
||||||
|
// app's KnowledgeTransport fulfils; no fetching lives here.
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
export type Sensitivity = "open" | "restricted" | "vault";
|
||||||
|
export type Curation = "live" | "gated";
|
||||||
|
export type ClaimExtraction = "off" | "auto";
|
||||||
|
export type OwnerType = "account" | "tenant" | "app" | "agent";
|
||||||
|
export type ObjectKind = "document" | "claim" | "note";
|
||||||
|
export type ObjectStatus =
|
||||||
|
| "ingesting"
|
||||||
|
| "active"
|
||||||
|
| "superseded"
|
||||||
|
| "archived"
|
||||||
|
| "failed"
|
||||||
|
| "proposed";
|
||||||
|
export type AssertionTier = "extracted" | "agent_asserted" | "human_confirmed";
|
||||||
|
export type ReviewState = "unreviewed" | "confirmed" | "rejected";
|
||||||
|
export type ConflictState = "none" | "open" | "dismissed";
|
||||||
|
|
||||||
|
/** A corpus. Mirrors `POST /tools/kb.list` collection rows + the collection view. */
|
||||||
|
export interface Collection {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
sensitivity: Sensitivity;
|
||||||
|
owner_type: OwnerType;
|
||||||
|
/** Only present on the collection view (`POST/PATCH /collections`). */
|
||||||
|
owner_id?: string | null;
|
||||||
|
source_app?: string | null;
|
||||||
|
curation: Curation;
|
||||||
|
claim_extraction: ClaimExtraction;
|
||||||
|
/** Present on `kb.list` rows; absent on the write view. */
|
||||||
|
object_count?: number;
|
||||||
|
updated_at?: string;
|
||||||
|
inserted_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionPage {
|
||||||
|
collections: Collection[];
|
||||||
|
next_cursor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create body — `POST /api/v1/collections`. */
|
||||||
|
export interface CollectionInput {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sensitivity?: Sensitivity;
|
||||||
|
curation?: Curation;
|
||||||
|
claim_extraction?: ClaimExtraction;
|
||||||
|
/** Omit for a personal (account-owned) corpus; "tenant" for an org corpus. */
|
||||||
|
owner_type?: OwnerType;
|
||||||
|
owner_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings patch — `PATCH /api/v1/collections/:slug` (slug is immutable). */
|
||||||
|
export interface CollectionPatch {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
sensitivity?: Sensitivity;
|
||||||
|
curation?: Curation;
|
||||||
|
claim_extraction?: ClaimExtraction;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One object in a collection listing. Mirrors `kb.list` object rows. */
|
||||||
|
export interface ObjectSummary {
|
||||||
|
object_id: string;
|
||||||
|
kind: ObjectKind;
|
||||||
|
title: string;
|
||||||
|
status: ObjectStatus;
|
||||||
|
updated_at?: string;
|
||||||
|
effective_at?: string | null;
|
||||||
|
sensitivity: Sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectPage {
|
||||||
|
objects: ObjectSummary[];
|
||||||
|
next_cursor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineSection {
|
||||||
|
id: string;
|
||||||
|
heading: string;
|
||||||
|
level: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Entity {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Provenance {
|
||||||
|
source_type: string;
|
||||||
|
source_ref?: string | null;
|
||||||
|
created_by?: string | null;
|
||||||
|
inserted_at?: string;
|
||||||
|
effective_at?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
|
card_model?: string | null;
|
||||||
|
extraction_model?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The catalog card + provenance. Mirrors `GET /api/v1/objects/:id` (kb.outline). */
|
||||||
|
export interface ObjectOutline {
|
||||||
|
object_id: string;
|
||||||
|
title: string;
|
||||||
|
kind: ObjectKind;
|
||||||
|
collection: string;
|
||||||
|
status: ObjectStatus;
|
||||||
|
sensitivity: Sensitivity;
|
||||||
|
summary?: string | null;
|
||||||
|
entities: Entity[];
|
||||||
|
tags: string[];
|
||||||
|
language?: string | null;
|
||||||
|
mime?: string | null;
|
||||||
|
byte_size?: number | null;
|
||||||
|
has_blob?: boolean;
|
||||||
|
outline: OutlineSection[];
|
||||||
|
provenance: Provenance;
|
||||||
|
supersedes?: string | null;
|
||||||
|
superseded_by?: string | null;
|
||||||
|
extracted_chars?: number | null;
|
||||||
|
/** Present when kind === "claim". */
|
||||||
|
claim?: ClaimDetail | null;
|
||||||
|
cite: string;
|
||||||
|
kb_abi_version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A slice of L1 text. Mirrors `GET /api/v1/objects/:id/text` (kb.read). */
|
||||||
|
export interface TextSlice {
|
||||||
|
object_id: string;
|
||||||
|
text?: string;
|
||||||
|
range?: { start: number; end: number };
|
||||||
|
truncated?: boolean;
|
||||||
|
cite?: string;
|
||||||
|
sensitivity?: Sensitivity;
|
||||||
|
superseded_by?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectRef {
|
||||||
|
object_id: string;
|
||||||
|
kind: ObjectKind;
|
||||||
|
status: ObjectStatus;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectPatch {
|
||||||
|
title?: string;
|
||||||
|
tags?: string[];
|
||||||
|
/** ISO date; touch to mark "still current". */
|
||||||
|
verified_at?: string;
|
||||||
|
sensitivity?: Sensitivity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- search --------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface SearchInput {
|
||||||
|
query: string;
|
||||||
|
collection?: string;
|
||||||
|
kinds?: ObjectKind[];
|
||||||
|
include_superseded?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
object_id: string;
|
||||||
|
kind: ObjectKind;
|
||||||
|
title: string;
|
||||||
|
collection: string;
|
||||||
|
snippet?: string;
|
||||||
|
score?: number;
|
||||||
|
updated_at?: string;
|
||||||
|
effective_at?: string | null;
|
||||||
|
sensitivity: Sensitivity;
|
||||||
|
cite: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchPage {
|
||||||
|
hits: SearchHit[];
|
||||||
|
next_cursor?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- claims (claims-spec §4) ---------------------------------------------
|
||||||
|
|
||||||
|
export interface ClaimSource {
|
||||||
|
object_id: string;
|
||||||
|
span?: { start: number; end: number };
|
||||||
|
cite?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A claim card. Mirrors a `kb.lookup` claim / `GET /api/v1/claims` row. */
|
||||||
|
export interface Claim {
|
||||||
|
claim_id: string;
|
||||||
|
subject: string;
|
||||||
|
predicate: string;
|
||||||
|
value: unknown;
|
||||||
|
assertion_tier: AssertionTier;
|
||||||
|
status: ObjectStatus;
|
||||||
|
review_state: ReviewState;
|
||||||
|
conflict_state: ConflictState;
|
||||||
|
conflicts_with?: string[];
|
||||||
|
source?: ClaimSource | null;
|
||||||
|
collection: string;
|
||||||
|
sensitivity: Sensitivity;
|
||||||
|
effective_at?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
|
verified_by?: string | null;
|
||||||
|
created_by?: string | null;
|
||||||
|
updated_at?: string;
|
||||||
|
cite?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The claim block inside an object outline when kind === "claim". */
|
||||||
|
export interface ClaimDetail {
|
||||||
|
subject: string;
|
||||||
|
predicate: string;
|
||||||
|
value: unknown;
|
||||||
|
assertion_tier: AssertionTier;
|
||||||
|
review_state: ReviewState;
|
||||||
|
conflict_state: ConflictState;
|
||||||
|
conflicts_with?: string[];
|
||||||
|
source?: ClaimSource | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClaimFilter {
|
||||||
|
subject?: string;
|
||||||
|
predicate?: string;
|
||||||
|
collection?: string;
|
||||||
|
status?: ObjectStatus;
|
||||||
|
review?: ReviewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for `POST /claims/:id/confirm` — optionally settle a conflict inline. */
|
||||||
|
export type ResolveAction =
|
||||||
|
| { supersede: string[] }
|
||||||
|
| { dismiss: true }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// ---- export bundles (spec §8) --------------------------------------------
|
||||||
|
|
||||||
|
export type ExportStatus = "pending" | "running" | "ready" | "failed";
|
||||||
|
|
||||||
|
export interface ExportJob {
|
||||||
|
id: string;
|
||||||
|
status: ExportStatus;
|
||||||
|
scope: string;
|
||||||
|
object_count?: number | null;
|
||||||
|
byte_size?: number | null;
|
||||||
|
error?: string | null;
|
||||||
|
inserted_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
/** Present when status === "ready" (route to fetch a presigned URL). */
|
||||||
|
download_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scope for a new export: a single collection slug, or the whole estate. */
|
||||||
|
export type ExportScope = "all" | string;
|
||||||
|
|
||||||
|
// ---- error shape ---------------------------------------------------------
|
||||||
|
|
||||||
|
export type KnowledgeErrorCode =
|
||||||
|
| "not_found"
|
||||||
|
| "scope_denied"
|
||||||
|
| "sensitivity_refused"
|
||||||
|
| "invalid_arg"
|
||||||
|
| "over_limit"
|
||||||
|
| "invalid"
|
||||||
|
| "unauthorized"
|
||||||
|
| "http";
|
||||||
|
|
||||||
|
export interface KnowledgeError {
|
||||||
|
code: KnowledgeErrorCode;
|
||||||
|
status: number;
|
||||||
|
/** Field errors when code === "invalid" (422 changeset). */
|
||||||
|
details?: Record<string, string[]>;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
24
tsconfig.check.json
Normal file
24
tsconfig.check.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"types": [],
|
||||||
|
"paths": {
|
||||||
|
"react": ["../arcadia-personal-cloud-web/node_modules/@types/react/index.d.ts"],
|
||||||
|
"react/jsx-runtime": ["../arcadia-personal-cloud-web/node_modules/@types/react/jsx-runtime.d.ts"],
|
||||||
|
"react-dom": ["../arcadia-personal-cloud-web/node_modules/@types/react-dom/index.d.ts"],
|
||||||
|
"lucide-react": ["../arcadia-personal-cloud-web/node_modules/lucide-react/dist/lucide-react.d.ts"],
|
||||||
|
"@crema/file-ui": ["../lib-file-ui/src/index.tsx"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src", "demo"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user