mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
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:
parent
d5dd15be72
commit
87f6e5eb05
7 changed files with 806 additions and 160 deletions
302
ts/packages/workbench/src/components/chat/explain-graph.tsx
Normal file
302
ts/packages/workbench/src/components/chat/explain-graph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] }),
|
||||
}),
|
||||
{
|
||||
|
|
|
|||
146
ts/packages/workbench/src/lib/graph-utils.ts
Normal file
146
ts/packages/workbench/src/lib/graph-utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue