diff --git a/web/src/app/(main)/function/findings/detail/lineage.tsx b/web/src/app/(main)/function/findings/detail/lineage.tsx index c6a112c..2bb1b8c 100644 --- a/web/src/app/(main)/function/findings/detail/lineage.tsx +++ b/web/src/app/(main)/function/findings/detail/lineage.tsx @@ -1,104 +1,14 @@ "use client"; import * as React from "react"; -import { - BugIcon, - CompassIcon, - FlagIcon, - FlaskConicalIcon, - LightbulbIcon, - type LucideIcon, - MapPinIcon, - CornerDownRightIcon, -} from "lucide-react"; -import { cn } from "@/lib/utils"; +import { ExplorationGraph } from "@/components/exploration-graph"; import { api } from "@/lib/api"; -import type { Edge, ExploreKind, TaskNode } from "@/lib/types"; - -// viewKind renders the task-root fact (origin) as a distinct "起点". -type ViewKind = ExploreKind | "begin"; -function viewKind(n: TaskNode): ViewKind { - return n.type === "fact" && n.state === "origin" ? "begin" : n.type; -} - -const KIND_META: Record = { - begin: { label: "起点", icon: MapPinIcon, cls: "text-slate-500 border-slate-500/30 bg-slate-500/10" }, - task: { label: "任务", icon: MapPinIcon, cls: "text-slate-500 border-slate-500/30 bg-slate-500/10" }, - goal: { label: "目标", icon: FlagIcon, cls: "text-violet-600 border-violet-500/30 bg-violet-500/10 dark:text-violet-400" }, - intent: { label: "意图", icon: CompassIcon, cls: "text-blue-600 border-blue-500/30 bg-blue-500/10 dark:text-blue-400" }, - fact: { label: "事实", icon: FlaskConicalIcon, cls: "text-amber-600 border-amber-500/30 bg-amber-500/10 dark:text-amber-400" }, - finding: { label: "漏洞", icon: BugIcon, cls: "text-rose-600 border-rose-500/30 bg-rose-500/10 dark:text-rose-400" }, - hint: { label: "提示", icon: LightbulbIcon, cls: "text-emerald-600 border-emerald-500/30 bg-emerald-500/10 dark:text-emerald-400" }, -}; - -const REL_LABEL: Record = { - spawns: "派生", - derived_from: "意图链", - yields: "产出", - proves: "证明", -}; - -const SUMMARY_FIELDS: Record = { - task: ["summary", "description"], - begin: ["summary", "description"], - goal: ["text"], - hint: ["text"], - intent: ["summary"], - fact: ["summary"], - finding: ["name", "summary"], -}; - -// nodeSummary pulls the display text out of the node's JSON payload. -function nodeSummary(n: TaskNode): string { - const raw = n.payload ?? ""; - const fields = SUMMARY_FIELDS[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 || "(无内容)"; -} - -// orderByDepth topologically layers the lineage nodes by their longest distance -// from a root (a node with no incoming edge inside the set), so the chain renders -// origin → … → finding top-down. Cycle-safe (caps iterations). -function orderByDepth(nodes: TaskNode[], edges: Edge[]): TaskNode[] { - const ids = new Set(nodes.map((n) => n.id)); - const parents = new Map(); - for (const e of edges) { - if (ids.has(e.src) && ids.has(e.dst)) { - parents.set(e.dst, [...(parents.get(e.dst) ?? []), e.src]); - } - } - const depth = new Map(); - const visiting = new Set(); - const calc = (id: string): number => { - const cached = depth.get(id); - if (cached !== undefined) return cached; - if (visiting.has(id)) return 0; // 回边:不再深入 - visiting.add(id); - const ps = parents.get(id) ?? []; - const d = ps.length === 0 ? 0 : 1 + Math.max(...ps.map(calc)); - visiting.delete(id); - depth.set(id, d); - return d; - }; - return [...nodes].sort((a, b) => { - const d = calc(a.id) - calc(b.id); - return d !== 0 ? d : Number(a.id) - Number(b.id); - }); -} +import type { Edge, TaskNode } from "@/lib/types"; +// FindingLineageView renders the exploration sub-graph from the task's initial +// node down to this finding's node — the same 攻击链路图 canvas as the task graph, +// scoped to just this finding's lineage. export function FindingLineageView({ findingId }: { findingId: string }) { const [nodes, setNodes] = React.useState([]); const [edges, setEdges] = React.useState([]); @@ -110,8 +20,8 @@ export function FindingLineageView({ findingId }: { findingId: string }) { .findingLineage(findingId) .then((g) => { if (!alive) return; - setNodes(g.nodes); - setEdges(g.edges); + setNodes(g.nodes ?? []); + setEdges(g.edges ?? []); }) .catch(() => {}) .finally(() => { @@ -122,24 +32,7 @@ export function FindingLineageView({ findingId }: { findingId: string }) { }; }, [findingId]); - const ordered = React.useMemo(() => orderByDepth(nodes, edges), [nodes, edges]); - const byId = React.useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); - // 每个节点的入边(父节点 + 关系),用于在卡片上方标注"从哪来、什么关系"。 - const incoming = React.useMemo(() => { - const m = new Map(); - const ids = new Set(nodes.map((n) => n.id)); - for (const e of edges) { - if (ids.has(e.src) && ids.has(e.dst)) { - m.set(e.dst, [...(m.get(e.dst) ?? []), { from: e.src, rel: e.rel }]); - } - } - return m; - }, [nodes, edges]); - - if (!loaded) { - return

