From 9dd162d8151695a2cce7b62182f22b2944ffc9c8 Mon Sep 17 00:00:00 2001 From: jules Date: Tue, 7 Jul 2026 10:01:33 +1000 Subject: [PATCH] W4: object viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components-object: ObjectList, ObjectViewer (CatalogCard + ProvenancePanel + SupersessionBanner + OutlineNav + TextReader with citation highlight/scroll + load-more, image render, vault notice, action bar), CitationLink + parseCite. Demo browses collections → objects → viewer. Typechecks clean. Co-Authored-By: Claude Fable 5 (build) --- demo/knowledge.tsx | 90 ++++++-- src/components-object.tsx | 472 +++++++++++++++++++++++++++++++++++++- 2 files changed, 537 insertions(+), 25 deletions(-) diff --git a/demo/knowledge.tsx b/demo/knowledge.tsx index 500741d..5e7ca2f 100644 --- a/demo/knowledge.tsx +++ b/demo/knowledge.tsx @@ -6,18 +6,30 @@ import { useEffect, useState } from "react"; import { CollectionForm, CollectionList, + ObjectList, + ObjectViewer, MockKnowledgeTransport, type Collection, type CollectionInput, type CollectionPatch, type KnowledgeTransport, + type ObjectOutline, + type ObjectSummary, } from "../src/index"; const transport: KnowledgeTransport = new MockKnowledgeTransport(); +type View = + | { kind: "collections" } + | { kind: "collection"; slug: string } + | { kind: "object"; id: string }; + export default function KnowledgeDemo() { + const [view, setView] = useState({ kind: "collections" }); const [collections, setCollections] = useState([]); const [pending, setPending] = useState>({}); + const [objects, setObjects] = useState([]); + const [outline, setOutline] = useState(null); const [creating, setCreating] = useState(null); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); @@ -35,6 +47,11 @@ export default function KnowledgeDemo() { void refresh(); }, []); + useEffect(() => { + if (view.kind === "collection") void transport.listObjects(view.slug).then((p) => setObjects(p.objects)); + if (view.kind === "object") void transport.outline(view.id).then(setOutline); + }, [view]); + async function onCreate(value: CollectionInput | CollectionPatch) { setBusy(true); await transport.createCollection(value as CollectionInput); @@ -42,7 +59,6 @@ export default function KnowledgeDemo() { setCreating(null); void refresh(); } - async function onEdit(value: CollectionInput | CollectionPatch) { if (!editing) return; setBusy(true); @@ -54,33 +70,61 @@ export default function KnowledgeDemo() { return (
-
-

Knowledge

-

Your corpuses — browse, organise, and review.

-
+ - {creating && ( -
-

New corpus

- setCreating(null)} /> -
+ {view.kind === "collections" && ( + <> + {creating && ( +
+

New corpus

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

Corpus settings

+ setEditing(null)} /> +
+ )} + setCreating(owner)} + onOpen={(c) => setView({ kind: "collection", slug: c.slug })} + /> + )} - {editing && ( -
-

Corpus settings

- setEditing(null)} /> -
+ {view.kind === "collection" && ( + <> +
+

{view.slug}

+ +
+ setView({ kind: "object", id: o.object_id })} /> + )} - setCreating(owner)} - onOpen={(c) => setEditing(c)} - /> + {view.kind === "object" && outline && ( + transport.readText(outline.object_id, sel)} + resolveBlobUrl={() => transport.blobUrl(outline.object_id)} + onOpenObject={(id) => setView({ kind: "object", id })} + actions={{ + onVerify: () => transport.updateObject(outline.object_id, { verified_at: new Date().toISOString() }), + onDownload: () => transport.blobUrl(outline.object_id).then((u) => window.open(u)), + }} + /> + )}
); } diff --git a/src/components-object.tsx b/src/components-object.tsx index 20f8e6e..cf8fa5e 100644 --- a/src/components-object.tsx +++ b/src/components-object.tsx @@ -1,2 +1,470 @@ -// Placeholder — filled in its workstream (W4/W5/W6). -export {}; +// PURPOSE: Object viewer (spec §3.4) — catalog card + provenance + supersession +// + outline-driven text reader with citation highlighting, image render, +// vault notice, and object actions. Plus ObjectList + CitationLink. +// Pure: text is loaded via an injected readText callback, not fetched. +// =========================================================================== +import { useCallback, useEffect, useRef, useState, type FC, type ReactNode, type RefObject } from "react"; +import { + ArrowUpRight, + Check, + Download, + FileText, + Image as ImageIcon, + Loader2, + Lock, + Sparkles, + Tag, + Trash2, +} from "lucide-react"; +import type { ObjectOutline, ObjectSummary, OutlineSection, TextSlice } from "./types"; +import { + Badge, + SensitivityBadge, + StatusBadge, + cn, + formatBytes, + formatDate, + isImageMime, +} from "./_internal"; + +// ---- ObjectList ----------------------------------------------------------- + +export interface ObjectListProps { + objects: ObjectSummary[]; + onOpen?: (object: ObjectSummary) => void; + emptyState?: ReactNode; + className?: string; +} + +export const ObjectList: FC = ({ objects, onOpen, emptyState, className }) => { + if (objects.length === 0 && emptyState) return <>{emptyState}; + return ( +
    + {objects.map((o) => ( +
  • + +
  • + ))} +
