diff --git a/ts/packages/workbench/src/components/chat/explain-graph.tsx b/ts/packages/workbench/src/components/chat/explain-graph.tsx new file mode 100644 index 00000000..adac7c97 --- /dev/null +++ b/ts/packages/workbench/src/components/chat/explain-graph.tsx @@ -0,0 +1,302 @@ +import { + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + Network, + ChevronRight, + ChevronDown, + Maximize, + Loader2, +} from "lucide-react"; +import { useSocket } from "@/providers/socket-provider"; +import { useSessionStore } from "@/hooks/use-session-store"; +import { + triplesToGraph, + localName, + hashColor, + type GraphNode, + type GraphLink, +} from "@/lib/graph-utils"; +import type { ExplainEvent, Triple } from "@trustgraph/client"; +import type { ForceGraphMethods, ForceGraphProps } from "react-force-graph-2d"; +import { Badge } from "@/components/ui/badge"; + +// --------------------------------------------------------------------------- +// Lazy-load ForceGraph2D (shares the same chunk as the graph page) +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType & { ref?: React.Ref }>; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +interface ExplainGraphProps { + explainEvents: ExplainEvent[]; + collection: string; +} + +export function ExplainGraph({ explainEvents, collection }: ExplainGraphProps) { + const socket = useSocket(); + const flowId = useSessionStore((s) => s.flowId); + + const [expanded, setExpanded] = useState(false); + const [triples, setTriples] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [fetched, setFetched] = useState(false); + + const fgRef = useRef | undefined>( + undefined, + ); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + // Track container width for the force graph + useEffect(() => { + if (!expanded || !containerRef.current) return; + const ro = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setContainerWidth(Math.floor(entry.contentRect.width)); + }); + ro.observe(containerRef.current); + return () => ro.disconnect(); + }, [expanded]); + + // Fetch triples when first expanded + useEffect(() => { + if (!expanded || fetched) return; + setFetched(true); + setLoading(true); + setError(null); + + const flow = socket.flow(flowId); + + // Fetch triples for each explain event's named graph and merge + Promise.all( + explainEvents.map((ev) => + flow + .triplesQuery(undefined, undefined, undefined, 500, collection, ev.explainGraph) + .catch(() => [] as Triple[]), + ), + ) + .then((results) => { + setTriples(results.flat()); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + setLoading(false); + }); + }, [expanded, fetched, explainEvents, socket, flowId, collection]); + + // Build graph data + const { data: graphData, typeMap } = useMemo( + () => triplesToGraph(triples), + [triples], + ); + + // Auto-fit once data loads + const hasAutoFit = useRef(false); + useEffect(() => { + if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) { + hasAutoFit.current = true; + const timer = setTimeout(() => fgRef.current?.zoomToFit(400, 20), 500); + return () => clearTimeout(timer); + } + }, [graphData.nodes.length]); + + // Node painting (simplified version of graph page) + const paintNode = useCallback( + (node: GraphNode, ctx: CanvasRenderingContext2D, globalScale: number) => { + const radius = Math.max(2.5, Math.sqrt(node.degree + 1) * 2); + const x = node.x ?? 0; + const y = node.y ?? 0; + + ctx.beginPath(); + ctx.arc(x, y, radius, 0, 2 * Math.PI); + ctx.fillStyle = node.color ?? "#5b80ff"; + ctx.fill(); + + const fontSize = Math.max(9 / globalScale, 1.5); + ctx.font = `${fontSize}px Inter, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + const isLight = document.documentElement.classList.contains("light"); + ctx.fillStyle = isLight + ? "rgba(24,24,27,0.85)" + : "rgba(250,250,250,0.85)"; + ctx.fillText(node.label, x, y + radius + 1); + }, + [], + ); + + // Link label painting + const paintLink = useCallback( + (link: GraphLink, ctx: CanvasRenderingContext2D, globalScale: number) => { + if (globalScale < 1.5) return; + const src = link.source as unknown as GraphNode; + const tgt = link.target as unknown as GraphNode; + if (!src.x || !tgt.x) return; + + const midX = ((src.x ?? 0) + (tgt.x ?? 0)) / 2; + const midY = ((src.y ?? 0) + (tgt.y ?? 0)) / 2; + + const fontSize = Math.max(7 / globalScale, 1.5); + ctx.font = `${fontSize}px Inter, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "rgba(161,161,170,0.6)"; + ctx.fillText(link.label, midX, midY); + }, + [], + ); + + // Compute unique types for mini legend + const uniqueTypes = useMemo(() => { + const seen = new Map(); + for (const [, typeUri] of typeMap) { + const name = localName(typeUri); + if (!seen.has(name)) { + seen.set(name, typeUri); + } + } + return Array.from(seen.entries()); + }, [typeMap]); + + return ( +
+ {/* Toggle header */} + + + {/* Expanded content */} + {expanded && ( +
+ {loading && ( +
+ + Loading source graph... +
+ )} + + {error && ( +

+ Failed to load graph: {error} +

+ )} + + {!loading && !error && graphData.nodes.length === 0 && ( +

+ No graph data available for this query. +

+ )} + + {!loading && graphData.nodes.length > 0 && ( + <> + {/* Graph info bar */} +
+ + {graphData.nodes.length} nodes, {graphData.links.length} edges + + +
+ + {/* Mini graph canvas */} +
+ + +
+ } + > + { + const radius = Math.max( + 2.5, + Math.sqrt(node.degree + 1) * 2, + ); + ctx.beginPath(); + ctx.arc( + node.x ?? 0, + node.y ?? 0, + radius + 2, + 0, + 2 * Math.PI, + ); + ctx.fillStyle = color; + ctx.fill(); + }} + linkCanvasObjectMode={() => "after"} + linkCanvasObject={paintLink} + linkColor={() => "rgba(91,128,255,0.25)"} + linkDirectionalArrowLength={3} + linkDirectionalArrowRelPos={0.85} + backgroundColor="transparent" + width={containerWidth || undefined} + height={280} + /> + +
+ + {/* Mini type legend */} + {uniqueTypes.length > 0 && ( +
+ {uniqueTypes.slice(0, 8).map(([name]) => ( +
+ + {name} +
+ ))} + {uniqueTypes.length > 8 && ( + + +{uniqueTypes.length - 8} more + + )} +
+ )} + + )} +
+ )} + + ); +} diff --git a/ts/packages/workbench/src/components/chat/message-actions.tsx b/ts/packages/workbench/src/components/chat/message-actions.tsx new file mode 100644 index 00000000..9998b4bd --- /dev/null +++ b/ts/packages/workbench/src/components/chat/message-actions.tsx @@ -0,0 +1,82 @@ +import { useState, useCallback } from "react"; +import { Copy, Check, Trash2, RotateCcw } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface MessageActionsProps { + content: string; + isLastAssistant: boolean; + onDelete: () => void; + onRegenerate?: () => void; +} + +export function MessageActions({ + content, + isLastAssistant, + onDelete, + onRegenerate, +}: MessageActionsProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback for insecure contexts + const textarea = document.createElement("textarea"); + textarea.value = content; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [content]); + + return ( +
+ + + {isLastAssistant && onRegenerate && ( + + )} + + +
+ ); +} diff --git a/ts/packages/workbench/src/hooks/use-chat.ts b/ts/packages/workbench/src/hooks/use-chat.ts index 97f8802e..22cfcfaa 100644 --- a/ts/packages/workbench/src/hooks/use-chat.ts +++ b/ts/packages/workbench/src/hooks/use-chat.ts @@ -8,7 +8,7 @@ import { import { useSessionStore } from "./use-session-store"; import { useProgressStore } from "./use-progress-store"; import { useSettings } from "@/providers/settings-provider"; -import type { StreamingMetadata } from "@trustgraph/client"; +import type { StreamingMetadata, ExplainEvent } from "@trustgraph/client"; // --------------------------------------------------------------------------- // Hook @@ -17,6 +17,7 @@ import type { StreamingMetadata } from "@trustgraph/client"; export interface UseChatReturn { submitMessage: (opts: { input: string }) => void; cancelRequest: () => void; + regenerateLastMessage: () => void; } /** @@ -93,6 +94,22 @@ export function useChat(): UseChatReturn { const flow = socket.flow(flowId); + // Collect explainability events during streaming + const explainEvents: ExplainEvent[] = []; + const onExplain = (event: ExplainEvent) => { + explainEvents.push(event); + }; + + // Attach collected explain events to the message on completion + const attachExplainEvents = () => { + if (explainEvents.length > 0) { + updateLastMessage((prev) => ({ + ...prev, + explainEvents: [...explainEvents], + })); + } + }; + // Shared handler for streaming responses (graph-rag / document-rag) const onChunk = ( chunk: string, @@ -115,6 +132,7 @@ export function useChat(): UseChatReturn { })); if (complete) { + attachExplainEvents(); removeActivity(activityLabel); } }; @@ -132,11 +150,11 @@ export function useChat(): UseChatReturn { // 3. Dispatch based on chat mode switch (chatMode) { case "graph-rag": - flow.graphRagStreaming(input, onChunk, onError, undefined, collection); + flow.graphRagStreaming(input, onChunk, onError, undefined, collection, onExplain); break; case "document-rag": - flow.documentRagStreaming(input, onChunk, onError, undefined, collection); + flow.documentRagStreaming(input, onChunk, onError, undefined, collection, onExplain); break; case "agent": { @@ -212,11 +230,14 @@ export function useChat(): UseChatReturn { }; }); if (complete) { + attachExplainEvents(); removeActivity(activityLabel); } }, // error onError, + // explainability + onExplain, ); break; } @@ -235,5 +256,15 @@ export function useChat(): UseChatReturn { ], ); - return { submitMessage, cancelRequest }; + const regenerateLastMessage = useCallback(() => { + const msgs = useConversation.getState().messages; + const lastAssistant = [...msgs].reverse().find((m) => m.role === "assistant"); + const lastUser = [...msgs].reverse().find((m) => m.role === "user"); + if (lastAssistant && lastUser) { + useConversation.getState().deleteMessage(lastAssistant.id); + submitMessage({ input: lastUser.content }); + } + }, [submitMessage]); + + return { submitMessage, cancelRequest, regenerateLastMessage }; } diff --git a/ts/packages/workbench/src/hooks/use-conversation.ts b/ts/packages/workbench/src/hooks/use-conversation.ts index cfbfb91c..0573d7f3 100644 --- a/ts/packages/workbench/src/hooks/use-conversation.ts +++ b/ts/packages/workbench/src/hooks/use-conversation.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import type { ExplainEvent } from "@trustgraph/client"; // --------------------------------------------------------------------------- // Types @@ -34,6 +35,8 @@ export interface ChatMessage { }; /** Indicates the current active phase during streaming */ activePhase?: AgentPhase; + /** Explainability events received during streaming (graph URIs for source subgraphs) */ + explainEvents?: ExplainEvent[]; } // --------------------------------------------------------------------------- @@ -59,6 +62,8 @@ interface ConversationState { updater: (prev: ChatMessage) => ChatMessage, ) => void; + deleteMessage: (id: string) => void; + clearMessages: () => void; } @@ -90,6 +95,11 @@ export const useConversation = create()( }; }), + deleteMessage: (id) => + set((state) => ({ + messages: state.messages.filter((m) => m.id !== id), + })), + clearMessages: () => set({ messages: [] }), }), { diff --git a/ts/packages/workbench/src/lib/graph-utils.ts b/ts/packages/workbench/src/lib/graph-utils.ts new file mode 100644 index 00000000..00b6605a --- /dev/null +++ b/ts/packages/workbench/src/lib/graph-utils.ts @@ -0,0 +1,146 @@ +import type { Triple, Term } from "@trustgraph/client"; +import type { NodeObject, LinkObject } from "react-force-graph-2d"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label"; +export const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GraphNode extends NodeObject { + id: string; + label: string; + color?: string; + /** Number of connections (used for sizing) */ + degree: number; +} + +export interface GraphLink extends LinkObject { + source: string; + target: string; + label: string; +} + +export interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; +} + +// --------------------------------------------------------------------------- +// Term helpers +// --------------------------------------------------------------------------- + +export function termValue(t: Term): string { + switch (t.t) { + case "i": + return t.i; + case "l": + return t.v; + case "b": + return t.d; + case "t": + return "[triple]"; + } +} + +export function isIri(t: Term): boolean { + return t.t === "i"; +} + +/** Extract the local name from a URI for display */ +export function localName(uri: string): string { + const hash = uri.lastIndexOf("#"); + const slash = uri.lastIndexOf("/"); + const idx = Math.max(hash, slash); + if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1); + return uri; +} + +/** Deterministic color from a string (for node types) */ +export function hashColor(s: string): string { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = s.charCodeAt(i) + ((hash << 5) - hash); + } + const hue = ((hash % 360) + 360) % 360; + return `hsl(${hue}, 60%, 55%)`; +} + +// --------------------------------------------------------------------------- +// Build graph data from triples +// --------------------------------------------------------------------------- + +export function triplesToGraph(triples: Triple[]): { + data: GraphData; + labelMap: Map; + typeMap: Map; +} { + const labelMap = new Map(); + const typeMap = new Map(); + + // First pass: collect labels and types + for (const t of triples) { + const pred = termValue(t.p); + if (pred === RDFS_LABEL && t.o.t === "l") { + labelMap.set(termValue(t.s), t.o.v); + } + if (pred === RDF_TYPE && isIri(t.o)) { + typeMap.set(termValue(t.s), termValue(t.o)); + } + } + + // Second pass: build nodes and links (skip structural triples) + const nodeMap = new Map(); + const links: GraphLink[] = []; + + const ensureNode = (uri: string): void => { + if (!nodeMap.has(uri)) { + const type = typeMap.get(uri); + nodeMap.set(uri, { + id: uri, + label: labelMap.get(uri) ?? localName(uri), + color: type ? hashColor(localName(type)) : "#5b80ff", + degree: 0, + }); + } + }; + + for (const t of triples) { + const sVal = termValue(t.s); + const pVal = termValue(t.p); + const oVal = termValue(t.o); + + // Skip label and type predicates -- they are metadata, not graph edges + if (pVal === RDFS_LABEL) continue; + if (pVal === RDF_TYPE) continue; + + // Build edges for entity-to-entity relationships. + // Include both IRIs and literals as valid entity nodes — plain-name + // knowledge graphs (e.g. seeded demo data) use literals for entities. + const sIsEntity = isIri(t.s) || t.s.t === "l"; + const oIsEntity = isIri(t.o) || t.o.t === "l"; + if (!sIsEntity || !oIsEntity) continue; + + ensureNode(sVal); + ensureNode(oVal); + nodeMap.get(sVal)!.degree++; + nodeMap.get(oVal)!.degree++; + + links.push({ + source: sVal, + target: oVal, + label: labelMap.get(pVal) ?? localName(pVal), + }); + } + + return { + data: { nodes: Array.from(nodeMap.values()), links }, + labelMap, + typeMap, + }; +} diff --git a/ts/packages/workbench/src/pages/chat.tsx b/ts/packages/workbench/src/pages/chat.tsx index c31afcfe..724176d5 100644 --- a/ts/packages/workbench/src/pages/chat.tsx +++ b/ts/packages/workbench/src/pages/chat.tsx @@ -25,6 +25,8 @@ import { useChat } from "@/hooks/use-chat"; import { useSettings } from "@/providers/settings-provider"; import { useProgressStore } from "@/hooks/use-progress-store"; import { AutoTextarea } from "@/components/ui/textarea"; +import { MessageActions } from "@/components/chat/message-actions"; +import { ExplainGraph } from "@/components/chat/explain-graph"; // --------------------------------------------------------------------------- // Constants @@ -112,7 +114,7 @@ function AgentPhaseBlock({ // Single message bubble // --------------------------------------------------------------------------- -function MessageBubble({ msg }: { msg: ChatMessage }) { +function MessageBubble({ msg, collection }: { msg: ChatMessage; collection: string }) { const isUser = msg.role === "user"; const hasAgentPhases = msg.agentPhases != null; const isError = !isUser && msg.content.startsWith("Error:"); @@ -185,6 +187,11 @@ function MessageBubble({ msg }: { msg: ChatMessage }) { )} )} + + {/* Explainability graph */} + {!isUser && !isError && !msg.isStreaming && msg.explainEvents && msg.explainEvents.length > 0 && ( + + )} ); } @@ -200,7 +207,8 @@ export default function ChatPage() { const setInput = useConversation((s) => s.setInput); const setChatMode = useConversation((s) => s.setChatMode); const clearMessages = useConversation((s) => s.clearMessages); - const { submitMessage, cancelRequest } = useChat(); + const { submitMessage, cancelRequest, regenerateLastMessage } = useChat(); + const deleteMessage = useConversation((s) => s.deleteMessage); const collection = useSettings((s) => s.settings.collection); const isLoading = useProgressStore((s) => s.isLoading); @@ -255,6 +263,7 @@ export default function ChatPage() {
{MODES.map((mode) => (
{/* Messages */} -
+
{messages.length === 0 && (

Send a message to start a conversation.

- Mode: {chatMode} + Mode: {MODES.find((m) => m.value === chatMode)?.label ?? chatMode}

)} - {messages.map((msg) => ( - - ))} + {messages.map((msg, idx) => { + const isLastAssistant = + msg.role === "assistant" && + idx === messages.length - 1; + + return ( +
+ {!msg.isStreaming && ( + deleteMessage(msg.id)} + onRegenerate={isLastAssistant ? regenerateLastMessage : undefined} + /> + )} + +
+ ); + })}
diff --git a/ts/packages/workbench/src/pages/graph.tsx b/ts/packages/workbench/src/pages/graph.tsx index 07b91d02..587c3a7c 100644 --- a/ts/packages/workbench/src/pages/graph.tsx +++ b/ts/packages/workbench/src/pages/graph.tsx @@ -17,6 +17,9 @@ import { X, ArrowRight, ArrowLeft, + Filter, + ChevronDown, + ChevronRight, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useSocket } from "@/providers/socket-provider"; @@ -25,6 +28,16 @@ import { useSettings } from "@/providers/settings-provider"; import { useProgressStore } from "@/hooks/use-progress-store"; import { Badge } from "@/components/ui/badge"; import type { Triple, Term } from "@trustgraph/client"; +import { + termValue, + localName, + hashColor, + triplesToGraph, + RDFS_LABEL, + RDF_TYPE, + type GraphNode, + type GraphLink, +} from "@/lib/graph-utils"; // --------------------------------------------------------------------------- // Lazy-load ForceGraph2D to keep bundle size down @@ -32,153 +45,13 @@ import type { Triple, Term } from "@trustgraph/client"; import type { ForceGraphMethods, - NodeObject, - LinkObject, ForceGraphProps, } from "react-force-graph-2d"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ForceGraph2D = lazy(() => import("react-force-graph-2d")) as unknown as React.ComponentType & { ref?: React.Ref }>; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -interface GraphNode extends NodeObject { - id: string; - label: string; - color?: string; - /** Number of connections (used for sizing) */ - degree: number; -} - -interface GraphLink extends LinkObject { - source: string; - target: string; - label: string; -} - -interface GraphData { - nodes: GraphNode[]; - links: GraphLink[]; -} - -// --------------------------------------------------------------------------- -// Helpers -- Term value extraction -// --------------------------------------------------------------------------- - -const RDFS_LABEL = "http://www.w3.org/2000/01/rdf-schema#label"; -const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; - -function termValue(t: Term): string { - switch (t.t) { - case "i": - return t.i; - case "l": - return t.v; - case "b": - return t.d; - case "t": - return "[triple]"; - } -} - -function isIri(t: Term): boolean { - return t.t === "i"; -} - -/** Extract the local name from a URI for display */ -function localName(uri: string): string { - const hash = uri.lastIndexOf("#"); - const slash = uri.lastIndexOf("/"); - const idx = Math.max(hash, slash); - if (idx >= 0 && idx < uri.length - 1) return uri.substring(idx + 1); - return uri; -} - -/** Deterministic color from a string (for node types) */ -function hashColor(s: string): string { - let hash = 0; - for (let i = 0; i < s.length; i++) { - hash = s.charCodeAt(i) + ((hash << 5) - hash); - } - const hue = ((hash % 360) + 360) % 360; - return `hsl(${hue}, 60%, 55%)`; -} - -// --------------------------------------------------------------------------- -// Build graph data from triples -// --------------------------------------------------------------------------- - -function triplesToGraph(triples: Triple[]): { - data: GraphData; - labelMap: Map; - typeMap: Map; -} { - const labelMap = new Map(); - const typeMap = new Map(); - - // First pass: collect labels and types - for (const t of triples) { - const pred = termValue(t.p); - if (pred === RDFS_LABEL && t.o.t === "l") { - labelMap.set(termValue(t.s), t.o.v); - } - if (pred === RDF_TYPE && isIri(t.o)) { - typeMap.set(termValue(t.s), termValue(t.o)); - } - } - - // Second pass: build nodes and links (skip structural triples) - const nodeMap = new Map(); - const links: GraphLink[] = []; - - const ensureNode = (uri: string): void => { - if (!nodeMap.has(uri)) { - const type = typeMap.get(uri); - nodeMap.set(uri, { - id: uri, - label: labelMap.get(uri) ?? localName(uri), - color: type ? hashColor(localName(type)) : "#5b80ff", - degree: 0, - }); - } - }; - - for (const t of triples) { - const sVal = termValue(t.s); - const pVal = termValue(t.p); - const oVal = termValue(t.o); - - // Skip label and type predicates -- they are metadata, not graph edges - if (pVal === RDFS_LABEL) continue; - if (pVal === RDF_TYPE) continue; - - // Build edges for entity-to-entity relationships. - // Include both IRIs and literals as valid entity nodes — plain-name - // knowledge graphs (e.g. seeded demo data) use literals for entities. - const sIsEntity = isIri(t.s) || t.s.t === "l"; - const oIsEntity = isIri(t.o) || t.o.t === "l"; - if (!sIsEntity || !oIsEntity) continue; - - ensureNode(sVal); - ensureNode(oVal); - nodeMap.get(sVal)!.degree++; - nodeMap.get(oVal)!.degree++; - - links.push({ - source: sVal, - target: oVal, - label: labelMap.get(pVal) ?? localName(pVal), - }); - } - - return { - data: { nodes: Array.from(nodeMap.values()), links }, - labelMap, - typeMap, - }; -} +// Graph helpers imported from @/lib/graph-utils // --------------------------------------------------------------------------- // Node detail panel @@ -313,6 +186,15 @@ export default function GraphPage() { const [searchTerm, setSearchTerm] = useState(""); const [selectedNode, setSelectedNode] = useState(null); + // Query filters + const [showFilters, setShowFilters] = useState(false); + const [subjectFilter, setSubjectFilter] = useState(""); + const [predicateFilter, setPredicateFilter] = useState(""); + const [objectFilter, setObjectFilter] = useState(""); + const [tripleLimit, setTripleLimit] = useState(2000); + const [showLegend, setShowLegend] = useState(false); + const hasActiveFilters = subjectFilter || predicateFilter || objectFilter; + const fgRef = useRef | undefined>( undefined, ); @@ -322,6 +204,9 @@ export default function GraphPage() { } | null>(null); const roRef = useRef(null); + // Auto-fit tracking — declared early so fetchTriples can reset it + const hasAutoFit = useRef(false); + // Ref callback — attaches ResizeObserver when the container mounts const containerRef = useCallback((el: HTMLDivElement | null) => { // Disconnect previous observer @@ -341,20 +226,25 @@ export default function GraphPage() { roRef.current = ro; }, []); - // Fetch triples + // Fetch triples with optional filters const fetchTriples = useCallback(async () => { const act = "Load graph"; try { setLoading(true); setError(null); addActivity(act); + hasAutoFit.current = false; const flow = socket.flow(flowId); + const s: Term | undefined = subjectFilter ? { t: "i", i: subjectFilter } : undefined; + const p: Term | undefined = predicateFilter ? { t: "i", i: predicateFilter } : undefined; + const o: Term | undefined = objectFilter ? { t: "i", i: objectFilter } : undefined; + const result = await flow.triplesQuery( - undefined, - undefined, - undefined, - 2000, + s, + p, + o, + tripleLimit, collection, ); setTriples(result); @@ -364,18 +254,30 @@ export default function GraphPage() { setLoading(false); removeActivity(act); } - }, [socket, flowId, collection, addActivity, removeActivity]); + }, [socket, flowId, collection, subjectFilter, predicateFilter, objectFilter, tripleLimit, addActivity, removeActivity]); useEffect(() => { fetchTriples(); }, [fetchTriples]); // Build graph - const { data: graphData, labelMap } = useMemo( + const { data: graphData, labelMap, typeMap } = useMemo( () => triplesToGraph(Array.isArray(triples) ? triples : []), [triples], ); + // Unique types for legend + const uniqueTypes = useMemo(() => { + const seen = new Map(); + for (const [, typeUri] of typeMap) { + const name = localName(typeUri); + if (!seen.has(name)) { + seen.set(name, typeUri); + } + } + return Array.from(seen.entries()); + }, [typeMap]); + // Search filter -- highlight matching nodes const searchLower = searchTerm.toLowerCase(); const matchingIds = useMemo(() => { @@ -396,7 +298,6 @@ export default function GraphPage() { : ""; // Auto-fit graph to view once data loads - const hasAutoFit = useRef(false); useEffect(() => { if (graphData.nodes.length > 0 && fgRef.current && !hasAutoFit.current) { hasAutoFit.current = true; @@ -496,6 +397,7 @@ export default function GraphPage() {
setSearchTerm(e.target.value)} @@ -542,6 +444,44 @@ export default function GraphPage() {
+ {/* Filter toggle */} + + + {/* Legend toggle */} + {uniqueTypes.length > 0 && ( + + )} +
+ {/* Filter panel */} + {showFilters && ( +
+
+

+ + Query Filters +

+ {hasActiveFilters && ( + + )} +
+
+
+ + setSubjectFilter(e.target.value)} + placeholder="URI filter..." + className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+ + setPredicateFilter(e.target.value)} + placeholder="URI filter..." + className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+ + setObjectFilter(e.target.value)} + placeholder="URI filter..." + className="w-full rounded-lg border border-border bg-surface-100 px-3 py-1.5 text-xs text-fg placeholder:text-fg-subtle focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500" + /> +
+
+
+
+ + setTripleLimit(Number(e.target.value))} + className="w-24 accent-brand-500" + /> + {tripleLimit} +
+ +
+
+ )} + {/* Content */} {error && (

@@ -626,6 +656,26 @@ export default function GraphPage() { )}

+ {/* Type legend overlay */} + {showLegend && uniqueTypes.length > 0 && ( +
+

+ Node Types +

+
+ {uniqueTypes.map(([name]) => ( +
+ + {name} +
+ ))} +
+
+ )} + {/* Detail panel -- positioned absolutely so it overlays the graph */} {selectedNode && (