"use client"; "use no memo"; import * as React from "react"; import { Background, BackgroundVariant, Controls, type Edge as RFEdge, Handle, MarkerType, MiniMap, type Node as RFNode, type NodeProps, Panel, Position, ReactFlow, ReactFlowProvider, useEdgesState, useNodesState, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { Bug, Compass, Flag, FlaskConical, Layers, Lightbulb, type LucideIcon, Target, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { StatusBadge } from "@/components/status-badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { toneClasses, type Tone } from "@/lib/status"; import type { Edge, ExploreKind, TaskNode } from "@/lib/types"; export const relLabel: Record = { spawns: "派生", derived_from: "意图链", yields: "产出", proves: "证明", }; export const relColor: Record = { spawns: "#10b981", derived_from: "#6366f1", yields: "#f59e0b", proves: "#ef4444", }; type TypeMeta = { label: string; icon: LucideIcon; iconBg: string; chip: string; hex: string; }; const typeMeta: Record = { task: { label: "根任务", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" }, begin: { label: "起点", icon: Flag, iconBg: "bg-slate-500", chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300", hex: "#64748b" }, goal: { label: "目标", icon: Target, iconBg: "bg-emerald-500", chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", hex: "#10b981" }, hint: { label: "提示", icon: Lightbulb, iconBg: "bg-violet-500", chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400", hex: "#8b5cf6" }, intent: { label: "意图", icon: Compass, iconBg: "bg-blue-500", chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400", hex: "#3b82f6" }, fact: { label: "事实", icon: FlaskConical, iconBg: "bg-amber-500", chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400", hex: "#f59e0b" }, finding: { label: "漏洞", icon: Bug, iconBg: "bg-rose-500", chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400", hex: "#f43f5e" }, digest: { label: "压缩", icon: Layers, iconBg: "bg-teal-500", chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400", hex: "#14b8a6" }, }; // The task root is an origin fact (kind='fact', state='origin'); render it as "起点". function viewKind(n: TaskNode): ExploreKind { return n.type === "fact" && n.state === "origin" ? "begin" : n.type; } function prioTone(p: number): Tone { return p >= 8 ? "red" : p >= 5 ? "amber" : "slate"; } const summaryFields: Record = { task: ["summary", "description"], begin: ["summary", "description"], goal: ["text"], hint: [], intent: ["summary"], fact: ["summary"], finding: ["name", "summary"], digest: ["body"], }; function nodeSummary(n: TaskNode): string { const raw = n.payload ?? ""; const fields = summaryFields[viewKind(n)] ?? []; if (fields.length && raw.trim()) { try { const obj: unknown = JSON.parse(raw); if (obj && typeof obj === "object") { for (const f of fields) { const v = (obj as Record)[f]; if (typeof v === "string" && v.trim()) return v; } } } catch { /* 非 JSON:原样显示 */ } } return raw; } // digestMemberIds reads the fact/intent ids a digest folds from its payload // (member_ids.{facts,intents}); ids are numbers in JSON, node ids are strings. function digestMemberIds(n: TaskNode): string[] { if (n.type !== "digest" || !n.payload) return []; try { const p = JSON.parse(n.payload) as { member_ids?: { facts?: Array; intents?: Array }; }; return [...(p.member_ids?.facts ?? []), ...(p.member_ids?.intents ?? [])].map(String); } catch { return []; } } // collapseDigestGraph contracts each active digest's covered members into the // digest super-node: the members are dropped from the rendered set and every // edge touching a member is rewired onto its digest (member→member internal // edges collapse to a self-loop and are dropped; the resulting edges are // deduped by src+rel+dst). covers edges are structural and never drawn. // Superseded digests (no covers edges) are dropped entirely. A graph with no // active digest passes through unchanged. The full node list is kept by the // caller so the detail drawer can still resolve a folded member by id. function collapseDigestGraph(nodes: TaskNode[], edges: Edge[]): { viewNodes: TaskNode[]; viewEdges: Edge[] } { const activeDigests = new Set( nodes.filter((n) => n.type === "digest" && n.state === "active").map((n) => n.id), ); const memberToDigest = new Map(); for (const e of edges) { if (e.rel === "covers" && activeDigests.has(e.src)) memberToDigest.set(e.dst, e.src); } const viewNodes = nodes.filter((n) => n.type === "digest" ? n.state === "active" : !memberToDigest.has(n.id), ); const remap = (id: string) => memberToDigest.get(id) ?? id; const seen = new Set(); const viewEdges: Edge[] = []; for (const e of edges) { if (e.rel === "covers") continue; const src = remap(e.src); const dst = remap(e.dst); if (src === dst) continue; const key = `${src}${e.rel}${dst}`; if (seen.has(key)) continue; seen.add(key); viewEdges.push(src === e.src && dst === e.dst ? e : { ...e, src, dst }); } return { viewNodes, viewEdges }; } const COL_W = 360; const ROW_H = 140; type ExploreNodeData = { node: TaskNode }; type ExploreRFNode = RFNode; function pushMap(m: Map, k: string, v: string) { const a = m.get(k); if (a) a.push(v); else m.set(k, [v]); } // computeLayout auto-layers nodes by longest path along edges (cycle-safe DFS // break + barycenter ordering), so origin → … → finding reads left-to-right. function computeLayout( nodes: TaskNode[], edges: Edge[], ): Map { const order = nodes.map((n) => n.id); const idset = new Set(order); const valid = edges.filter((e) => idset.has(e.src) && idset.has(e.dst)); const key = (s: string, d: string) => `${s}->${d}`; const out = new Map(); for (const e of valid) pushMap(out, e.src, e.dst); const seen = new Map(); const back = new Set(); for (const root of order) { if ((seen.get(root) ?? 0) !== 0) continue; const stack: Array<{ u: string; i: number }> = [{ u: root, i: 0 }]; seen.set(root, 1); while (stack.length) { const top = stack[stack.length - 1]; const nbrs = out.get(top.u) ?? []; if (top.i >= nbrs.length) { seen.set(top.u, 2); stack.pop(); continue; } const v = nbrs[top.i++]; const s = seen.get(v) ?? 0; if (s === 1) back.add(key(top.u, v)); else if (s === 0) { seen.set(v, 1); stack.push({ u: v, i: 0 }); } } } const dagSucc = new Map(); const indeg = new Map(order.map((id) => [id, 0])); for (const e of valid) { if (back.has(key(e.src, e.dst))) continue; pushMap(dagSucc, e.src, e.dst); indeg.set(e.dst, (indeg.get(e.dst) ?? 0) + 1); } const layer = new Map(order.map((id) => [id, 0])); const queue = order.filter((id) => (indeg.get(id) ?? 0) === 0); for (let qi = 0; qi < queue.length; qi++) { const u = queue[qi]; for (const v of dagSucc.get(u) ?? []) { layer.set(v, Math.max(layer.get(v) ?? 0, (layer.get(u) ?? 0) + 1)); const d = (indeg.get(v) ?? 0) - 1; indeg.set(v, d); if (d === 0) queue.push(v); } } const maxLayer = Math.max(0, ...layer.values()); const columns: string[][] = Array.from({ length: maxLayer + 1 }, () => []); for (const id of order) columns[layer.get(id) ?? 0].push(id); const colIndexOf = new Map(); columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c))); const neighbors = new Map(); for (const e of valid) { const cs = colIndexOf.get(e.src); const cd = colIndexOf.get(e.dst); if (cs === undefined || cd === undefined || Math.abs(cs - cd) !== 1) continue; pushMap(neighbors, e.src, e.dst); pushMap(neighbors, e.dst, e.src); } const sweep = (dir: 1 | -1) => { const start = dir === 1 ? 1 : columns.length - 2; const end = dir === 1 ? columns.length : -1; for (let c = start; c !== end; c += dir) { const ref = c - dir; const refIndex = new Map(columns[ref].map((id, i) => [id, i])); const scored = columns[c].map((id, i) => { const ns = (neighbors.get(id) ?? []) .map((nid) => refIndex.get(nid)) .filter((v): v is number => v !== undefined); const bary = ns.length ? ns.reduce((a, b) => a + b, 0) / ns.length : i; return { id, bary, i }; }); scored.sort((a, b) => a.bary - b.bary || a.i - b.i); columns[c] = scored.map((s) => s.id); } }; for (let iter = 0; iter < 4; iter++) { sweep(1); sweep(-1); } const maxCount = Math.max(1, ...columns.map((c) => c.length)); const midline = ((maxCount - 1) * ROW_H) / 2; const pos = new Map(); columns.forEach((ids, c) => { const startY = midline - ((ids.length - 1) * ROW_H) / 2; ids.forEach((id, i) => pos.set(id, { x: c * COL_W, y: startY + i * ROW_H })); }); return pos; } // Dify-style node card. function ExploreNode({ data, selected }: NodeProps) { const n = data.node; const kind = viewKind(n); const meta = typeMeta[kind] ?? typeMeta.intent; const Icon = meta.icon; const showState = n.type === "goal" || n.type === "intent"; const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0; const isLive = !n.inherited && n.type === "intent" && n.state === "running"; return (
{meta.label} {isLive && ( )} {showPriority && ( P{n.priority} )}
{nodeSummary(n) || meta.label}
{n.inherited && n.source_task_id ? ( 来源 #{n.source_task_id} ) : showState ? ( ) : ( {meta.label} )} #{n.id}
); } const nodeTypes = { explore: ExploreNode }; function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } function NodeDetailSheet({ node, nodesById, onSelect, onOpenChange, }: { node: TaskNode | null; nodesById: Map; onSelect: (n: TaskNode) => void; onOpenChange: (open: boolean) => void; }) { const [copied, setCopied] = React.useState(false); const meta = node ? (typeMeta[viewKind(node)] ?? typeMeta.intent) : null; const Icon = meta?.icon; const raw = node ? JSON.stringify(node, null, 2) : ""; const memberIds = node ? digestMemberIds(node) : []; const members = memberIds.map((id) => nodesById.get(id)).filter((m): m is TaskNode => !!m); const copy = () => { void navigator.clipboard?.writeText(raw).then(() => { setCopied(true); window.setTimeout(() => setCopied(false), 1500); }); }; return ( {node && meta && Icon && ( <>
{meta.label} {node.id}
{node.inherited && node.source_task_id && ( 来源任务 #{node.source_task_id} · 只读 )}

属性

{meta.label} {node.type === "goal" || node.type === "intent" ? ( ) : ( {node.state} )} {node.priority} {node.origin} {node.inherited && node.source_task_id && ( #{node.source_task_id}(继承,只读) )} {new Date(node.ts).toLocaleString("zh-CN")}
{node.type === "digest" && ( <> {nodeSummary(node) && (

压缩结论

{nodeSummary(node)}

)}

压缩的节点({memberIds.length})

{members.map((m) => { const mMeta = typeMeta[viewKind(m)] ?? typeMeta.intent; const MIcon = mMeta.icon; return ( ); })} {members.length < memberIds.length && ( 另有 {memberIds.length - members.length} 个成员节点未随当前图加载 )}
)}

原始数据

                    {raw}
                  
)}
); } // ExplorationGraphInner renders the given nodes/edges as a React Flow canvas. // Positions are auto-laid-out and preserved across prop updates (so a polling // caller's graph doesn't jump); click a node to open its detail drawer. function ExplorationGraphInner({ nodes, edges, emptyHint = "暂无探索数据", }: { nodes: TaskNode[]; edges: Edge[]; emptyHint?: string; }) { const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]); const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]); const [selected, setSelected] = React.useState(null); // Full node index (incl. folded members) so the digest drawer can resolve // covered members even though they are not rendered on the canvas. const nodesById = React.useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); // Collapsed view: folded members removed, their edges rewired onto the digest. const { viewNodes, viewEdges } = React.useMemo(() => collapseDigestGraph(nodes, edges), [nodes, edges]); const isEmpty = viewNodes.length === 0; React.useEffect(() => { setSelected((cur) => (cur ? (nodesById.get(cur.id) ?? cur) : cur)); const liveTargets = new Set( viewNodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id), ); const layout = computeLayout(viewNodes, viewEdges); setRfNodes((prev) => { const prevPos = new Map(prev.map((p) => [p.id, p.position])); return viewNodes.map((n) => ({ id: n.id, type: "explore" as const, position: prevPos.get(n.id) ?? layout.get(n.id) ?? { x: 0, y: 0 }, data: { node: n }, sourcePosition: Position.Right, targetPosition: Position.Left, })); }); setRfEdges( viewEdges.map((e, i) => { const color = relColor[e.rel] ?? "#94a3b8"; const animated = liveTargets.has(e.dst); return { id: `e${i}-${e.src}-${e.dst}`, source: e.src, target: e.dst, label: relLabel[e.rel] ?? e.rel, type: "default", animated, labelShowBg: false, labelStyle: { fontSize: 10, fontWeight: 600, fill: color }, style: { stroke: color, strokeWidth: 2, strokeOpacity: animated ? 0.9 : 0.55 }, markerEnd: { type: MarkerType.ArrowClosed, color, width: 14, height: 14 }, } satisfies RFEdge; }), ); }, [viewNodes, viewEdges, nodesById, setRfNodes, setRfEdges]); return ( <> setSelected(node.data.node)} nodeTypes={nodeTypes} fitView fitViewOptions={{ padding: 0.2 }} proOptions={{ hideAttribution: true }} onlyRenderVisibleElements minZoom={0.1} className="!bg-[#f0f2f7] dark:!bg-neutral-950" > { const data = node.data as ExploreNodeData | undefined; const k = data?.node ? viewKind(data.node) : "intent"; return typeMeta[k]?.hex ?? "#94a3b8"; }} nodeStrokeWidth={0} nodeBorderRadius={4} />
{isEmpty && {emptyHint}}
{(["begin", "goal", "intent", "fact", "finding", "hint", "digest"] as ExploreKind[]).map((k) => { const m = typeMeta[k]; const Icon = m.icon; return ( {m.label} ); })}
{Object.entries(relLabel).map(([k, v]) => ( {v} ))}
!o && setSelected(null)} /> ); } // ExplorationGraph is the shared探索图/攻击链路图 canvas — used by the task graph // tab (full graph, polled) and the finding lineage view (origin→finding subgraph). export function ExplorationGraph({ nodes, edges, className, emptyHint, }: { nodes: TaskNode[]; edges: Edge[]; className?: string; emptyHint?: string; }) { return (
); }