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:
jules
2026-07-07 09:57:46 +10:00
commit d2829d6348
12 changed files with 1729 additions and 0 deletions

82
README.md Normal file
View 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
View 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
View 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
View 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>
);
};

View File

@@ -0,0 +1,2 @@
// Placeholder — filled in its workstream (W4/W5/W6).
export {};

View 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>
);

View File

@@ -0,0 +1,2 @@
// Placeholder — filled in its workstream (W4/W5/W6).
export {};

View File

@@ -0,0 +1,2 @@
// Placeholder — filled in its workstream (W4/W5/W6).
export {};

37
src/index.tsx Normal file
View 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
View 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
View 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
View 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"]
}