feat: decision tree visualization with React Flow + Dagre auto-layout

This commit is contained in:
Vectry
2026-02-09 23:58:41 +00:00
parent 21b4f9f316
commit 867e1e9eb1
6 changed files with 1151 additions and 24 deletions

View File

@@ -13,13 +13,16 @@
}, },
"dependencies": { "dependencies": {
"@agentlens/database": "*", "@agentlens/database": "*",
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0"
"lucide-react": "^0.469.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",

View File

@@ -38,7 +38,44 @@ export async function GET(
return NextResponse.json({ error: "Trace not found" }, { status: 404 }); return NextResponse.json({ error: "Trace not found" }, { status: 404 });
} }
return NextResponse.json({ trace }, { status: 200 }); // Transform data to match frontend expectations
const transformedTrace = {
...trace,
decisionPoints: trace.decisionPoints.map((dp) => ({
id: dp.id,
type: dp.type,
chosenAction: typeof dp.chosen === "string" ? dp.chosen : JSON.stringify(dp.chosen),
alternatives: dp.alternatives.map((alt) => (typeof alt === "string" ? alt : JSON.stringify(alt))),
reasoning: dp.reasoning,
contextSnapshot: dp.contextSnapshot as Record<string, unknown> | null,
confidence: null, // Not in schema, default to null
timestamp: dp.timestamp.toISOString(),
parentSpanId: dp.parentSpanId,
})),
spans: trace.spans.map((span) => ({
id: span.id,
name: span.name,
type: span.type,
status: span.status === "COMPLETED" ? "OK" : span.status === "ERROR" ? "ERROR" : "CANCELLED",
startedAt: span.startedAt.toISOString(),
endedAt: span.endedAt?.toISOString() ?? null,
durationMs: span.durationMs,
input: span.input,
output: span.output,
metadata: (span.metadata as Record<string, unknown>) ?? {},
parentSpanId: span.parentSpanId,
})),
events: trace.events.map((event) => ({
id: event.id,
type: event.type,
name: event.name,
timestamp: event.timestamp.toISOString(),
metadata: (event.metadata as Record<string, unknown>) ?? {},
spanId: event.spanId,
})),
};
return NextResponse.json({ trace: transformedTrace }, { status: 200 });
} catch (error) { } catch (error) {
console.error("Error retrieving trace:", error); console.error("Error retrieving trace:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 }); return NextResponse.json({ error: "Internal server error" }, { status: 500 });

View File

@@ -1,8 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { TraceDetail } from "@/components/trace-detail"; import { TraceDetail } from "@/components/trace-detail";
interface TraceResponse { interface TraceData {
trace: {
id: string; id: string;
name: string; name: string;
status: "RUNNING" | "COMPLETED" | "ERROR"; status: "RUNNING" | "COMPLETED" | "ERROR";
@@ -12,7 +11,7 @@ interface TraceResponse {
tags: string[]; tags: string[];
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
costUsd: number | null; costUsd: number | null;
}; totalCost: number | null;
decisionPoints: Array<{ decisionPoints: Array<{
id: string; id: string;
type: string; type: string;
@@ -22,6 +21,7 @@ interface TraceResponse {
contextSnapshot: Record<string, unknown> | null; contextSnapshot: Record<string, unknown> | null;
confidence: number | null; confidence: number | null;
timestamp: string; timestamp: string;
parentSpanId: string | null;
}>; }>;
spans: Array<{ spans: Array<{
id: string; id: string;
@@ -34,6 +34,7 @@ interface TraceResponse {
input: unknown; input: unknown;
output: unknown; output: unknown;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
parentSpanId: string | null;
}>; }>;
events: Array<{ events: Array<{
id: string; id: string;
@@ -41,9 +42,14 @@ interface TraceResponse {
name: string; name: string;
timestamp: string; timestamp: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
spanId: string | null;
}>; }>;
} }
interface TraceResponse {
trace: TraceData;
}
async function getTrace(id: string): Promise<TraceResponse | null> { async function getTrace(id: string): Promise<TraceResponse | null> {
try { try {
const res = await fetch(`http://localhost:3000/api/traces/${id}`, { const res = await fetch(`http://localhost:3000/api/traces/${id}`, {
@@ -76,12 +82,24 @@ export default async function TraceDetailPage({ params }: TraceDetailPageProps)
notFound(); notFound();
} }
const { trace } = data;
return ( return (
<TraceDetail <TraceDetail
trace={data.trace} trace={{
decisionPoints={data.decisionPoints} id: trace.id,
spans={data.spans} name: trace.name,
events={data.events} status: trace.status,
startedAt: trace.startedAt,
endedAt: trace.endedAt,
durationMs: trace.durationMs,
tags: trace.tags,
metadata: trace.metadata,
costUsd: trace.costUsd ?? trace.totalCost,
}}
decisionPoints={trace.decisionPoints}
spans={trace.spans}
events={trace.events}
/> />
); );
} }

View File

@@ -0,0 +1,797 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
ReactFlow,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type Edge,
type Node,
Handle,
Position,
MiniMap,
Controls,
Background,
BackgroundVariant,
ConnectionLineType,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "@dagrejs/dagre";
import {
GitBranch,
Activity,
Zap,
Terminal,
AlertCircle,
CheckCircle,
XCircle,
Clock,
ChevronDown,
ChevronRight,
Lightbulb,
Layers,
} from "lucide-react";
import { cn, formatDuration } from "@/lib/utils";
// Types matching trace-detail.tsx
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
interface DecisionPoint {
id: string;
type: string;
chosenAction: string;
alternatives: string[];
reasoning: string | null;
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
parentSpanId?: string | null;
}
interface Span {
id: string;
name: string;
type: string;
status: "OK" | "ERROR" | "CANCELLED";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
parentSpanId?: string | null;
}
interface Event {
id: string;
type: string;
name: string;
timestamp: string;
metadata: Record<string, unknown>;
spanId?: string | null;
}
interface Trace {
id: string;
name: string;
status: TraceStatus;
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
}
interface DecisionTreeProps {
trace: Trace;
decisionPoints: DecisionPoint[];
spans: Span[];
events: Event[];
}
// Color configurations
const spanTypeColors: Record<string, { bg: string; text: string; border: string }> = {
LLM_CALL: {
bg: "bg-purple-500/10",
text: "text-purple-400",
border: "border-purple-500/20",
},
TOOL_CALL: {
bg: "bg-blue-500/10",
text: "text-blue-400",
border: "border-blue-500/20",
},
CHAIN: {
bg: "bg-amber-500/10",
text: "text-amber-400",
border: "border-amber-500/20",
},
AGENT: {
bg: "bg-cyan-500/10",
text: "text-cyan-400",
border: "border-cyan-500/20",
},
MEMORY_OP: {
bg: "bg-pink-500/10",
text: "text-pink-400",
border: "border-pink-500/20",
},
CUSTOM: {
bg: "bg-neutral-700/30",
text: "text-neutral-400",
border: "border-neutral-600/30",
},
};
const spanStatusColors: Record<string, { dot: string; pulse?: boolean }> = {
OK: { dot: "bg-emerald-400" },
ERROR: { dot: "bg-red-400" },
CANCELLED: { dot: "bg-amber-400", pulse: true },
};
const decisionTypeColors: Record<string, { bg: string; text: string; border: string }> = {
TOOL_SELECTION: {
bg: "bg-blue-500/10",
text: "text-blue-400",
border: "border-blue-500/20",
},
PATH_SELECTION: {
bg: "bg-purple-500/10",
text: "text-purple-400",
border: "border-purple-500/20",
},
RESPONSE_FORMULATION: {
bg: "bg-emerald-500/10",
text: "text-emerald-400",
border: "border-emerald-500/20",
},
DEFAULT: {
bg: "bg-neutral-700/30",
text: "text-neutral-400",
border: "border-neutral-600/30",
},
};
const eventTypeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
LLM_CALL: Zap,
TOOL_CALL: Terminal,
ERROR: AlertCircle,
DEFAULT: Activity,
};
// Node data types
type TraceNodeData = {
trace: Trace;
} & Record<string, unknown>;
type SpanNodeData = {
span: Span;
maxDuration: number;
} & Record<string, unknown>;
type DecisionNodeData = {
decision: DecisionPoint;
} & Record<string, unknown>;
type EventNodeData = {
event: Event;
} & Record<string, unknown>;
// Custom Node Components
function TraceNode({ data, selected }: { data: TraceNodeData; selected?: boolean }) {
const { trace } = data;
const statusConfig: Record<TraceStatus, { icon: React.ComponentType<{ className?: string }>; color: string }> = {
RUNNING: { icon: Activity, color: "text-amber-400" },
COMPLETED: { icon: CheckCircle, color: "text-emerald-400" },
ERROR: { icon: XCircle, color: "text-red-400" },
};
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
return (
<div
className={cn(
"w-[300px] p-4 rounded-xl bg-neutral-900 border-2 transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-emerald-500/50 hover:border-emerald-400/70"
)}
>
<Handle type="target" position={Position.Top} className="!bg-emerald-500" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10">
<GitBranch className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-100 truncate">{trace.name}</h3>
<div className="flex items-center gap-2 mt-1">
<StatusIcon className={cn("w-3 h-3", status.color)} />
<span className={cn("text-xs", status.color)}>{trace.status}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-neutral-700">
<Clock className="w-3 h-3 text-neutral-500" />
<span className="text-xs text-neutral-400">{formatDuration(trace.durationMs)}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-emerald-500" />
</div>
);
}
function SpanNode({ data, selected }: { data: SpanNodeData; selected?: boolean }) {
const { span, maxDuration } = data;
const colors = spanTypeColors[span.type] || spanTypeColors.CUSTOM;
const statusColor = spanStatusColors[span.status] || spanStatusColors.CANCELLED;
const durationPercent = maxDuration > 0 ? ((span.durationMs || 0) / maxDuration) * 100 : 0;
return (
<div
className={cn(
"w-[260px] p-3 rounded-xl bg-neutral-900 border transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="flex items-start gap-2">
<div className={cn("w-2 h-2 rounded-full mt-1.5", statusColor.dot, statusColor.pulse && "animate-pulse")} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-neutral-100 text-sm truncate">{span.name}</h4>
<div
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium mt-1 border",
colors.bg,
colors.text,
colors.border
)}
>
{span.type}
</div>
</div>
</div>
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all"
style={{ width: `${durationPercent}%` }}
/>
</div>
<span className="text-xs text-neutral-400">{formatDuration(span.durationMs)}</span>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
function DecisionNode({ data, selected }: { data: DecisionNodeData; selected?: boolean }) {
const { decision } = data;
const [expanded, setExpanded] = useState(false);
const colors = decisionTypeColors[decision.type] || decisionTypeColors.DEFAULT;
return (
<div
className={cn(
"w-[240px] rounded-xl bg-neutral-900 border transition-all overflow-hidden",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<div
className={cn(
"px-1.5 py-0.5 rounded text-[10px] font-medium border",
colors.bg,
colors.text,
colors.border
)}
>
{decision.type}
</div>
{decision.confidence !== null && (
<span className="text-[10px] text-emerald-400 font-medium">
{Math.round(decision.confidence * 100)}%
</span>
)}
</div>
<p className="text-sm text-neutral-100 truncate">{decision.chosenAction}</p>
{decision.alternatives.length > 0 && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 mt-2 text-[10px] text-neutral-400 hover:text-neutral-200 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{decision.alternatives.length} alternative{decision.alternatives.length !== 1 ? "s" : ""}
</button>
)}
{expanded && decision.alternatives.length > 0 && (
<div className="mt-2 space-y-1">
{decision.alternatives.map((alt, idx) => (
<div
key={idx}
className="flex items-center gap-1 text-[10px] text-neutral-500 px-2 py-1 bg-neutral-800/50 rounded"
>
<span className="text-neutral-600">#{idx + 1}</span>
<span className="truncate">{alt}</span>
</div>
))}
</div>
)}
{decision.reasoning && (
<div className="mt-2 pt-2 border-t border-neutral-800">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Lightbulb className="w-3 h-3" />
<span className="truncate">{decision.reasoning}</span>
</div>
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
function EventNode({ data, selected }: { data: EventNodeData; selected?: boolean }) {
const { event } = data;
const Icon = eventTypeIcons[event.type] || eventTypeIcons.DEFAULT;
return (
<div
className={cn(
"w-[180px] p-2 rounded-xl bg-neutral-900 border transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-neutral-800">
<Icon className="w-3 h-3 text-neutral-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-neutral-100 truncate">{event.name}</p>
<p className="text-[10px] text-neutral-500">{event.type}</p>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
// Node types must be defined outside the component to avoid re-renders
const nodeTypes = {
trace: TraceNode,
span: SpanNode,
decision: DecisionNode,
event: EventNode,
};
// Build the tree layout using Dagre
function buildTreeLayout(
trace: Trace,
spans: Span[],
decisionPoints: DecisionPoint[],
events: Event[]
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: "TB",
ranksep: 80,
nodesep: 40,
});
const nodes: Node[] = [];
const edges: Edge[] = [];
// Add root trace node
const traceNodeId = `trace-${trace.id}`;
g.setNode(traceNodeId, { width: 300, height: 100 });
nodes.push({
id: traceNodeId,
type: "trace",
data: { trace } as TraceNodeData,
position: { x: 0, y: 0 },
});
// Calculate max duration for span bars
const maxDuration = Math.max(...spans.map((s) => s.durationMs || 0), 1);
// Add span nodes and build parent-child relationships
const spanMap = new Map<string, string>(); // spanId -> nodeId
const childSpans = new Map<string, string[]>(); // parentSpanId -> spanIds
spans.forEach((span) => {
const nodeId = `span-${span.id}`;
spanMap.set(span.id, nodeId);
if (span.parentSpanId) {
const siblings = childSpans.get(span.parentSpanId) || [];
siblings.push(span.id);
childSpans.set(span.parentSpanId, siblings);
}
g.setNode(nodeId, { width: 260, height: 80 });
nodes.push({
id: nodeId,
type: "span",
data: { span, maxDuration } as SpanNodeData,
position: { x: 0, y: 0 },
});
});
// Connect spans to their parents or to trace root
spans.forEach((span) => {
const nodeId = spanMap.get(span.id)!;
if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
const parentId = spanMap.get(span.parentSpanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: span.status === "ERROR" ? "#f87171" : "#34d399" },
});
} else {
// Top-level span - connect to trace root
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: span.status === "ERROR" ? "#f87171" : "#34d399" },
});
}
});
// Add decision nodes
decisionPoints.forEach((decision) => {
const nodeId = `decision-${decision.id}`;
g.setNode(nodeId, { width: 240, height: 90 });
nodes.push({
id: nodeId,
type: "decision",
data: { decision } as DecisionNodeData,
position: { x: 0, y: 0 },
});
// Connect to parent span or trace root
if (decision.parentSpanId && spanMap.has(decision.parentSpanId)) {
const parentId = spanMap.get(decision.parentSpanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#60a5fa" },
});
} else {
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#60a5fa" },
});
}
});
// Add event nodes
events.forEach((event) => {
const nodeId = `event-${event.id}`;
g.setNode(nodeId, { width: 180, height: 50 });
nodes.push({
id: nodeId,
type: "event",
data: { event } as EventNodeData,
position: { x: 0, y: 0 },
});
// Connect to span or trace root
if (event.spanId && spanMap.has(event.spanId)) {
const parentId = spanMap.get(event.spanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#a78bfa" },
});
} else {
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#a78bfa" },
});
}
});
// Calculate layout
dagre.layout(g);
// Update node positions from Dagre
nodes.forEach((node) => {
const dagreNode = g.node(node.id);
node.position = {
x: dagreNode.x - dagreNode.width / 2,
y: dagreNode.y - dagreNode.height / 2,
};
});
return { nodes, edges };
}
export function DecisionTree({ trace, spans, decisionPoints, events }: DecisionTreeProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildTreeLayout(trace, spans, decisionPoints, events),
[trace, spans, decisionPoints, events]
);
const [nodes, _, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge(params, eds));
},
[setEdges]
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode((prev) => (prev?.id === node.id ? null : node));
}, []);
// Empty state
if (spans.length === 0 && decisionPoints.length === 0 && events.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[600px] text-center">
<Layers className="w-12 h-12 text-neutral-600 mb-4" />
<h3 className="text-lg font-medium text-neutral-300 mb-2">No trace data available</h3>
<p className="text-sm text-neutral-500 max-w-md">
This trace does not contain any spans, decisions, or events to visualize.
</p>
</div>
);
}
return (
<div className="flex gap-4 h-[600px]">
<div className="flex-1 rounded-xl border border-neutral-800 overflow-hidden bg-neutral-950">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
minZoom={0.1}
maxZoom={2}
colorMode="dark"
attributionPosition="bottom-right"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#404040" />
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case "trace":
return "#10b981";
case "span":
return "#3b82f6";
case "decision":
return "#8b5cf6";
case "event":
return "#f59e0b";
default:
return "#6b7280";
}
}}
maskColor="rgba(23, 23, 23, 0.8)"
className="!bg-neutral-900/80 !border-neutral-700"
/>
<Controls className="!bg-neutral-900 !border-neutral-700 [&>button]:!bg-neutral-800 [&>button]:!border-neutral-700 [&>button]:!text-neutral-300 [&>button:hover]:!bg-neutral-700" />
</ReactFlow>
</div>
{/* Details Panel */}
{selectedNode && (
<div className="w-80 rounded-xl border border-neutral-800 bg-neutral-900 p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-neutral-100">Details</h3>
<button
onClick={() => setSelectedNode(null)}
className="text-neutral-400 hover:text-neutral-200 transition-colors"
>
<XCircle className="w-5 h-5" />
</button>
</div>
<NodeDetails node={selectedNode} />
</div>
)}
</div>
);
}
function NodeDetails({ node }: { node: Node }) {
switch (node.type) {
case "trace": {
const data = node.data as unknown as TraceNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.trace.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Status</span>
<p className="text-sm text-neutral-100 mt-1">{data.trace.status}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Duration</span>
<p className="text-sm text-neutral-100 mt-1">{formatDuration(data.trace.durationMs)}</p>
</div>
{data.trace.costUsd !== null && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Cost</span>
<p className="text-sm text-neutral-100 mt-1">${data.trace.costUsd.toFixed(4)}</p>
</div>
)}
{data.trace.tags.length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Tags</span>
<div className="flex flex-wrap gap-2 mt-1">
{data.trace.tags.map((tag) => (
<span key={tag} className="px-2 py-1 rounded bg-neutral-800 text-xs text-neutral-300">
{tag}
</span>
))}
</div>
</div>
)}
</div>
);
}
case "span": {
const data = node.data as unknown as SpanNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Status</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.status}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Duration</span>
<p className="text-sm text-neutral-100 mt-1">{formatDuration(data.span.durationMs)}</p>
</div>
{Boolean(data.span.input) && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Input</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.span.input, null, 2)}
</pre>
</div>
)}
{Boolean(data.span.output) && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Output</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.span.output, null, 2)}
</pre>
</div>
)}
</div>
);
}
case "decision": {
const data = node.data as unknown as DecisionNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.decision.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Chosen Action</span>
<p className="text-sm text-neutral-100 mt-1">{data.decision.chosenAction}</p>
</div>
{data.decision.confidence !== null && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Confidence</span>
<p className="text-sm text-emerald-400 mt-1">{Math.round(data.decision.confidence * 100)}%</p>
</div>
)}
{data.decision.alternatives.length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">
Alternatives ({data.decision.alternatives.length})
</span>
<div className="space-y-1 mt-1">
{data.decision.alternatives.map((alt, idx) => (
<p key={idx} className="text-xs text-neutral-400">
{idx + 1}. {alt}
</p>
))}
</div>
</div>
)}
{data.decision.reasoning && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Reasoning</span>
<p className="text-sm text-neutral-300 mt-1 italic">&ldquo;{data.decision.reasoning}&rdquo;</p>
</div>
)}
</div>
);
}
case "event": {
const data = node.data as unknown as EventNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.event.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.event.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Timestamp</span>
<p className="text-sm text-neutral-100 mt-1">{new Date(data.event.timestamp).toLocaleString()}</p>
</div>
{Object.keys(data.event.metadata).length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Metadata</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.event.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
}
default:
return null;
}
}

