import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { SectionLabel, SearchInput, ExplainGraph } from "../components"; import type { ExplainGraphNode, ExplainGraphEdge } from "../components"; import { COLLECTION } from "../config"; import { useInference } from "@trustgraph/react-state"; import type { ExplainEvent, Triple, Term } from "@trustgraph/react-state"; import { useSocket } from "@trustgraph/react-provider"; import type { BaseApi } from "@trustgraph/react-provider"; import { palette, text, border, withGlow, semantic } from "../theme"; // ── Namespaces ────────────────────────────────────────────────────── const TG = "https://trustgraph.ai/ns/"; const TG_QUERY = TG + "query"; const TG_CONCEPT = TG + "concept"; const TG_ENTITY = TG + "entity"; const TG_EDGE_COUNT = TG + "edgeCount"; const TG_SELECTED_EDGE = TG + "selectedEdge"; const TG_EDGE = TG + "edge"; const TG_REASONING = TG + "reasoning"; const TG_CONTENT = TG + "content"; const TG_CONTAINS = TG + "contains"; const TG_CHUNK_COUNT = TG + "chunkCount"; const TG_ACTION = TG + "action"; const TG_ARGUMENTS = TG + "arguments"; const TG_THOUGHT = TG + "thought"; const TG_OBSERVATION = TG + "observation"; const TG_DOCUMENT = TG + "document"; const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const PROV = "http://www.w3.org/ns/prov#"; const PROV_STARTED_AT_TIME = PROV + "startedAtTime"; const PROV_WAS_DERIVED_FROM = PROV + "wasDerivedFrom"; const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label"; // ── Types ─────────────────────────────────────────────────────────── interface EdgeSelection { edgeUri: string; edge?: { s: string; p: string; o: string }; edgeLabels?: { s: string; p: string; o: string }; reasoning?: string; sources?: ProvenanceChain[]; } interface ProvenanceChain { chain: { uri: string; label: string }[]; } interface QuestionData { query?: string; timestamp?: string; } interface GroundingData { concepts: string[]; } interface ExplorationData { edgeCount?: string; chunkCount?: string; entities: string[]; entityLabels?: string[]; } interface FocusData { edgeSelections: EdgeSelection[]; } interface SynthesisData { contentLength?: number; } interface AnalysisData { action?: string; arguments?: string; thoughtUri?: string; observationUri?: string; } interface ConclusionData { documentUri?: string; } interface ReflectionData { documentUri?: string; reflectionType?: string; } type EventData = QuestionData | GroundingData | ExplorationData | FocusData | SynthesisData | AnalysisData | ConclusionData | ReflectionData; interface SourcePanelState { chunkUri: string; documentUri: string; documentTitle?: string; documentTags?: string[]; chunkText?: string; loading: boolean; error?: string; } interface ExplainNode { explainId: string; explainGraph: string; eventType: string; data?: EventData; fetched: boolean; fetching: boolean; error?: string; } // ── Helpers ───────────────────────────────────────────────────────── function shortUri(uri: string): string { if (uri.startsWith("urn:trustgraph:prov:")) return "tg:prov:" + uri.slice(20); if (uri.startsWith("urn:trustgraph:")) return "tg:" + uri.slice(15); if (uri.startsWith(TG)) return "tg:" + uri.slice(TG.length); if (uri.startsWith(PROV)) return "prov:" + uri.slice(PROV.length); if (uri.startsWith("http://www.w3.org/2000/01/rdf-schema#")) return "rdfs:" + uri.slice(37); if (uri.startsWith("http://www.w3.org/1999/02/22-rdf-syntax-ns#")) return "rdf:" + uri.slice(43); if (uri.startsWith("urn:")) return uri; const pos = Math.max(uri.lastIndexOf("#"), uri.lastIndexOf("/")); return pos >= 0 ? uri.slice(pos + 1) : uri; } // Ordered type checks — mirrors the Python ExplainEntity.from_triples logic. // Each entry: [type URI to look for, display name]. // First match wins. const TYPE_CHECKS: [string, string][] = [ [TG + "GraphRagQuestion", "question"], [TG + "DocRagQuestion", "question"], [TG + "AgentQuestion", "question"], [TG + "Question", "question"], [TG + "Grounding", "grounding"], [TG + "Exploration", "exploration"], [TG + "Focus", "focus"], [TG + "Synthesis", "synthesis"], [TG + "Reflection", "reflection"], [TG + "Thought", "reflection"], [TG + "Observation", "reflection"], [TG + "Analysis", "analysis"], [TG + "Conclusion", "conclusion"], ]; function getEventTypeFromTriples(triples: Triple[]): string { const types = new Set(); for (const t of triples) { if (predIri(t) === RDF_TYPE) types.add(objValue(t)); } for (const [typeUri, displayName] of TYPE_CHECKS) { if (types.has(typeUri)) return displayName; } return "unknown"; } function eventTypeColor(eventType: string): string { switch (eventType) { case "question": return palette.amber; case "grounding": return palette.orange; case "exploration": return palette.blue; case "focus": return palette.purple; case "analysis": return palette.purple; case "reflection": return palette.cyan; case "synthesis": return palette.emerald; case "conclusion": return palette.emerald; default: return text.muted; } } // Get predicate IRI from a triple function predIri(triple: Triple): string { return triple.p.t === "i" ? triple.p.i : ""; } // Get object value (string) from a triple function objValue(triple: Triple): string { const o = triple.o; if (o.t === "i") return o.i; if (o.t === "l") return o.v; if (o.t === "b") return o.d; return ""; } // Get object as quoted triple {s, p, o} if it's a triple term function objQuotedTriple(triple: Triple): { s: string; p: string; o: string } | null { const o = triple.o; if (o.t === "t" && o.tr) { return { s: o.tr.s.t === "i" ? o.tr.s.i : (o.tr.s as any).v || "", p: o.tr.p.t === "i" ? o.tr.p.i : (o.tr.p as any).v || "", o: o.tr.o.t === "i" ? o.tr.o.i : (o.tr.o as any).v || "", }; } return null; } // ── KG query helpers (using the socket API) ───────────────────────── async function queryTriples( api: ReturnType, subject: string, predicate?: string, limit = 100, collection = COLLECTION, graph?: string, ): Promise { const s: Term = { t: "i", i: subject }; const p: Term | undefined = predicate ? { t: "i", i: predicate } : undefined; return api.triplesQuery(s, p, undefined, limit, collection, graph); } // Backoff retry for eventually-consistent event triples. // Calls onUpdate each time new triples arrive, settles when two consecutive // fetches return the same count, or after maxTries (6 = 1 initial + 5 retries). // Backoff: 50ms × 3 each retry, capped at 1500ms. async function queryTriplesUntilSettled( api: ReturnType, subject: string, onUpdate: (triples: Triple[]) => void, limit = 100, collection = COLLECTION, graph?: string, maxTries = 6, ): Promise { let prevCount = -1; let settled: Triple[] = []; let delay = 50; for (let attempt = 0; attempt < maxTries; attempt++) { const triples = await queryTriples(api, subject, undefined, limit, collection, graph); if (triples.length !== prevCount) { settled = triples; onUpdate(triples); } else { // Two consecutive identical counts — settled return settled; } prevCount = triples.length; if (attempt < maxTries - 1) { await new Promise(r => setTimeout(r, delay)); delay = Math.min(delay * 3, 1500); } } return settled; } // Resolve rdfs:label for a URI, with cache async function resolveLabel( api: ReturnType, uri: string, cache: Map, ): Promise { if (cache.has(uri)) return cache.get(uri)!; try { const triples = await api.triplesQuery( { t: "i", i: uri }, { t: "i", i: RDFS_LABEL }, undefined, 1, COLLECTION, ); const label = triples.length > 0 ? objValue(triples[0]) : shortUri(uri); cache.set(uri, label); return label; } catch { const fallback = shortUri(uri); cache.set(uri, fallback); return fallback; } } // Trace prov:wasDerivedFrom chain up to root async function traceProvenanceChain( api: ReturnType, startUri: string, labelCache: Map, maxDepth = 10, ): Promise { const chain: { uri: string; label: string }[] = []; let current: string | null = startUri; for (let i = 0; i < maxDepth && current; i++) { const label = await resolveLabel(api, current, labelCache); chain.push({ uri: current, label }); // Find parent const parentTriples = await api.triplesQuery( { t: "i", i: current }, { t: "i", i: PROV_WAS_DERIVED_FROM }, undefined, 1, COLLECTION, ); const parentUri = parentTriples.length > 0 ? objValue(parentTriples[0]) : null; if (!parentUri || parentUri === current) break; current = parentUri; } return { chain }; } // Query edge provenance: find subgraphs containing the edge via tg:contains async function queryEdgeProvenance( api: ReturnType, edge: { s: string; p: string; o: string }, labelCache: Map, ): Promise { // Find subgraphs that contain this edge: ?subgraph tg:contains <> const oTerm: Term = (edge.o.startsWith("http") || edge.o.startsWith("urn:")) ? { t: "i", i: edge.o } : { t: "l", v: edge.o }; const containsTriples = await api.triplesQuery( undefined, { t: "i", i: TG_CONTAINS }, { t: "t", tr: { s: { t: "i", i: edge.s }, p: { t: "i", i: edge.p }, o: oTerm, }, }, 10, COLLECTION, ); // For each subgraph, follow wasDerivedFrom to sources const chains: ProvenanceChain[] = []; for (const t of containsTriples) { const subgraphUri = t.s.t === "i" ? t.s.i : ""; if (!subgraphUri) continue; const derivedTriples = await api.triplesQuery( { t: "i", i: subgraphUri }, { t: "i", i: PROV_WAS_DERIVED_FROM }, undefined, 10, COLLECTION, ); for (const dt of derivedTriples) { const sourceUri = objValue(dt); if (sourceUri) { const chain = await traceProvenanceChain(api, sourceUri, labelCache); chains.push(chain); } } } return chains; } // ── Parse basic event data (synchronous, from already-fetched triples) ── function parseBasicEventData(eventType: string, triples: Triple[]): EventData { switch (eventType) { case "question": { const data: QuestionData = {}; for (const t of triples) { const p = predIri(t); if (p === TG_QUERY) data.query = objValue(t); if (p === PROV_STARTED_AT_TIME) data.timestamp = objValue(t); } return data; } case "grounding": { const concepts: string[] = []; for (const t of triples) { if (predIri(t) === TG_CONCEPT) { const v = objValue(t); if (v) concepts.push(v); } } return { concepts } as GroundingData; } case "exploration": { const data: ExplorationData = { entities: [] }; for (const t of triples) { const p = predIri(t); if (p === TG_EDGE_COUNT) data.edgeCount = objValue(t); if (p === TG_CHUNK_COUNT) data.chunkCount = objValue(t); if (p === TG_ENTITY) { const uri = objValue(t); if (uri) data.entities.push(uri); } } return data; } case "focus": { const edgeSelUris: string[] = []; for (const t of triples) { if (predIri(t) === TG_SELECTED_EDGE) { const uri = objValue(t); if (uri) edgeSelUris.push(uri); } } return { edgeSelections: edgeSelUris.map(uri => ({ edgeUri: uri })), } as FocusData; } case "synthesis": { const data: SynthesisData = {}; for (const t of triples) { if (predIri(t) === TG_CONTENT) { data.contentLength = objValue(t).length; } } return data; } case "analysis": { const data: AnalysisData = {}; for (const t of triples) { const p = predIri(t); if (p === TG_ACTION) data.action = objValue(t); if (p === TG_ARGUMENTS) data.arguments = objValue(t); if (p === TG_THOUGHT) data.thoughtUri = objValue(t); if (p === TG_OBSERVATION) data.observationUri = objValue(t); } return data; } case "conclusion": { const data: ConclusionData = {}; for (const t of triples) { if (predIri(t) === TG_DOCUMENT) data.documentUri = objValue(t); } return data; } case "reflection": { const data: ReflectionData = {}; for (const t of triples) { if (predIri(t) === TG_DOCUMENT) data.documentUri = objValue(t); } return data; } default: return {}; } } // ── Enrich event data (async — labels, edge details, provenance) ──── async function enrichEventData( api: ReturnType, eventType: string, _triples: Triple[], basicData: EventData, labelCache: Map, explainGraph: string, ): Promise { switch (eventType) { case "exploration": { const data = { ...(basicData as ExplorationData) }; if (data.entities.length > 0) { data.entityLabels = await Promise.all( data.entities.map(uri => resolveLabel(api, uri, labelCache)) ); } return data; } case "focus": { const basic = basicData as FocusData; const edgeSelections = await Promise.all(basic.edgeSelections.map(async (basicSel) => { const edgeTriples = await queryTriples( api, basicSel.edgeUri, undefined, 100, COLLECTION, explainGraph, ); const sel: EdgeSelection = { edgeUri: basicSel.edgeUri }; for (const et of edgeTriples) { const p = predIri(et); if (p === TG_EDGE) sel.edge = objQuotedTriple(et) || undefined; if (p === TG_REASONING) sel.reasoning = objValue(et); } if (sel.edge) { const [labels, sources] = await Promise.all([ Promise.all([ resolveLabel(api, sel.edge.s, labelCache), resolveLabel(api, sel.edge.p, labelCache), resolveLabel(api, sel.edge.o, labelCache), ]), queryEdgeProvenance(api, sel.edge, labelCache), ]); sel.edgeLabels = { s: labels[0], p: labels[1], o: labels[2] }; sel.sources = sources; } return sel; })); return { edgeSelections } as FocusData; } default: return basicData; } } // ── Component ─────────────────────────────────────────────────────── type QueryMode = "graph-rag" | "doc-rag" | "agent"; const queryModeLabels: Record = { "graph-rag": "Graph RAG", "doc-rag": "Doc RAG", "agent": "Agent", }; export function ExplainView() { const [input, setInput] = useState(""); const [queryMode, setQueryMode] = useState("graph-rag"); const [response, setResponse] = useState(""); const [agentMessages, setAgentMessages] = useState<{ type: string; text: string; done?: boolean }[]>([]); const [isQuerying, setIsQuerying] = useState(false); const [explainNodes, setExplainNodes] = useState([]); const [error, setError] = useState(null); const [highlightedNodeIds, setHighlightedNodeIds] = useState([]); const [highlightedEdgeIds, setHighlightedEdgeIds] = useState([]); const [sourcePanel, setSourcePanel] = useState(null); const scrollRef = useRef(null); const explainScrollRef = useRef(null); const labelCacheRef = useRef(new Map()); const { graphRag, documentRag, agent } = useInference({}); const socket = useSocket(); useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: "smooth" }); }, [response]); useEffect(() => { explainScrollRef.current?.scrollIntoView({ behavior: "smooth" }); }, [explainNodes]); // Fetch event data when new nodes arrive // Use a ref to access current nodes without re-rendering const nodesRef = useRef(explainNodes); nodesRef.current = explainNodes; const fetchNode = useCallback(async (explainId: string) => { setExplainNodes(prev => prev.map(n => n.explainId === explainId ? { ...n, fetching: true } : n )); try { const api = socket.flow("default"); const node = nodesRef.current.find(n => n.explainId === explainId); if (!node) return; const updateNode = (updates: Partial) => { setExplainNodes(prev => prev.map(n => n.explainId === explainId ? { ...n, ...updates } : n )); }; // Phase 1: Fetch event triples with backoff until settled. // These are eventually consistent — render progressively as they arrive. let latestEventType = "unknown"; let latestBasicData: EventData = {}; const settledTriples = await queryTriplesUntilSettled( api, node.explainId, (triples) => { latestEventType = getEventTypeFromTriples(triples); latestBasicData = parseBasicEventData(latestEventType, triples); updateNode({ eventType: latestEventType, data: latestBasicData, fetched: true, fetching: false }); }, 100, COLLECTION, node.explainGraph, ); if (settledTriples.length === 0) { updateNode({ fetched: true, fetching: false }); return; } // Phase 2: Enrich with KG lookups (labels, edge details, provenance). // These reference known-to-exist data — no retry needed, just fetch once. const enriched = await enrichEventData(api, latestEventType, settledTriples, latestBasicData, labelCacheRef.current, node.explainGraph); if (enriched !== latestBasicData) { updateNode({ data: enriched }); } } catch (err) { setExplainNodes(prev => prev.map(n => n.explainId === explainId ? { ...n, error: String(err), fetching: false } : n )); } }, [socket]); useEffect(() => { for (const node of explainNodes) { if (!node.fetched && !node.fetching && !node.error) { fetchNode(node.explainId); } } }, [explainNodes, fetchNode]); const addExplainEvent = useCallback((event: ExplainEvent) => { setExplainNodes(prev => { if (prev.some(n => n.explainId === event.explainId)) return prev; return [...prev, { explainId: event.explainId, explainGraph: event.explainGraph, eventType: "unknown", fetched: false, fetching: false, }]; }); }, []); const handleSubmit = useCallback(async (query: string) => { if (!query.trim() || isQuerying) return; setIsQuerying(true); setResponse(""); setAgentMessages([]); setExplainNodes([]); setHighlightedNodeIds([]); setHighlightedEdgeIds([]); setSourcePanel(null); setError(null); setInput(""); labelCacheRef.current.clear(); const trimmed = query.trim(); try { switch (queryMode) { case "graph-rag": { await graphRag({ input: trimmed, collection: COLLECTION, options: { maxSubgraphSize: 150 }, callbacks: { onChunk: (chunk: string) => setResponse(prev => prev + chunk), onExplain: addExplainEvent, onError: (err: string) => setError(err), }, }); break; } case "doc-rag": { await documentRag({ input: trimmed, collection: COLLECTION, callbacks: { onChunk: (chunk: string) => setResponse(prev => prev + chunk), onExplain: addExplainEvent, onError: (err: string) => setError(err), }, }); break; } case "agent": { // Track current streaming message per type const accum: Record = {}; const appendChunk = (type: string, chunk: string, complete?: boolean) => { accum[type] = (accum[type] || "") + chunk; const currentText = accum[type]; setAgentMessages(prev => { // Find existing in-progress message of this type at end const lastIdx = prev.length - 1; if (lastIdx >= 0 && prev[lastIdx].type === type && !prev[lastIdx].done) { const updated = [...prev]; updated[lastIdx] = { type, text: currentText, done: !!complete }; return updated; } // New message return [...prev, { type, text: currentText, done: !!complete }]; }); if (complete) { accum[type] = ""; } }; await agent({ input: trimmed, callbacks: { onThink: (chunk: string, complete?: boolean) => appendChunk("thinking", chunk, complete), onObserve: (chunk: string, complete?: boolean) => appendChunk("observation", chunk, complete), onAnswer: (chunk: string, complete?: boolean) => appendChunk("answer", chunk, complete), onExplain: addExplainEvent, onError: (err: string) => setError(err), }, }); break; } } } catch (err) { setError(String(err)); } finally { setIsQuerying(false); } }, [graphRag, documentRag, agent, queryMode, isQuerying, addExplainEvent]); // ── Derive graph nodes and edges from explain events ────────────── const { graphNodes, graphEdges } = useMemo(() => { const nodeMap = new Map(); const edgeList: ExplainGraphEdge[] = []; for (const node of explainNodes) { if (!node.fetched || !node.data) continue; if (node.eventType === "exploration") { const d = node.data as ExplorationData; const labels = d.entityLabels || []; d.entities.forEach((uri, i) => { if (!nodeMap.has(uri)) { nodeMap.set(uri, { id: uri, label: labels[i] || shortUri(uri), color: palette.blue }); } }); } if (node.eventType === "focus") { const d = node.data as FocusData; for (const sel of d.edgeSelections) { if (!sel.edge) continue; const { s, p, o } = sel.edge; const sLabel = sel.edgeLabels?.s || shortUri(s); const pLabel = sel.edgeLabels?.p || shortUri(p); const oLabel = sel.edgeLabels?.o || shortUri(o); // Ensure nodes exist if (!nodeMap.has(s)) nodeMap.set(s, { id: s, label: sLabel, color: palette.pink }); if (!nodeMap.has(o)) nodeMap.set(o, { id: o, label: oLabel, color: palette.pink }); edgeList.push({ id: sel.edgeUri, from: s, to: o, label: pLabel, reasoning: sel.reasoning, }); } } } return { graphNodes: Array.from(nodeMap.values()), graphEdges: edgeList }; }, [explainNodes]); // ── Entity/edge click → neighbourhood highlight on graph ───────── const handleEntityClick = useCallback((entityUri: string) => { // Highlight this node + connected edges + neighbour nodes const connectedEdges = graphEdges.filter(e => e.from === entityUri || e.to === entityUri); const neighbourIds = new Set([entityUri]); const edgeIds: string[] = []; for (const e of connectedEdges) { edgeIds.push(e.id); neighbourIds.add(e.from); neighbourIds.add(e.to); } setHighlightedNodeIds(Array.from(neighbourIds)); setHighlightedEdgeIds(edgeIds); }, [graphEdges]); const handleEdgeClick = useCallback((sel: EdgeSelection) => { // Highlight this edge + its two endpoint nodes const nodeIds: string[] = []; if (sel.edge) { nodeIds.push(sel.edge.s, sel.edge.o); } setHighlightedNodeIds(nodeIds); setHighlightedEdgeIds([sel.edgeUri]); }, []); const handleSourceClick = useCallback((source: ProvenanceChain) => { // chain[0] = chunk (closest to edge), chain[last] = root document const chunkNode = source.chain[0]; const docNode = source.chain[source.chain.length - 1]; if (!chunkNode || !docNode) return; // Same chunk — ignore (use the × button to close) if (sourcePanel?.chunkUri === chunkNode.uri) return; setSourcePanel({ chunkUri: chunkNode.uri, documentUri: docNode.uri, loading: true, }); const librarian = socket.librarian(); // Fetch parent document metadata (title, tags) from librarian librarian.getDocumentMetadata(docNode.uri).then(meta => { setSourcePanel(prev => prev?.chunkUri === chunkNode.uri ? { ...prev, documentTitle: meta?.title, documentTags: meta?.tags } : prev ); }).catch(() => { // Metadata not available — that's OK }); // The chunk URI is itself a document ID in the librarian — stream it directly let chunkText = ""; librarian.streamDocument( chunkNode.uri, (content, _chunkIndex, _totalChunks, complete) => { try { chunkText += atob(content); } catch { chunkText += content; } if (complete) { setSourcePanel(prev => prev?.chunkUri === chunkNode.uri ? { ...prev, chunkText, loading: false } : prev ); } }, (err) => { setSourcePanel(prev => prev?.chunkUri === chunkNode.uri ? { ...prev, loading: false, error: err } : prev ); }, ); }, [socket, sourcePanel?.chunkUri]); return (
{/* LHS: Query + Response */}
{queryModeLabels[queryMode].toUpperCase()} QUERY
{(["graph-rag", "doc-rag", "agent"] as QueryMode[]).map(mode => ( ))}
handleSubmit(input)} placeholder="Ask a question..." buttonText="Query" isLoading={isQuerying} buttonColor={palette.cyan} />
{error && (
ERROR
{error}
)} {!response && !isQuerying && !error && agentMessages.length === 0 && (
Ask a question to see {queryModeLabels[queryMode]} in action with live explainability.
)} {/* Streaming response for graph-rag and doc-rag */} {(response || (isQuerying && queryMode !== "agent")) && (
{response && (
RESPONSE
{response}
)} {isQuerying && (
{response ? "Streaming..." : "Processing query..."}
)}
)} {/* Agent messages */} {queryMode === "agent" && agentMessages.length > 0 && (
{agentMessages.map((msg, i) => { const colors: Record = { thinking: palette.purple, observation: palette.blue, answer: palette.emerald, }; const color = colors[msg.type] || text.muted; return (
{msg.type}
{msg.text}
); })}
)} {queryMode === "agent" && isQuerying && agentMessages.length === 0 && (
Agent is working...
)}
{/* Source text panel — shown when a provenance link is clicked */} {sourcePanel && (
{/* Header with document metadata */}
SOURCE {sourcePanel.documentTitle ? ( {sourcePanel.documentTitle} ) : ( {shortUri(sourcePanel.documentUri)} )} {sourcePanel.documentTags && sourcePanel.documentTags.length > 0 && ( {sourcePanel.documentTags.map((tag, i) => ( {tag} ))} )}
{/* Chunk text content */}
{sourcePanel.loading && (
Loading source text...
)} {sourcePanel.error && (
{sourcePanel.error}
)} {sourcePanel.chunkText && (
{sourcePanel.chunkText}
)}
)}
{/* RHS: Graph + Explainability panel */}
{/* Graph view — top half */}
{ setHighlightedNodeIds(prev => prev.includes(nodeId) ? prev.filter(id => id !== nodeId) : [...prev, nodeId] ); }} onEdgeClick={(edgeId) => { setHighlightedEdgeIds(prev => prev.includes(edgeId) ? prev.filter(id => id !== edgeId) : [...prev, edgeId] ); }} />
{/* Event cards — bottom half */}
EVENTS {explainNodes.length > 0 && ( {explainNodes.length} event{explainNodes.length !== 1 ? "s" : ""} )}
{explainNodes.length === 0 && !isQuerying && (
Explain events will appear here as the query progresses.
)} {isQuerying && explainNodes.length === 0 && (
Waiting for explain events...
)}
{explainNodes.map((node, idx) => ( ))}
); } // ── ExplainCard ───────────────────────────────────────────────────── function ExplainCard({ node, index, onEntityClick, onEdgeClick, onSourceClick }: { node: ExplainNode; index: number; onEntityClick?: (uri: string) => void; onEdgeClick?: (sel: EdgeSelection) => void; onSourceClick?: (source: ProvenanceChain) => void; }) { const typeColor = eventTypeColor(node.eventType); return (
{/* Header */}
{index + 1} {node.eventType} {node.fetching && ( loading... )}
{/* Event data */} {node.fetched && node.data && ( )} {node.error && (
{node.error}
)}
); } // ── EventDataView ─────────────────────────────────────────────────── function EventDataView({ eventType, data, onEntityClick, onEdgeClick, onSourceClick }: { eventType: string; data: EventData; onEntityClick?: (uri: string) => void; onEdgeClick?: (sel: EdgeSelection) => void; onSourceClick?: (source: ProvenanceChain) => void; }) { const mono = { fontFamily: "'IBM Plex Mono', monospace" } as const; switch (eventType) { case "question": { const d = data as QuestionData; return (
{d.query && (
Query: {d.query}
)} {d.timestamp && (
{d.timestamp}
)}
); } case "grounding": { const d = data as GroundingData; return (
{d.concepts.length > 0 && ( <>
{d.concepts.length} concept{d.concepts.length !== 1 ? "s" : ""} extracted
{d.concepts.map((concept, i) => ( {concept} ))}
)}
); } case "exploration": { const d = data as ExplorationData; return (
{d.edgeCount && (
Subgraph extracted: {d.edgeCount} edges
)} {d.chunkCount && (
Chunks retrieved: {d.chunkCount}
)} {d.entityLabels && d.entityLabels.length > 0 && (
{d.entityLabels.length} seed entit{d.entityLabels.length !== 1 ? "ies" : "y"}
{d.entityLabels.map((label, i) => ( onEntityClick?.(d.entities[i])} style={{ fontSize: 11, padding: "3px 8px", borderRadius: 4, background: withGlow(palette.blue, 0.1), border: `1px solid ${withGlow(palette.blue, 0.2)}`, color: text.secondary, ...mono, cursor: onEntityClick ? "pointer" : "default", transition: "all 0.15s ease", }} onMouseEnter={e => { if (onEntityClick) (e.currentTarget.style.background = withGlow(palette.blue, 0.25)); }} onMouseLeave={e => { (e.currentTarget.style.background = withGlow(palette.blue, 0.1)); }} > {label} ))}
)}
); } case "focus": { const d = data as FocusData; return (
{d.edgeSelections && d.edgeSelections.length > 0 && ( <>
Focused on {d.edgeSelections.length} edge{d.edgeSelections.length !== 1 ? "s" : ""}
{d.edgeSelections.map((sel, i) => ( onEdgeClick?.(sel)} onSourceClick={onSourceClick} /> ))} )}
); } case "synthesis": { const d = data as SynthesisData; return (
{d.contentLength != null && (
Synthesis: {d.contentLength} chars
)}
); } case "analysis": { const d = data as AnalysisData; let parsedArgs: Record | null = null; if (d.arguments) { try { parsedArgs = JSON.parse(d.arguments); } catch { /* ignore */ } } return (
{d.action && (
Tool: {d.action}
)} {parsedArgs && Object.entries(parsedArgs).map(([key, val]) => (
{key}: {String(val)}
))} {!parsedArgs && d.arguments && (
{d.arguments}
)}
); } case "conclusion": { const d = data as ConclusionData; return (
{d.documentUri && (
{shortUri(d.documentUri)}
)}
); } case "reflection": { const d = data as ReflectionData; return (
{d.documentUri && (
{shortUri(d.documentUri)}
)}
); } default: return null; } } // ── EdgeSelectionView ─────────────────────────────────────────────── function EdgeSelectionView({ sel, onClick, onSourceClick }: { sel: EdgeSelection; onClick?: () => void; onSourceClick?: (source: ProvenanceChain) => void; }) { const mono = { fontFamily: "'IBM Plex Mono', monospace" } as const; return (
{ if (onClick) e.currentTarget.style.background = withGlow(palette.purple, 0.08); }} onMouseLeave={e => { e.currentTarget.style.background = "transparent"; }} > {/* Edge triple */} {sel.edgeLabels && (
{sel.edgeLabels.s} {sel.edgeLabels.p} {sel.edgeLabels.o}
)} {/* Provenance sources — clickable to view source text */} {sel.sources && sel.sources.length > 0 && (
{sel.sources.map((source, si) => { const chainLabel = source.chain.map(c => c.label).join(" → "); return ( { e.stopPropagation(); onSourceClick?.(source); }} title={`View source: ${chainLabel}`} style={{ fontSize: 10, padding: "2px 7px", borderRadius: 4, background: withGlow(palette.amber, 0.08), border: `1px solid ${withGlow(palette.amber, 0.2)}`, color: text.hint, ...mono, cursor: onSourceClick ? "pointer" : "default", transition: "all 0.15s ease", }} onMouseEnter={e => { if (onSourceClick) { e.currentTarget.style.background = withGlow(palette.amber, 0.2); e.currentTarget.style.color = palette.amber; } }} onMouseLeave={e => { e.currentTarget.style.background = withGlow(palette.amber, 0.08); e.currentTarget.style.color = text.hint; }} > {chainLabel} ); })}
)} {/* Reasoning - compact */} {sel.reasoning && (
{sel.reasoning.length > 120 ? sel.reasoning.slice(0, 120) + "..." : sel.reasoning}
)}
); }