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) <noreply@anthropic.com>
This commit is contained in:
elpresidank 2026-04-12 02:55:46 -05:00
parent d5dd15be72
commit 87f6e5eb05
7 changed files with 806 additions and 160 deletions

View file

@ -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<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
// ---------------------------------------------------------------------------
// 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<Triple[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fetched, setFetched] = useState(false);
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
undefined,
);
const containerRef = useRef<HTMLDivElement>(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<string, string>();
for (const [, typeUri] of typeMap) {
const name = localName(typeUri);
if (!seen.has(name)) {
seen.set(name, typeUri);
}
}
return Array.from(seen.entries());
}, [typeMap]);
return (
<div className="mt-2 rounded-md border border-border/50">
{/* Toggle header */}
<button
onClick={() => setExpanded((p) => !p)}
aria-expanded={expanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs font-medium text-fg-muted hover:bg-surface-100/50"
>
{expanded ? (
<ChevronDown className="h-3 w-3 shrink-0" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" />
)}
<Network className="h-3 w-3 shrink-0 text-brand-400" />
<span>View source graph</span>
<Badge variant="info">{explainEvents.length} subgraph{explainEvents.length > 1 ? "s" : ""}</Badge>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border/50">
{loading && (
<div className="flex items-center gap-2 px-3 py-6 text-xs text-fg-subtle">
<Loader2 className="h-3 w-3 animate-spin" />
Loading source graph...
</div>
)}
{error && (
<p className="px-3 py-3 text-xs text-error">
Failed to load graph: {error}
</p>
)}
{!loading && !error && graphData.nodes.length === 0 && (
<p className="px-3 py-4 text-center text-xs text-fg-subtle">
No graph data available for this query.
</p>
)}
{!loading && graphData.nodes.length > 0 && (
<>
{/* Graph info bar */}
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] text-fg-subtle">
<span>
{graphData.nodes.length} nodes, {graphData.links.length} edges
</span>
<button
onClick={() => fgRef.current?.zoomToFit(400, 20)}
className="rounded p-1 hover:bg-surface-200 hover:text-fg"
title="Fit to view"
aria-label="Fit to view"
>
<Maximize className="h-3 w-3" />
</button>
</div>
{/* Mini graph canvas */}
<div
ref={containerRef}
className="relative bg-surface-0"
style={{ height: 280 }}
>
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin text-fg-subtle" />
</div>
}
>
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: GraphNode, color, ctx) => {
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}
/>
</Suspense>
</div>
{/* Mini type legend */}
{uniqueTypes.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 border-t border-border/50 px-3 py-2">
{uniqueTypes.slice(0, 8).map(([name]) => (
<div key={name} className="flex items-center gap-1.5 text-[10px] text-fg-subtle">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: hashColor(name) }}
/>
<span>{name}</span>
</div>
))}
{uniqueTypes.length > 8 && (
<span className="text-[10px] text-fg-subtle">
+{uniqueTypes.length - 8} more
</span>
)}
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View file

@ -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 (
<div
className={cn(
"absolute -top-8 right-2 z-10 flex items-center gap-0.5",
"rounded-lg border border-border bg-surface-200 px-1 py-0.5 shadow-sm",
"pointer-events-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100",
)}
>
<button
onClick={handleCopy}
className="rounded p-1.5 text-fg-subtle hover:bg-surface-300 hover:text-fg"
title={copied ? "Copied!" : "Copy message"}
aria-label={copied ? "Copied" : "Copy message"}
>
{copied ? (
<Check className="h-3 w-3 text-success" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
{isLastAssistant && onRegenerate && (
<button
onClick={onRegenerate}
className="rounded p-1.5 text-fg-subtle hover:bg-surface-300 hover:text-fg"
title="Regenerate response"
aria-label="Regenerate response"
>
<RotateCcw className="h-3 w-3" />
</button>
)}
<button
onClick={onDelete}
className="rounded p-1.5 text-fg-subtle hover:bg-error/20 hover:text-error"
title="Delete message"
aria-label="Delete message"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
}

View file

@ -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 };
}

View file

@ -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<ConversationState>()(
};
}),
deleteMessage: (id) =>
set((state) => ({
messages: state.messages.filter((m) => m.id !== id),
})),
clearMessages: () => set({ messages: [] }),
}),
{

View file

@ -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<string, string>;
typeMap: Map<string, string>;
} {
const labelMap = new Map<string, string>();
const typeMap = new Map<string, string>();
// 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<string, GraphNode>();
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,
};
}

View file

