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:
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user