+ ); +}; + +// ---- CitationLink --------------------------------------------------------- + +export function parseCite(cite: string): { objectId: string; span?: { start: number; end: number } } | null { + const m = /^kb:\/\/([^#]+)(?:#(\d+)-(\d+))?$/.exec(cite); + if (!m) return null; + return { objectId: m[1], span: m[2] ? { start: Number(m[2]), end: Number(m[3]) } : undefined }; +} + +/** Renders a `kb://#s-e` handle as a click-through the app routes to the + * object viewer with ?start=&end=. `onOpen` receives (objectId, span). */ +export interface CitationLinkProps { + cite: string; + label?: string; + onOpen?: (objectId: string, span?: { start: number; end: number }) => void; + className?: string; +} + +export const CitationLink: FC = ({ cite, label, onOpen, className }) => { + const parsed = parseCite(cite); + return ( + + ); +}; + +// ---- CatalogCard ---------------------------------------------------------- + +export const CatalogCard: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => ( +
+
+ + + {outline.language && {outline.language.toUpperCase()}} + {outline.mime && {outline.mime}} + {outline.byte_size != null && {formatBytes(outline.byte_size)}} +
+

{outline.title}

+ {outline.summary &&

{outline.summary}

} + {(outline.tags.length > 0 || outline.entities.length > 0) && ( +
+ {outline.tags.map((t) => ( + }> + {t} + + ))} + {outline.entities.map((e) => ( + + {e.name} + + ))} +
+ )} +
+); + +// ---- ProvenancePanel ------------------------------------------------------ + +export const ProvenancePanel: FC<{ outline: ObjectOutline; className?: string }> = ({ outline, className }) => { + const p = outline.provenance; + const rows: [string, ReactNode][] = [ + ["Source", p.source_ref ? `${p.source_type} · ${p.source_ref}` : p.source_type], + ["Added by", p.created_by ?? "—"], + ["Added", formatDate(p.inserted_at)], + ["Effective", p.effective_at ? formatDate(p.effective_at) : "—"], + ["Last verified", p.verified_at ? formatDate(p.verified_at) : "Not confirmed"], + ["Card model", p.card_model ?? "— (mechanical)"], + ["Extraction model", p.extraction_model ?? "— (native text)"], + ]; + return ( +
+

Provenance

