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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user