@ -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 }) {
)}
</div>
)}
{/* Explainability graph */}
{!isUser && !isError && !msg.isStreaming && msg.explainEvents && msg.explainEvents.length > 0 && (
<ExplainGraph explainEvents={msg.explainEvents} collection={collection} />
)}
</div>
);
}
@ -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() {
<div role="group" aria-label="Chat mode" className="flex rounded-lg border border-border bg-surface-100 p-0.5">
{MODES.map((mode) => (
<button
type="button"
key={mode.value}
onClick={() => setChatMode(mode.value)}
aria-pressed={chatMode === mode.value}
@ -282,20 +291,36 @@ export default function ChatPage() {
</div>
{/* Messages */}
<div className="flex-1 space-y-4 overflow-y-auto pb-4">
<div className="flex-1 space-y-4 overflow-y-auto pb-4 pt-10">
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-fg-subtle">
<MessageSquareText className="mb-3 h-10 w-10 opacity-30" />
<p>Send a message to start a conversation.</p>
<p className="mt-1 text-xs">
Mode: <span className="text-fg-muted">{chatMode}</span>
Mode: <span className="text-fg-muted">{MODES.find((m) => m.value === chatMode)?.label ?? chatMode}</span>
</p>
</div>
)}
{messages.map((msg) => (
<MessageBubble key={msg.id} msg={msg} />
))}
{messages.map((msg, idx) => {
const isLastAssistant =
msg.role === "assistant" &&
idx === messages.length - 1;
return (
<div key={msg.id} className="group relative">
{!msg.isStreaming && (
<MessageActions
content={msg.content}
isLastAssistant={isLastAssistant}
onDelete={() => deleteMessage(msg.id)}
onRegenerate={isLastAssistant ? regenerateLastMessage : undefined}
/>
)}
<MessageBubble msg={msg} collection={collection} />
</div>
);
})}
<div ref={scrollRef} />
</div>

View file

@ -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<ForceGraphProps<any, any> & { ref?: React.Ref<any> }>;
// ---------------------------------------------------------------------------
// 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<string, string>;
typeMap: Map<string, string>;
} {
const labelMap = new Map<string, string>();
const typeMap = new Map<string, string>();
// 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<string, GraphNode>();
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<string | null>(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<ForceGraphMethods<GraphNode, GraphLink> | undefined>(
undefined,
);
@ -322,6 +204,9 @@ export default function GraphPage() {
} | null>(null);
const roRef = useRef<ResizeObserver | null>(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<string, string>();
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() {
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-subtle" />
<input
id="graph-search"
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
@ -542,6 +444,44 @@ export default function GraphPage() {
</button>
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters((p) => !p)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
showFilters || hasActiveFilters
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
: "border-border text-fg-muted hover:bg-surface-200",
)}
title="Query filters"
aria-label="Toggle query filters"
aria-expanded={showFilters}
>
<Filter className="h-3.5 w-3.5" />
Filters
{hasActiveFilters && !showFilters && (
<span className="ml-0.5 h-1.5 w-1.5 rounded-full bg-brand-400" />
)}
</button>
{/* Legend toggle */}
{uniqueTypes.length > 0 && (
<button
onClick={() => setShowLegend((p) => !p)}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors",
showLegend
? "border-brand-500/50 bg-brand-600/10 text-brand-400"
: "border-border text-fg-muted hover:bg-surface-200",
)}
title="Type legend"
aria-label="Toggle type legend"
aria-expanded={showLegend}
>
Legend
</button>
)}
<button
onClick={fetchTriples}
disabled={loading}
@ -557,6 +497,96 @@ export default function GraphPage() {
</div>
</div>
{/* Filter panel */}
{showFilters && (
<div className="mb-4 rounded-lg border border-border bg-surface-50 p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xs font-medium text-fg-muted">
<Filter className="h-3 w-3" />
Query Filters
</h3>
{hasActiveFilters && (
<button
onClick={() => {
setSubjectFilter("");
setPredicateFilter("");
setObjectFilter("");
}}
className="text-xs text-brand-400 hover:text-brand-300"
>
Clear all
</button>
)}
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label htmlFor="filter-subject" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Subject
</label>
<input
id="filter-subject"
type="text"
value={subjectFilter}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="filter-predicate" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Predicate
</label>
<input
id="filter-predicate"
type="text"
value={predicateFilter}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1">
<label htmlFor="filter-object" className="block text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Object
</label>
<input
id="filter-object"
type="text"
value={objectFilter}
onChange={(e) => 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"
/>
</div>
</div>
<div className="mt-3 flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="filter-limit" className="text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Limit
</label>
<input
id="filter-limit"
type="range"
min={100}
max={5000}
step={100}
value={tripleLimit}
onChange={(e) => setTripleLimit(Number(e.target.value))}
className="w-24 accent-brand-500"
/>
<span className="text-xs text-fg-muted">{tripleLimit}</span>
</div>
<button
onClick={fetchTriples}
disabled={loading}
className="ml-auto flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-brand-500 disabled:opacity-40"
>
Apply
</button>
</div>
</div>
)}
{/* Content */}
{error && (
<p className="mb-4 rounded-lg bg-error/10 px-4 py-2 text-sm text-error">
@ -626,6 +656,26 @@ export default function GraphPage() {
)}
</div>
{/* Type legend overlay */}
{showLegend && uniqueTypes.length > 0 && (
<div className="absolute bottom-3 left-3 z-10 max-h-48 overflow-y-auto rounded-lg border border-border bg-surface-50/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-fg-subtle">
Node Types
</h4>
<div className="space-y-1">
{uniqueTypes.map(([name]) => (
<div key={name} className="flex items-center gap-2 text-xs text-fg-muted">
<span
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hashColor(name) }}
/>
<span className="truncate">{name}</span>
</div>
))}
</div>
</div>
)}
{/* Detail panel -- positioned absolutely so it overlays the graph */}
{selectedNode && (
<div className="absolute inset-y-0 right-0 z-10">