+
+ {rows.map(([k, v]) => ( +
+
{k}
+
{v}
+
+ ))} +
+
+ ); +}; + +// ---- SupersessionBanner --------------------------------------------------- + +export const SupersessionBanner: FC<{ + outline: ObjectOutline; + onOpen?: (objectId: string) => void; + className?: string; +}> = ({ outline, onOpen, className }) => { + if (!outline.superseded_by && !outline.supersedes) return null; + return ( +
+ {outline.superseded_by && ( +
+ A newer version of this document exists. + +
+ )} + {outline.supersedes && ( + + )} +
+ ); +}; + +// ---- OutlineNav ----------------------------------------------------------- + +export const OutlineNav: FC<{ + sections: OutlineSection[]; + activeId?: string; + onSelect?: (section: OutlineSection) => void; + className?: string; +}> = ({ sections, activeId, onSelect, className }) => { + if (sections.length === 0) return null; + return ( + + ); +}; + +// ---- TextReader ----------------------------------------------------------- + +export interface TextReaderProps { + outline: ObjectOutline; + readText: (sel?: { section?: string; start?: number; end?: number }) => Promise; + /** A span to load, mark, and scroll to (citation resolver, spec §3.4). */ + highlight?: { start: number; end: number }; + activeSection?: string; + className?: string; +} + +export const TextReader: FC = ({ outline, readText, highlight, activeSection, className }) => { + const [slice, setSlice] = useState(null); + const [loading, setLoading] = useState(false); + const markRef = useRef(null); + + const load = useCallback( + async (sel?: { section?: string; start?: number; end?: number }) => { + setLoading(true); + try { + setSlice(await readText(sel)); + } finally { + setLoading(false); + } + }, + [readText], + ); + + // Initial + reactive loads: prefer an explicit highlight span, then an active + // section, else the head of the document. + useEffect(() => { + if (highlight) { + const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end); + void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 }); + } else if (activeSection) { + void load({ section: activeSection }); + } else { + void load(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [highlight?.start, highlight?.end, activeSection, outline.object_id]); + + useEffect(() => { + if (markRef.current) markRef.current.scrollIntoView({ block: "center", behavior: "smooth" }); + }, [slice]); + + async function loadMore() { + if (!slice?.range) return; + const next = await readText({ start: slice.range.end }); + setSlice((prev) => + prev && prev.range && next.range + ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } + : next, + ); + } + + if (loading && !slice) { + return ( +
+ Loading… +
+ ); + } + if (!slice) return null; + + const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars; + + return ( +
+
+ {renderWithHighlight(slice, highlight, markRef)} +
+ {canReadMore && ( + + )} +
+ ); +}; + +function renderWithHighlight( + slice: TextSlice, + highlight: { start: number; end: number } | undefined, + markRef: RefObject, +): ReactNode { + const text = slice.text ?? ""; + const base = slice.range?.start ?? 0; + if (!highlight) return text; + const from = highlight.start - base; + const to = highlight.end - base; + if (from < 0 || from >= text.length || to <= from) return text; + return ( + <> + {text.slice(0, from)} + + {text.slice(from, to)} + + {text.slice(to)} + + ); +} + +// ---- ObjectActions -------------------------------------------------------- + +export interface ObjectActions { + onEditMeta?: () => void; + onVerify?: () => void; + onArchive?: () => void; + onDownload?: () => void; + onSupersede?: () => void; + onExtractClaims?: () => void; +} + +const ActionButton: FC<{ onClick?: () => void; icon: ReactNode; children: ReactNode; action: string; danger?: boolean }> = ({ + onClick, + icon, + children, + action, + danger, +}) => + onClick ? ( + + ) : null; + +// ---- ObjectViewer (composite) -------------------------------------------- + +export interface ObjectViewerProps { + outline: ObjectOutline; + readText: TextReaderProps["readText"]; + /** Resolve a browser-openable blob URL (for images + download). */ + resolveBlobUrl?: () => Promise; + highlight?: { start: number; end: number }; + onOpenObject?: (objectId: string, span?: { start: number; end: number }) => void; + actions?: ObjectActions; + className?: string; +} + +export const ObjectViewer: FC = ({ + outline, + readText, + resolveBlobUrl, + highlight, + onOpenObject, + actions, + className, +}) => { + const [activeSection, setActiveSection] = useState(); + const [blob, setBlob] = useState(null); + const isImage = isImageMime(outline.mime); + const isVault = outline.sensitivity === "vault"; + const hasActions = + !!actions && + (actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims); + + useEffect(() => { + let live = true; + if (isImage && outline.has_blob && resolveBlobUrl) { + void resolveBlobUrl().then((u) => { + if (live) setBlob(u); + }); + } + return () => { + live = false; + }; + }, [isImage, outline.has_blob, outline.object_id, resolveBlobUrl]); + + return ( +
+ + + + {hasActions && ( +
+ }>Edit title & tags + }>Confirm still current + {outline.has_blob && }>Download original} + }>Replace with newer + {!isVault && }>Extract claims} + } danger>Archive +
+ )} + + {isVault && ( +
+ + + Vault. Never sent to AI models — the catalog is file metadata only. You can still read and download it here. + +
+ )} + + {isImage && outline.has_blob && ( +
+ {blob ? ( + {outline.title} + ) : ( +
+ +
+ )} +
+ )} + +
+
+ {outline.extracted_chars && outline.extracted_chars > 0 ? ( + + ) : ( +

+ {isImage ? "This image's description is its searchable text — see the summary above." : "No extracted text for this object."} +

+ )} +
+ +
+
+ ); +};