refactor: 抽出共享 ExplorationGraph 画布,漏洞链路图改用 React Flow

- 新增 components/exploration-graph.tsx:把任务图谱 Tab 的 React Flow 渲染
  (节点卡片/自动分层布局/连线/小地图/图例/点击抽屉)整体抽成 <ExplorationGraph
  nodes edges> 复用组件,取 {nodes,edges} 作为 props
- graph-tab.tsx 精简为纯取数(轮询 explorationGraph)+ 复用该画布
- 漏洞详情「链路图」Tab 改用同一画布渲染 lineage 子图(从初始节点到漏洞节点),
  与任务攻击链路图完全一致的观感;之前的纵向卡片版删除

纯前端重构,后端 lineage 接口不变。tsc 通过(tasks/page.tsx 的既有报错与本次无关)。
This commit is contained in:
Autumn-27 committed 2026-08-18 13:13:48 -04:00
1 parent b741ee176c
commit fcc1e75a85
4 files changed
+543 -786

No files matched your search

@@ -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<ViewKind, { label: string; icon: LucideIcon; cls: string }> = {
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<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
const SUMMARY_FIELDS: Record<ViewKind, string[]> = {
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<string, unknown>)[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<string, string[]>();
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<string, number>();
const visiting = new Set<string>();
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<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
@@ -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<string, { from: string; rel: string }[]>();
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 <p className="text-muted-foreground p-6 text-sm">加载中…</p>;
}
if (nodes.length === 0) {
if (loaded && nodes.length === 0) {
return (
<p className="text-muted-foreground p-6 text-sm">
无链路可展示(该漏洞未关联探索节点,或所属任务已删除)。
@@ -148,71 +41,11 @@ export function FindingLineageView({ findingId }: { findingId: string }) {
}
return (
<div className="flex flex-col gap-0">
<p className="text-muted-foreground mb-3 text-xs">
从任务初始节点回溯到本漏洞节点的探索链路(共 {nodes.length} 个节点)。
</p>
{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 (
<div key={n.id} className="flex flex-col">
{/* 入边关系(除根外):从哪个父节点、什么关系而来 */}
{i > 0 && ins.length > 0 && (
<div className="text-muted-foreground ml-4 flex flex-col gap-0.5 border-l-2 py-1.5 pl-4 text-xs">
{ins.map((e, k) => {
const parent = byId.get(e.from);
const pKind = parent ? viewKind(parent) : "fact";
return (
<div key={`${e.from}-${e.rel}-${k}`} className="flex items-center gap-1">
<CornerDownRightIcon className="size-3 shrink-0" />
<span className="rounded bg-muted px-1 py-0.5 font-medium">
{REL_LABEL[e.rel] ?? e.rel}
</span>
<span className="truncate">
来自 {KIND_META[pKind]?.label ?? pKind}
{parent && (
<span className="opacity-70"> · {nodeSummary(parent).slice(0, 40)}</span>
)}
</span>
</div>
);
})}
</div>
)}
{/* 节点卡片 */}
<div
className={cn(
"rounded-md border p-3",
isFinding ? "border-rose-500/60 bg-rose-500/5 ring-1 ring-rose-500/30" : "bg-card",
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-xs font-medium",
meta.cls,
)}
>
<Icon className="size-3" />
{meta.label}
</span>
{n.state && n.state !== "origin" && (
<span className="text-muted-foreground text-[10px] uppercase">{n.state}</span>
)}
{isFinding && (
<span className="text-rose-600 dark:text-rose-400 text-xs font-medium">← 本漏洞</span>
)}
<span className="text-muted-foreground ml-auto font-mono text-[10px]">#{n.id}</span>
</div>
<p className="mt-1.5 text-sm whitespace-pre-wrap">{nodeSummary(n)}</p>
</div>
</div>
);
})}
</div>
<ExplorationGraph
nodes={nodes}
edges={edges}
className="h-[68vh]"
emptyHint={loaded ? "无链路" : "加载中…"}
/>
);
}
@@ -329,13 +329,9 @@ function FindingDetailInner() {
</div>
</TabsContent>
{/* 链路图:从任务初始节点回溯到本漏洞节点的探索链路 */}
{/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */}
<TabsContent value="lineage" className="mt-0">
<Card>
<CardContent className="pt-6">
<FindingLineageView findingId={finding.id} />
</CardContent>
</Card>
<FindingLineageView findingId={finding.id} />
</TabsContent>
</div>
</Tabs>
@@ -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<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
const relColor: Record<string, string> = {
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<ExploreKind, TypeMeta> = {
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<ExploreKind, string[]> = {
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<string, unknown>)[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<ExploreNodeData, "explore">;
function pushMap(m: Map<string, string[]>, 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<string, { x: number; y: number }> {
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<string, string[]>();
for (const e of valid) pushMap(out, e.src, e.dst);
// 1) 迭代式 DFS 断环
const seen = new Map<string, 0 | 1 | 2>();
const back = new Set<string>();
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<string, string[]>();
const indeg = new Map<string, number>(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<string, number>(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<string, number>();
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
const neighbors = new Map<string, string[]>();
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<string, { x: number; y: number }>();
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<ExploreRFNode>) {
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 (
<div
className={cn(
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
)}
>
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
<Handle
type="target"
position={Position.Left}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
{/* 头部:实心图标块 + 类型标题 + 状态点 / 优先级 */}
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-3.5 text-white" />
</span>
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
{meta.label}
</span>
{isLive && (
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
</span>
)}
{showPriority && (
<span
className={cn(
"shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
toneClasses[prioTone(n.priority)],
)}
>
P{n.priority}
</span>
)}
</div>
{/* 摘要 */}
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
{nodeSummary(n) || meta.label}
</div>
{/* 底部:状态徽章 + 来源/ID */}
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
<StatusBadge
domain={n.type === "goal" ? "goal" : "intent"}
value={n.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">
{meta.label}
</span>
)}
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">
#{n.id}
</span>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
</div>
</div>
);
}
const nodeTypes = { explore: ExploreNode };
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5 text-sm">
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
// 点击节点后弹出的抽屉:上半展示可读摘要,下半展示节点原始 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 (
<Sheet open={node !== null} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md"
>
{node && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<div className="flex items-center gap-2.5 pr-8">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm",
meta.iconBg,
)}
>
<Icon className="size-4 text-white" />
</span>
<div className="min-w-0">
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
<section>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
<DetailRow label="类型">{meta.label}</DetailRow>
<DetailRow label="状态">
{node.type === "goal" || node.type === "intent" ? (
<StatusBadge
domain={node.type === "goal" ? "goal" : "intent"}
value={node.state}
dot
className="px-1.5 py-0 text-[10px]"
/>
) : (
<span className="font-mono text-xs">{node.state}</span>
)}
</DetailRow>
<DetailRow label="优先级">
<span className="tabular-nums">{node.priority}</span>
</DetailRow>
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
<section className="border-t pt-3">
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
<button
type="button"
onClick={copy}
className="text-muted-foreground hover:text-foreground text-xs transition-colors"
>
{copied ? "已复制" : "复制"}
</button>
</div>
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{raw}
</pre>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
);
}
function GraphInner({ taskId }: { taskId: string }) {
const [rfNodes, setRfNodes, onNodesChange] = useNodesState<ExploreRFNode>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
const [isEmpty, setIsEmpty] = React.useState(true);
const [selected, setSelected] = React.useState<TaskNode | null>(null);
export function GraphTab({ taskId }: { taskId: string }) {
const [nodes, setNodes] = React.useState<TaskNode[]>([]);
const [edges, setEdges] = React.useState<Edge[]>([]);
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 (
<>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => setSelected(node.data.node)}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
minZoom={0.1}
className="!bg-[#f0f2f7] dark:!bg-neutral-950"
>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
className="text-neutral-400/50 dark:text-neutral-700/60"
/>
<Controls
showInteractive={false}
className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground"
/>
<MiniMap
pannable
zoomable
className="!bg-card !rounded-lg !border !shadow-sm"
maskColor="rgb(148 163 184 / 0.18)"
nodeColor={(node) => {
const data = node.data as ExploreNodeData | undefined;
const k = data?.node ? viewKind(data.node) : "intent";
return typeMeta[k]?.hex ?? "#94a3b8";
}}
nodeStrokeWidth={0}
nodeBorderRadius={4}
/>
<Panel position="top-left">
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
{isEmpty && <span className="text-muted-foreground">暂无探索数据</span>}
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
const m = typeMeta[k];
const Icon = m.icon;
return (
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
<Icon className="size-2.5 text-white" />
</span>
{m.label}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{Object.entries(relLabel).map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
{v}
</span>
))}
</div>
</div>
</Panel>
</ReactFlow>
<NodeDetailSheet node={selected} onOpenChange={(o) => !o && setSelected(null)} />
</>
);
}
export function GraphTab({ taskId }: { taskId: string }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<ReactFlowProvider>
<GraphInner taskId={taskId} />
</ReactFlowProvider>
</div>
<ExplorationGraph nodes={nodes} edges={edges} className="h-[72vh]" />
</CardContent>
</Card>
);
+518
View File
@@ -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<string, string> = {
spawns: "派生",
derived_from: "意图链",
yields: "产出",
proves: "证明",
};
export const relColor: Record<string, string> = {
spawns: "#10b981",
derived_from: "#6366f1",
yields: "#f59e0b",
proves: "#ef4444",
};
type TypeMeta = {
label: string;
icon: LucideIcon;
iconBg: string;
chip: string;
hex: string;
};
const typeMeta: Record<ExploreKind, TypeMeta> = {
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<ExploreKind, string[]> = {
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<string, unknown>)[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<ExploreNodeData, "explore">;
function pushMap(m: Map<string, string[]>, 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<string, { x: number; y: number }> {
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<string, string[]>();
for (const e of valid) pushMap(out, e.src, e.dst);
const seen = new Map<string, 0 | 1 | 2>();
const back = new Set<string>();
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<string, string[]>();
const indeg = new Map<string, number>(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<string, number>(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<string, number>();
columns.forEach((ids, c) => ids.forEach((id) => colIndexOf.set(id, c)));
const neighbors = new Map<string, string[]>();
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<string, { x: number; y: number }>();
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<ExploreRFNode>) {
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 (
<div
className={cn(
"w-[240px] cursor-pointer rounded-2xl border-2 transition-colors",
selected ? "border-blue-500" : isLive ? "border-blue-400/70" : "border-transparent",
)}
>
<div className="group relative rounded-[14px] border border-black/[0.06] bg-white shadow-sm transition-shadow hover:shadow-lg dark:border-white/10 dark:bg-neutral-900">
<Handle
type="target"
position={Position.Left}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5">
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-lg shadow-sm", meta.iconBg)}>
<Icon className="size-3.5 text-white" />
</span>
<span className="grow truncate text-[13px] font-semibold text-neutral-700 dark:text-neutral-100">
{meta.label}
</span>
{isLive && (
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex size-2 rounded-full bg-blue-500" />
</span>
)}
{showPriority && (
<span className={cn("shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold tabular-nums", toneClasses[prioTone(n.priority)])}>
P{n.priority}
</span>
)}
</div>
<div className="line-clamp-3 px-3 pb-2 text-[13px] leading-snug text-neutral-500 dark:text-neutral-400">
{nodeSummary(n) || meta.label}
</div>
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
)}
<span className="shrink-0 font-mono text-[10px] text-neutral-400 dark:text-neutral-500">#{n.id}</span>
</div>
<Handle
type="source"
position={Position.Right}
className="!size-2 !border-2 !border-neutral-300 !bg-white opacity-0 transition-opacity group-hover:opacity-100 dark:!border-neutral-600 dark:!bg-neutral-800"
/>
</div>
</div>
);
}
const nodeTypes = { explore: ExploreNode };
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start gap-3 py-1.5 text-sm">
<span className="text-muted-foreground w-14 shrink-0">{label}</span>
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
</div>
);
}
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 (
<Sheet open={node !== null} onOpenChange={onOpenChange}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
{node && meta && Icon && (
<>
<SheetHeader className="border-b p-4">
<div className="flex items-center gap-2.5 pr-8">
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm", meta.iconBg)}>
<Icon className="size-4 text-white" />
</span>
<div className="min-w-0">
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
</div>
</SheetHeader>
<ScrollArea className="min-h-0 flex-1">
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
<section>
<h4 className="text-muted-foreground mb-1 text-xs font-medium">属性</h4>
<DetailRow label="类型">{meta.label}</DetailRow>
<DetailRow label="状态">
{node.type === "goal" || node.type === "intent" ? (
<StatusBadge domain={node.type === "goal" ? "goal" : "intent"} value={node.state} dot className="px-1.5 py-0 text-[10px]" />
) : (
<span className="font-mono text-xs">{node.state}</span>
)}
</DetailRow>
<DetailRow label="优先级">
<span className="tabular-nums">{node.priority}</span>
</DetailRow>
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
<section className="border-t pt-3">
<div className="mb-1.5 flex items-center justify-between">
<h4 className="text-muted-foreground text-xs font-medium">原始数据</h4>
<button type="button" onClick={copy} className="text-muted-foreground hover:text-foreground text-xs transition-colors">
{copied ? "已复制" : "复制"}
</button>
</div>
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
{raw}
</pre>
</section>
</div>
</ScrollArea>
</>
)}
</SheetContent>
</Sheet>
);
}
// 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<ExploreRFNode>([]);
const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState<RFEdge>([]);
const [selected, setSelected] = React.useState<TaskNode | null>(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 (
<>
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={(_, node) => setSelected(node.data.node)}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
proOptions={{ hideAttribution: true }}
minZoom={0.1}
className="!bg-[#f0f2f7] dark:!bg-neutral-950"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} className="text-neutral-400/50 dark:text-neutral-700/60" />
<Controls showInteractive={false} className="!rounded-lg !border !shadow-sm [&>button]:!border-border [&>button]:!bg-card [&>button:hover]:!bg-accent [&_svg]:!fill-foreground" />
<MiniMap
pannable
zoomable
className="!bg-card !rounded-lg !border !shadow-sm"
maskColor="rgb(148 163 184 / 0.18)"
nodeColor={(node) => {
const data = node.data as ExploreNodeData | undefined;
const k = data?.node ? viewKind(data.node) : "intent";
return typeMeta[k]?.hex ?? "#94a3b8";
}}
nodeStrokeWidth={0}
nodeBorderRadius={4}
/>
<Panel position="top-left">
<div className="bg-card/95 flex flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
{isEmpty && <span className="text-muted-foreground">{emptyHint}</span>}
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
{(["begin", "goal", "intent", "fact", "finding", "hint"] as ExploreKind[]).map((k) => {
const m = typeMeta[k];
const Icon = m.icon;
return (
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
<Icon className="size-2.5 text-white" />
</span>
{m.label}
</span>
);
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
{Object.entries(relLabel).map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: relColor[k] }} />
{v}
</span>
))}
</div>
</div>
</Panel>
</ReactFlow>
<NodeDetailSheet node={selected} onOpenChange={(o) => !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 (
<div className={cn("w-full overflow-hidden rounded-xl", className)}>
<ReactFlowProvider>
<ExplorationGraphInner nodes={nodes} edges={edges} emptyHint={emptyHint} />
</ReactFlowProvider>
</div>
);
}