From d2829d634813a7f972ae94aab476fdbc69e3a692 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 7 Jul 2026 09:57:46 +1000 Subject: [PATCH] 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) --- README.md | 82 +++++ demo/knowledge.tsx | 86 +++++ package.json | 21 ++ src/_internal.tsx | 188 ++++++++++ src/components-claims.tsx | 2 + src/components-collections.tsx | 351 ++++++++++++++++++ src/components-export.tsx | 2 + src/components-object.tsx | 2 + src/index.tsx | 37 ++ src/transport.ts | 651 +++++++++++++++++++++++++++++++++ src/types.ts | 283 ++++++++++++++ tsconfig.check.json | 24 ++ 12 files changed, 1729 insertions(+) create mode 100644 README.md create mode 100644 demo/knowledge.tsx create mode 100644 package.json create mode 100644 src/_internal.tsx create mode 100644 src/components-claims.tsx create mode 100644 src/components-collections.tsx create mode 100644 src/components-export.tsx create mode 100644 src/components-object.tsx create mode 100644 src/index.tsx create mode 100644 src/transport.ts create mode 100644 src/types.ts create mode 100644 tsconfig.check.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..4223429 --- /dev/null +++ b/README.md @@ -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(); + + 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` | diff --git a/demo/knowledge.tsx b/demo/knowledge.tsx new file mode 100644 index 0000000..500741d --- /dev/null +++ b/demo/knowledge.tsx @@ -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([]); + const [pending, setPending] = useState>({}); + const [creating, setCreating] = useState(null); + const [editing, setEditing] = useState(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 = {}; + 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 ( +
+
+

Knowledge

+

Your corpuses — browse, organise, and review.

+
+ + {creating && ( +
+

New corpus

+ setCreating(null)} /> +
+ )} + + {editing && ( +
+

Corpus settings

+ setEditing(null)} /> +
+ )} + + setCreating(owner)} + onOpen={(c) => setEditing(c)} + /> +
+ ); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..371f91b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/_internal.tsx b/src/_internal.tsx new file mode 100644 index 0000000..3da26c1 --- /dev/null +++ b/src/_internal.tsx @@ -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 }) => ( + +); + +// ---- 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; + 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, +}) => ( + + {icon} + {children} + +); + +const SENSITIVITY: Record = { + open: { + label: "Open", + tone: "bg-muted text-muted-foreground", + icon: , + 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: , + 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: , + 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 ( + + {s.label} + + ); +}; + +export const CurationBadge: FC<{ curation: Curation; className?: string }> = ({ curation, className }) => { + const gated = curation === "gated"; + return ( + + ); +}; + +const STATUS: Record = { + 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 ( + + {s.label} + + ); +}; + +const TIER: Record = { + extracted: { + label: "Extracted", + tone: "bg-muted text-muted-foreground", + icon: , + 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: , + 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: , + 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 ( + + {t.label} + + ); +}; diff --git a/src/components-claims.tsx b/src/components-claims.tsx new file mode 100644 index 0000000..20f8e6e --- /dev/null +++ b/src/components-claims.tsx @@ -0,0 +1,2 @@ +// Placeholder — filled in its workstream (W4/W5/W6). +export {}; diff --git a/src/components-collections.tsx b/src/components-collections.tsx new file mode 100644 index 0000000..dfc38d8 --- /dev/null +++ b/src/components-collections.tsx @@ -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 = ({ collection, pendingCount, onOpen, className }) => ( + +); + +// ---- CollectionList (grouped by owner) ------------------------------------ + +export interface CollectionListProps { + collections: Collection[]; + /** Pending review counts by collection slug, from the app's reviewQueue call. */ + pendingCounts?: Record; + /** 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 = ({ + 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 ( +
+ } + collections={personal} + pendingCounts={pendingCounts} + onOpen={onOpen} + onCreate={onCreate ? () => onCreate("account") : undefined} + createLabel="New corpus" + /> + {(org.length > 0 || canManageTenant) && ( + } + 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} + /> + )} +
+ ); +}; + +const Group: FC<{ + title: string; + icon: ReactNode; + collections: Collection[]; + pendingCounts?: Record; + onOpen?: (c: Collection) => void; + onCreate?: () => void; + createLabel: string; + emptyHint?: string; +}> = ({ title, icon, collections, pendingCounts, onOpen, onCreate, createLabel, emptyHint }) => ( +
+
+ {icon} +

{title}

+ ({collections.length}) + {onCreate && ( + + )} +
+ {collections.length === 0 ? ( +

+ {emptyHint ?? "Nothing here yet."} +

+ ) : ( +
+ {collections.map((c) => ( + + ))} +
+ )} +
+); + +// ---- 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 = ({ + 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(initial?.sensitivity ?? "open"); + const [curation, setCuration] = useState(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 ( +
{ + e.preventDefault(); + if (!readOnly) submit(); + }} + > + {!isEdit && canManageTenant && ( + +
+ setOwner("account")} icon={} label="Personal" /> + setOwner("tenant")} icon={} label={orgName} /> +
+
+ )} + + {!isEdit && ( + + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "-"))} + placeholder="supplier-docs" + required + /> + + )} + + + setName(e.target.value)} disabled={readOnly} required /> + + + +