From 87f6e5eb05944fea4bf2701877f8e457d0171127 Mon Sep 17 00:00:00 2001 From: elpresidank Date: Sun, 12 Apr 2026 02:55:46 -0500 Subject: [PATCH] feat: chat message actions, explainability graphs, and graph query filters Add chat UX improvements: message actions toolbar (copy/delete/regenerate) on hover, inline explainability subgraph visualization from RAG/agent queries, and token metadata for all chat modes. Enhance graph page with SPO query filters, configurable triple limit, and type legend overlay. Extract shared graph utilities for reuse across components. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/chat/explain-graph.tsx | 302 +++++++++++++++ .../src/components/chat/message-actions.tsx | 82 +++++ ts/packages/workbench/src/hooks/use-chat.ts | 39 +- .../workbench/src/hooks/use-conversation.ts | 10 + ts/packages/workbench/src/lib/graph-utils.ts | 146 ++++++++ ts/packages/workbench/src/pages/chat.tsx | 39 +- ts/packages/workbench/src/pages/graph.tsx | 348 ++++++++++-------- 7 files changed, 806 insertions(+), 160 deletions(-) create mode 100644 ts/packages/workbench/src/components/chat/explain-graph.tsx create mode 100644 ts/packages/workbench/src/components/chat/message-actions.tsx create mode 100644 ts/packages/workbench/src/lib/graph-utils.ts 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 && (