View File

@@ -20,9 +20,10 @@ import {
Terminal, Terminal,
} from "lucide-react"; } from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils"; import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { DecisionTree } from "./decision-tree";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR"; type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
type TabType = "decisions" | "spans" | "events"; type TabType = "tree" | "decisions" | "spans" | "events";
interface DecisionPoint { interface DecisionPoint {
id: string; id: string;
@@ -33,6 +34,7 @@ interface DecisionPoint {
contextSnapshot: Record<string, unknown> | null; contextSnapshot: Record<string, unknown> | null;
confidence: number | null; confidence: number | null;
timestamp: string; timestamp: string;
parentSpanId?: string | null;
} }
interface Span { interface Span {
@@ -46,6 +48,7 @@ interface Span {
input: unknown; input: unknown;
output: unknown; output: unknown;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
parentSpanId?: string | null;
} }
interface Event { interface Event {
@@ -54,6 +57,7 @@ interface Event {
name: string; name: string;
timestamp: string; timestamp: string;
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
spanId?: string | null;
} }
interface Trace { interface Trace {
@@ -138,7 +142,7 @@ export function TraceDetail({
spans, spans,
events, events,
}: TraceDetailProps) { }: TraceDetailProps) {
const [activeTab, setActiveTab] = useState<TabType>("decisions"); const [activeTab, setActiveTab] = useState<TabType>("tree");
const status = statusConfig[trace.status]; const status = statusConfig[trace.status];
const StatusIcon = status.icon; const StatusIcon = status.icon;
@@ -243,6 +247,12 @@ export function TraceDetail({
{/* Tabs */} {/* Tabs */}
<div className="border-b border-neutral-800"> <div className="border-b border-neutral-800">
<div className="flex gap-1"> <div className="flex gap-1">
<TabButton
active={activeTab === "tree"}
onClick={() => setActiveTab("tree")}
icon={GitBranch}
label="Tree"
/>
<TabButton <TabButton
active={activeTab === "decisions"} active={activeTab === "decisions"}
onClick={() => setActiveTab("decisions")} onClick={() => setActiveTab("decisions")}
@@ -265,7 +275,15 @@ export function TraceDetail({
</div> </div>
{/* Tab Content */} {/* Tab Content */}
<div className="min-h-[400px]"> <div className="min-h-[600px]">
{activeTab === "tree" && (
<DecisionTree
trace={trace}
spans={spans}
decisionPoints={decisionPoints}
events={events}
/>
)}
{activeTab === "decisions" && ( {activeTab === "decisions" && (
<DecisionsTab decisionPoints={decisionPoints} /> <DecisionsTab decisionPoints={decisionPoints} />
)} )}

258
package-lock.json generated
View File

@@ -22,6 +22,8 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@agentlens/database": "*", "@agentlens/database": "*",
"@dagrejs/dagre": "^2.0.4",
"@xyflow/react": "^12.10.0",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"next": "^15.1.0", "next": "^15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
@@ -29,6 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.0.0", "@tailwindcss/postcss": "^4.0.0",
"@types/dagre": "^0.7.53",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
@@ -58,6 +61,21 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
"integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "3.0.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz",
"integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==",
"license": "MIT"
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
@@ -1090,6 +1108,62 @@
"tailwindcss": "4.1.18" "tailwindcss": "4.1.18"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.10", "version": "22.19.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz",
@@ -1104,7 +1178,7 @@
"version": "19.2.13", "version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -1120,6 +1194,38 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/c12": { "node_modules/c12": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -1195,6 +1301,12 @@
"consola": "^3.2.3" "consola": "^3.2.3"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -1222,9 +1334,114 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
@@ -2195,6 +2412,43 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"packages/database": { "packages/database": {
"name": "@agentlens/database", "name": "@agentlens/database",
"version": "0.0.1", "version": "0.0.1",