加载中…

; - } - if (nodes.length === 0) { + if (loaded && nodes.length === 0) { return (

无链路可展示(该漏洞未关联探索节点,或所属任务已删除)。 @@ -148,71 +41,11 @@ export function FindingLineageView({ findingId }: { findingId: string }) { } return ( -

-

- 从任务初始节点回溯到本漏洞节点的探索链路(共 {nodes.length} 个节点)。 -

- {ordered.map((n, i) => { - const vk = viewKind(n); - const meta = KIND_META[vk] ?? KIND_META.fact; - const Icon = meta.icon; - const isFinding = vk === "finding"; - const ins = incoming.get(n.id) ?? []; - return ( -
- {/* 入边关系(除根外):从哪个父节点、什么关系而来 */} - {i > 0 && ins.length > 0 && ( -
- {ins.map((e, k) => { - const parent = byId.get(e.from); - const pKind = parent ? viewKind(parent) : "fact"; - return ( -
- - - {REL_LABEL[e.rel] ?? e.rel} - - - 来自 {KIND_META[pKind]?.label ?? pKind} - {parent && ( - · {nodeSummary(parent).slice(0, 40)} - )} - -
- ); - })} -
- )} - {/* 节点卡片 */} -
-
- - - {meta.label} - - {n.state && n.state !== "origin" && ( - {n.state} - )} - {isFinding && ( - ← 本漏洞 - )} - #{n.id} -
-

{nodeSummary(n)}

-
-
- ); - })} -
+ ); } diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index 4fd9b8f..c456ee4 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -329,13 +329,9 @@ function FindingDetailInner() { - {/* 链路图:从任务初始节点回溯到本漏洞节点的探索链路 */} + {/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */} - - - - - + diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/graph-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/graph-tab.tsx index 3c30826..b719bb4 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/graph-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/graph-tab.tsx @@ -1,486 +1,15 @@ "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, - Lightbulb, - type LucideIcon, - Target, -} from "lucide-react"; -import { cn } from "@/lib/utils"; import { Card, CardContent } from "@/components/ui/card"; -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 { ExplorationGraph } from "@/components/exploration-graph"; import { api } from "@/lib/api"; -import type { Edge, ExploreKind, TaskNode } from "@/lib/types"; +import type { Edge, TaskNode } from "@/lib/types"; -const relLabel: Record = { - spawns: "派生", - derived_from: "意图链", - yields: "产出", - proves: "证明", -}; - -const relColor: Record = { - spawns: "#10b981", - derived_from: "#6366f1", - yields: "#f59e0b", - proves: "#ef4444", -}; - -type TypeMeta = { - label: string; - icon: LucideIcon; - iconBg: string; // Dify 风格实心图标块底色 - chip: string; // 抽屉里用的柔和底色 - hex: string; // minimap / 连线取色 -}; - -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", - }, -}; - -// The task root is now an origin fact (kind='fact', state='origin') rather than a -// dedicated 'begin' node. Render/layout it with the "起点" visual (the `begin` -// entry above) so the graph root still reads as the origin. -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"; -} - -// 各类型节点在卡片上展示的字段:payload 通常是一段 JSON 字符串, -// 按类型取其中的 summary / text / description 作为显示内容。 -const summaryFields: Record = { - task: ["summary", "description"], - begin: ["summary", "description"], - goal: ["text"], - hint: [], - intent: ["summary"], - fact: ["summary"], - finding: ["summary"], -}; - -// 解析 payload 得到节点显示文案;解析失败或字段缺失时回退到原始 payload。 -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; -} - -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]); -} - -// 按连线的“递进深度”自动分层:顺着边从左往右排(意图→事实,事实/漏洞→再生意图…), -// 而不是按节点类型固定分列——这样反向/回环的边才不会互相缠绕。 -// 1) DFS 断环:指向递归栈中节点的边视为回边,不参与分层(处理 意图↔事实 这类回环); -// 2) 最长路径分层:谁在链路上更靠后,列就越靠右; -// 3) 重心排序:每层按相邻层邻居的平均位置重排,让有连线的节点对齐、减少交叉; -// 4) 每层整体垂直居中。 -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); - - // 1) 迭代式 DFS 断环 - 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 }); - } - } - } - - // 2) 最长路径分层(忽略回边) - 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); - } - } - - // 3) 按层分列 - 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); - } - - // 4) 垂直居中 - 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 工作流风格节点:白色圆角卡片 + 实心配色图标块 + 选中/运行蓝色描边。 -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.type === "intent" && n.state === "running"; - - return ( -
-
- - {/* 头部:实心图标块 + 类型标题 + 状态点 / 优先级 */} -
- - - - - {meta.label} - - {isLive && ( - - - - - )} - {showPriority && ( - - P{n.priority} - - )} -
- {/* 摘要 */} -
- {nodeSummary(n) || meta.label} -
- {/* 底部:状态徽章 + 来源/ID */} -
- {showState ? ( - - ) : ( - - {meta.label} - - )} - - #{n.id} - -
- -
-
- ); -} - -const nodeTypes = { explore: ExploreNode }; - -function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - -// 点击节点后弹出的抽屉:上半展示可读摘要,下半展示节点原始 JSON。 -function NodeDetailSheet({ - node, - onOpenChange, -}: { - node: TaskNode | null; - 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 copy = () => { - void navigator.clipboard?.writeText(raw).then(() => { - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); - }); - }; - - return ( - - - {node && meta && Icon && ( - <> - -
- - - -
- {meta.label} - {node.id} -
-
-
- - -
-
-

属性

- {meta.label} - - {node.type === "goal" || node.type === "intent" ? ( - - ) : ( - {node.state} - )} - - - {node.priority} - - - {node.origin} - - {new Date(node.ts).toLocaleString("zh-CN")} -
- -
-
-

原始数据

- -
-
-                    {raw}
-                  
-
-
-
- - )} -
-
- ); -} - -function GraphInner({ taskId }: { taskId: string }) { - const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]); - const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState([]); - const [isEmpty, setIsEmpty] = React.useState(true); - const [selected, setSelected] = React.useState(null); +export function GraphTab({ taskId }: { taskId: string }) { + const [nodes, setNodes] = React.useState([]); + const [edges, setEdges] = React.useState([]); React.useEffect(() => { let cancelled = false; @@ -489,51 +18,8 @@ function GraphInner({ taskId }: { taskId: string }) { .explorationGraph(taskId) .then((g) => { if (cancelled) return; - const nodes = g.nodes ?? []; - const edges: Edge[] = g.edges ?? []; - setIsEmpty(nodes.length === 0); - // 抽屉打开时,让其中的数据随轮询保持最新。 - setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur)); - - // 目标指向「执行中」意图的连线做流动动画,直观展示活跃探索路径。 - const liveTargets = new Set( - nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id), - ); - - const layout = computeLayout(nodes, edges); - setRfNodes((prev) => { - const prevPos = new Map(prev.map((p) => [p.id, p.position])); - return nodes.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( - edges.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; - }), - ); + setNodes(g.nodes ?? []); + setEdges(g.edges ?? []); }) .catch(() => { /* keep last good data */ @@ -545,88 +31,12 @@ function GraphInner({ taskId }: { taskId: string }) { cancelled = true; clearInterval(timer); }; - }, [taskId, setRfNodes, setRfEdges]); + }, [taskId]); - return ( - <> - setSelected(node.data.node)} - nodeTypes={nodeTypes} - fitView - fitViewOptions={{ padding: 0.2 }} - proOptions={{ hideAttribution: true }} - 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 && 暂无探索数据} -
- {(["begin", "goal", "intent", "fact", "finding", "hint"] 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)} /> - - ); -} - -export function GraphTab({ taskId }: { taskId: string }) { return ( -
- - - -
+
); diff --git a/web/src/components/exploration-graph.tsx b/web/src/components/exploration-graph.tsx new file mode 100644 index 0000000..49672fa --- /dev/null +++ b/web/src/components/exploration-graph.tsx @@ -0,0 +1,518 @@ +"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, + Lightbulb, + type LucideIcon, + Target, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +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" }, +}; + +// 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"], +}; + +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; +} + +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.type === "intent" && n.state === "running"; + + return ( +
+
+ +
+ + + + + {meta.label} + + {isLive && ( + + + + + )} + {showPriority && ( + + P{n.priority} + + )} +
+
+ {nodeSummary(n) || meta.label} +
+
+ {showState ? ( + + ) : ( + {meta.label} + )} + #{n.id} +
+ +
+
+ ); +} + +const nodeTypes = { explore: ExploreNode }; + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function NodeDetailSheet({ + node, + onOpenChange, +}: { + node: TaskNode | null; + 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 copy = () => { + void navigator.clipboard?.writeText(raw).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + }; + + return ( + + + {node && meta && Icon && ( + <> + +
+ + + +
+ {meta.label} + {node.id} +
+
+
+ + +
+
+

属性

+ {meta.label} + + {node.type === "goal" || node.type === "intent" ? ( + + ) : ( + {node.state} + )} + + + {node.priority} + + + {node.origin} + + {new Date(node.ts).toLocaleString("zh-CN")} +
+ +
+
+

原始数据

+ +
+
+                    {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); + const isEmpty = nodes.length === 0; + + React.useEffect(() => { + setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur)); + const liveTargets = new Set( + nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id), + ); + const layout = computeLayout(nodes, edges); + setRfNodes((prev) => { + const prevPos = new Map(prev.map((p) => [p.id, p.position])); + return nodes.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( + edges.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; + }), + ); + }, [nodes, edges, setRfNodes, setRfEdges]); + + return ( + <> + setSelected(node.data.node)} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.2 }} + proOptions={{ hideAttribution: true }} + 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"] 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 ( +
+ + + +
+ ); +}