trustgraph/src/pages/ExplainView.tsx

1412 lines
48 KiB
TypeScript
Raw Normal View History

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<string>();
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<BaseApi["flow"]>,
subject: string,
predicate?: string,
limit = 100,
collection = COLLECTION,
graph?: string,
): Promise<Triple[]> {
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<BaseApi["flow"]>,
subject: string,
onUpdate: (triples: Triple[]) => void,
limit = 100,
collection = COLLECTION,
graph?: string,
maxTries = 6,
): Promise<Triple[]> {
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<BaseApi["flow"]>,
uri: string,
cache: Map<string, string>,
): Promise<string> {
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<BaseApi["flow"]>,
startUri: string,
labelCache: Map<string, string>,
maxDepth = 10,
): Promise<ProvenanceChain> {
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<BaseApi["flow"]>,
edge: { s: string; p: string; o: string },
labelCache: Map<string, string>,
): Promise<ProvenanceChain[]> {
// Find subgraphs that contain this edge: ?subgraph tg:contains <<s p o>>
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<BaseApi["flow"]>,
eventType: string,
_triples: Triple[],
basicData: EventData,
labelCache: Map<string, string>,
explainGraph: string,
): Promise<EventData> {
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<QueryMode, string> = {
"graph-rag": "Graph RAG",
"doc-rag": "Doc RAG",
"agent": "Agent",
};
export function ExplainView() {
const [input, setInput] = useState("");
const [queryMode, setQueryMode] = useState<QueryMode>("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<ExplainNode[]>([]);
const [error, setError] = useState<string | null>(null);
const [highlightedNodeIds, setHighlightedNodeIds] = useState<string[]>([]);
const [highlightedEdgeIds, setHighlightedEdgeIds] = useState<string[]>([]);
const [sourcePanel, setSourcePanel] = useState<SourcePanelState | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const explainScrollRef = useRef<HTMLDivElement>(null);
const labelCacheRef = useRef(new Map<string, string>());
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<ExplainNode>) => {
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<string, string> = {};
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<string, ExplainGraphNode>();
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<string>([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 (
<div style={{ display: "flex", height: "calc(100vh - 110px)" }}>
{/* LHS: Query + Response */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", borderRight: `1px solid ${border.default}` }}>
<div style={{ padding: "20px 28px", borderBottom: `1px solid ${border.default}` }}>
<SectionLabel marginBottom={12}>{queryModeLabels[queryMode].toUpperCase()} QUERY</SectionLabel>
<div style={{ display: "flex", gap: 4, marginBottom: 12 }}>
{(["graph-rag", "doc-rag", "agent"] as QueryMode[]).map(mode => (
<button
key={mode}
onClick={() => setQueryMode(mode)}
disabled={isQuerying}
style={{
padding: "5px 14px", borderRadius: 6, fontSize: 11,
fontFamily: "'IBM Plex Mono', monospace", fontWeight: 600,
cursor: isQuerying ? "default" : "pointer",
background: queryMode === mode ? withGlow(palette.cyan, 0.15) : "transparent",
border: `1px solid ${queryMode === mode ? withGlow(palette.cyan, 0.4) : border.default}`,
color: queryMode === mode ? palette.cyan : text.muted,
opacity: isQuerying ? 0.5 : 1,
transition: "all 0.15s ease",
}}
>
{queryModeLabels[mode]}
</button>
))}
</div>
<SearchInput
value={input}
onChange={setInput}
onSubmit={() => handleSubmit(input)}
placeholder="Ask a question..."
buttonText="Query"
isLoading={isQuerying}
buttonColor={palette.cyan}
/>
</div>
<div style={{ flex: 1, padding: "24px 28px", overflowY: "auto" }}>
{error && (
<div style={{
padding: "12px 16px", borderRadius: 10,
background: withGlow(semantic.error, 0.08),
border: `1px solid ${withGlow(semantic.error, 0.2)}`,
marginBottom: 12,
}}>
<div style={{ fontSize: 10, color: withGlow(semantic.error, 0.53), fontFamily: "'IBM Plex Mono', monospace", marginBottom: 6 }}>ERROR</div>
<div style={{ fontSize: 13, color: text.secondary, lineHeight: 1.6 }}>{error}</div>
</div>
)}
{!response && !isQuerying && !error && agentMessages.length === 0 && (
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
Ask a question to see {queryModeLabels[queryMode]} in action with live explainability.
</div>
)}
{/* Streaming response for graph-rag and doc-rag */}
{(response || (isQuerying && queryMode !== "agent")) && (
<div>
{response && (
<div style={{
padding: "16px 20px", borderRadius: 10,
background: withGlow(semantic.answer, 0.08),
border: `1px solid ${withGlow(semantic.answer, 0.2)}`,
}}>
<div style={{ fontSize: 10, color: withGlow(semantic.answer, 0.53), fontFamily: "'IBM Plex Mono', monospace", marginBottom: 8 }}>
<span style={{ color: semantic.answer }}></span> RESPONSE
</div>
<div style={{ fontSize: 14, color: text.primary, lineHeight: 1.7, whiteSpace: "pre-wrap" }}>{response}</div>
</div>
)}
{isQuerying && (
<div style={{ padding: "8px 12px", fontSize: 11, color: withGlow(palette.cyan, 0.6), fontFamily: "'IBM Plex Mono', monospace", marginTop: 12 }}>
{response ? "Streaming..." : "Processing query..."}
</div>
)}
</div>
)}
{/* Agent messages */}
{queryMode === "agent" && agentMessages.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{agentMessages.map((msg, i) => {
const colors: Record<string, string> = {
thinking: palette.purple,
observation: palette.blue,
answer: palette.emerald,
};
const color = colors[msg.type] || text.muted;
return (
<div key={i} style={{
padding: "12px 16px", borderRadius: 10,
background: withGlow(color, 0.08),
border: `1px solid ${withGlow(color, 0.2)}`,
}}>
<div style={{ fontSize: 10, color: withGlow(color, 0.6), fontFamily: "'IBM Plex Mono', monospace", marginBottom: 6, textTransform: "uppercase", fontWeight: 600 }}>
{msg.type}
</div>
<div style={{ fontSize: 13, color: text.secondary, lineHeight: 1.6, whiteSpace: "pre-wrap" }}>{msg.text}</div>
</div>
);
})}
</div>
)}
{queryMode === "agent" && isQuerying && agentMessages.length === 0 && (
<div style={{ padding: "8px 12px", fontSize: 11, color: withGlow(palette.cyan, 0.6), fontFamily: "'IBM Plex Mono', monospace" }}>
Agent is working...
</div>
)}
<div ref={scrollRef} />
</div>
{/* Source text panel — shown when a provenance link is clicked */}
{sourcePanel && (
<div style={{
maxHeight: "40%", borderTop: `1px solid ${border.default}`,
display: "flex", flexDirection: "column",
background: withGlow(palette.amber, 0.03),
}}>
{/* Header with document metadata */}
<div style={{
padding: "8px 16px", borderBottom: `1px solid ${border.default}`,
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div style={{ fontSize: 11, fontFamily: "'IBM Plex Mono', monospace" }}>
<span style={{ fontWeight: 600, color: palette.amber }}>SOURCE</span>
{sourcePanel.documentTitle ? (
<span style={{ color: text.secondary, marginLeft: 8 }}>
{sourcePanel.documentTitle}
</span>
) : (
<span style={{ color: text.muted, marginLeft: 8 }}>
{shortUri(sourcePanel.documentUri)}
</span>
)}
{sourcePanel.documentTags && sourcePanel.documentTags.length > 0 && (
<span style={{ marginLeft: 8 }}>
{sourcePanel.documentTags.map((tag, i) => (
<span key={i} style={{
fontSize: 9, padding: "1px 6px", borderRadius: 3, marginLeft: 4,
background: withGlow(palette.cyan, 0.1),
border: `1px solid ${withGlow(palette.cyan, 0.2)}`,
color: text.subtle,
}}>
{tag}
</span>
))}
</span>
)}
</div>
<button
onClick={() => setSourcePanel(null)}
style={{
background: "none", border: "none", cursor: "pointer",
color: text.muted, fontSize: 16, padding: "0 4px",
lineHeight: 1,
}}
title="Close"
>
×
</button>
</div>
{/* Chunk text content */}
<div style={{ flex: 1, padding: "12px 16px", overflowY: "auto" }}>
{sourcePanel.loading && (
<div style={{ fontSize: 11, color: withGlow(palette.amber, 0.6), fontFamily: "'IBM Plex Mono', monospace" }}>
Loading source text...
</div>
)}
{sourcePanel.error && (
<div style={{ fontSize: 11, color: semantic.error, fontFamily: "'IBM Plex Mono', monospace" }}>
{sourcePanel.error}
</div>
)}
{sourcePanel.chunkText && (
<div style={{
fontSize: 12, color: text.secondary, lineHeight: 1.7,
whiteSpace: "pre-wrap",
}}>
{sourcePanel.chunkText}
</div>
)}
</div>
</div>
)}
</div>
{/* RHS: Graph + Explainability panel */}
<div style={{ width: "45%", display: "flex", flexDirection: "column" }}>
{/* Graph view — top half */}
<div style={{ height: "45%", borderBottom: `1px solid ${border.default}`, position: "relative" }}>
<ExplainGraph
nodes={graphNodes}
edges={graphEdges}
highlightedNodeIds={highlightedNodeIds}
highlightedEdgeIds={highlightedEdgeIds}
onNodeClick={(nodeId) => {
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]
);
}}
/>
</div>
{/* Event cards — bottom half */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
<div style={{ padding: "12px 20px", borderBottom: `1px solid ${border.default}` }}>
<SectionLabel>
EVENTS
{explainNodes.length > 0 && (
<span style={{ color: text.muted, fontWeight: 400, marginLeft: 8 }}>
{explainNodes.length} event{explainNodes.length !== 1 ? "s" : ""}
</span>
)}
</SectionLabel>
</div>
<div style={{ flex: 1, padding: "12px 16px", overflowY: "auto" }}>
{explainNodes.length === 0 && !isQuerying && (
<div style={{ color: text.hint, fontSize: 13, fontStyle: "italic" }}>
Explain events will appear here as the query progresses.
</div>
)}
{isQuerying && explainNodes.length === 0 && (
<div style={{ padding: "8px 12px", fontSize: 11, color: withGlow(palette.cyan, 0.6), fontFamily: "'IBM Plex Mono', monospace" }}>
Waiting for explain events...
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{explainNodes.map((node, idx) => (
<ExplainCard
key={node.explainId}
node={node}
index={idx}
onEntityClick={handleEntityClick}
onEdgeClick={handleEdgeClick}
onSourceClick={handleSourceClick}
/>
))}
</div>
<div ref={explainScrollRef} />
</div>
</div>
</div>
</div>
);
}
// ── 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 (
<div style={{
padding: "12px 16px", borderRadius: 8,
background: withGlow(typeColor, 0.06),
border: `1px solid ${withGlow(typeColor, 0.15)}`,
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
<span style={{
display: "inline-block", width: 20, height: 20, borderRadius: "50%",
background: withGlow(typeColor, 0.2), border: `1px solid ${withGlow(typeColor, 0.4)}`,
textAlign: "center", lineHeight: "20px", fontSize: 10, color: typeColor, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{
fontSize: 11, fontFamily: "'IBM Plex Mono', monospace",
color: typeColor, fontWeight: 600, textTransform: "uppercase",
}}>
{node.eventType}
</span>
{node.fetching && (
<span style={{ fontSize: 10, color: text.faint, fontStyle: "italic" }}>loading...</span>
)}
</div>
{/* Event data */}
{node.fetched && node.data && (
<EventDataView
eventType={node.eventType}
data={node.data}
onEntityClick={onEntityClick}
onEdgeClick={onEdgeClick}
onSourceClick={onSourceClick}
/>
)}
{node.error && (
<div style={{ fontSize: 10, color: semantic.error, marginTop: 4 }}>{node.error}</div>
)}
</div>
);
}
// ── 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 (
<div style={{ marginTop: 4 }}>
{d.query && (
<div style={{ fontSize: 12, color: text.secondary, lineHeight: 1.6, ...mono }}>
<span style={{ color: palette.amber }}>Query:</span> {d.query}
</div>
)}
{d.timestamp && (
<div style={{ fontSize: 10, color: text.faint, marginTop: 4, ...mono }}>
{d.timestamp}
</div>
)}
</div>
);
}
case "grounding": {
const d = data as GroundingData;
return (
<div style={{ marginTop: 4 }}>
{d.concepts.length > 0 && (
<>
<div style={{ fontSize: 11, color: palette.orange, marginBottom: 4, ...mono }}>
{d.concepts.length} concept{d.concepts.length !== 1 ? "s" : ""} extracted
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{d.concepts.map((concept, i) => (
<span key={i} style={{
fontSize: 11, padding: "3px 8px", borderRadius: 4,
background: withGlow(palette.orange, 0.1),
border: `1px solid ${withGlow(palette.orange, 0.2)}`,
color: text.secondary, ...mono,
}}>
{concept}
</span>
))}
</div>
</>
)}
</div>
);
}
case "exploration": {
const d = data as ExplorationData;
return (
<div style={{ marginTop: 4 }}>
{d.edgeCount && (
<div style={{ fontSize: 12, color: text.secondary, ...mono }}>
<span style={{ color: palette.blue }}>Subgraph extracted:</span> {d.edgeCount} edges
</div>
)}
{d.chunkCount && (
<div style={{ fontSize: 12, color: text.secondary, ...mono }}>
<span style={{ color: palette.blue }}>Chunks retrieved:</span> {d.chunkCount}
</div>
)}
{d.entityLabels && d.entityLabels.length > 0 && (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, color: palette.blue, marginBottom: 4, ...mono }}>
{d.entityLabels.length} seed entit{d.entityLabels.length !== 1 ? "ies" : "y"}
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{d.entityLabels.map((label, i) => (
<span
key={i}
onClick={() => 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}
</span>
))}
</div>
</div>
)}
</div>
);
}
case "focus": {
const d = data as FocusData;
return (
<div style={{ marginTop: 4 }}>
{d.edgeSelections && d.edgeSelections.length > 0 && (
<>
<div style={{ fontSize: 11, color: palette.purple, marginBottom: 6, ...mono }}>
Focused on {d.edgeSelections.length} edge{d.edgeSelections.length !== 1 ? "s" : ""}
</div>
{d.edgeSelections.map((sel, i) => (
<EdgeSelectionView key={sel.edgeUri || i} sel={sel} onClick={() => onEdgeClick?.(sel)} onSourceClick={onSourceClick} />
))}
</>
)}
</div>
);
}
case "synthesis": {
const d = data as SynthesisData;
return (
<div style={{ marginTop: 4 }}>
{d.contentLength != null && (
<div style={{ fontSize: 12, color: text.secondary, ...mono }}>
<span style={{ color: palette.emerald }}>Synthesis:</span> {d.contentLength} chars
</div>
)}
</div>
);
}
case "analysis": {
const d = data as AnalysisData;
let parsedArgs: Record<string, string> | null = null;
if (d.arguments) {
try { parsedArgs = JSON.parse(d.arguments); } catch { /* ignore */ }
}
return (
<div style={{ marginTop: 4 }}>
{d.action && (
<div style={{ fontSize: 12, color: text.secondary, ...mono, marginBottom: 4 }}>
<span style={{ color: palette.purple }}>Tool:</span> {d.action}
</div>
)}
{parsedArgs && Object.entries(parsedArgs).map(([key, val]) => (
<div key={key} style={{ fontSize: 11, color: text.muted, lineHeight: 1.5, ...mono }}>
<span style={{ color: text.subtle }}>{key}:</span> {String(val)}
</div>
))}
{!parsedArgs && d.arguments && (
<div style={{ fontSize: 11, color: text.muted, ...mono }}>
{d.arguments}
</div>
)}
</div>
);
}
case "conclusion": {
const d = data as ConclusionData;
return (
<div style={{ marginTop: 4 }}>
{d.documentUri && (
<div style={{ fontSize: 11, color: text.muted, ...mono }}>
{shortUri(d.documentUri)}
</div>
)}
</div>
);
}
case "reflection": {
const d = data as ReflectionData;
return (
<div style={{ marginTop: 4 }}>
{d.documentUri && (
<div style={{ fontSize: 11, color: text.muted, ...mono }}>
{shortUri(d.documentUri)}
</div>
)}
</div>
);
}
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 (
<div
onClick={onClick}
style={{
padding: "6px 10px", marginBottom: 4, borderRadius: 6,
borderLeft: `3px solid ${withGlow(palette.purple, 0.3)}`,
cursor: onClick ? "pointer" : "default",
transition: "background 0.15s ease",
}}
onMouseEnter={e => { if (onClick) e.currentTarget.style.background = withGlow(palette.purple, 0.08); }}
onMouseLeave={e => { e.currentTarget.style.background = "transparent"; }}
>
{/* Edge triple */}
{sel.edgeLabels && (
<div style={{ fontSize: 11, lineHeight: 1.5, ...mono }}>
<span style={{ color: palette.pink }}>{sel.edgeLabels.s}</span>
<span style={{ color: text.faint }}> </span>
<span style={{ color: palette.cyan }}>{sel.edgeLabels.p}</span>
<span style={{ color: text.faint }}> </span>
<span style={{ color: palette.pink }}>{sel.edgeLabels.o}</span>
</div>
)}
{/* Provenance sources — clickable to view source text */}
{sel.sources && sel.sources.length > 0 && (
<div style={{ marginTop: 3, display: "flex", flexWrap: "wrap", gap: 4 }}>
{sel.sources.map((source, si) => {
const chainLabel = source.chain.map(c => c.label).join(" → ");
return (
<span
key={si}
onClick={(e) => {
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}
</span>
);
})}
</div>
)}
{/* Reasoning - compact */}
{sel.reasoning && (
<div style={{ fontSize: 10, color: text.subtle, lineHeight: 1.4, fontStyle: "italic", marginTop: 2 }}>
{sel.reasoning.length > 120 ? sel.reasoning.slice(0, 120) + "..." : sel.reasoning}
</div>
)}
</div>
);
}