feat(admin): Agent监控/后台思考接WS实时 + 真流程图
- 新增 FlowGraph.tsx: SVG 自绘 DAG(分层布局/bezier边/箭头/按status着色,零新增依赖) - Agents.tsx: 上下文查询接真后端(task_output_query→task_output);终止/重派诚实标需后端M10+,绝不伪造 - Backthink.tsx: 链路导图接 FlowGraphView;干预诚实标需后端M10+ - 控制项一律 useAuditStore.log 记 ok/error,未接入不伪造成功
This commit is contained in:
1 parent
4ad1e465da
commit
ba8ff835cd
3 files changed
+290
-21
No files matched your search
@@ -0,0 +1,241 @@
|
||||
import { useId } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
AgentStatus,
|
||||
FlowEdge,
|
||||
FlowGraph,
|
||||
FlowNode,
|
||||
} from "../../lib/types";
|
||||
|
||||
/** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps).
|
||||
* Left-to-right layered layout: a node's layer is its longest-path depth from
|
||||
* a root; within a layer nodes are stacked top-down. Edges are cubic beziers
|
||||
* with an arrowhead; node stroke/fill is coded by status. */
|
||||
|
||||
const NODE_W = 148;
|
||||
const NODE_H = 52;
|
||||
const GAP_X = 64;
|
||||
const GAP_Y = 30;
|
||||
const PAD = 20;
|
||||
|
||||
interface FlowTone {
|
||||
stroke: string;
|
||||
fill: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const flowTone: Record<AgentStatus, FlowTone> = {
|
||||
idle: {
|
||||
stroke: "var(--mu-surface-5)",
|
||||
fill: "color-mix(in srgb, var(--mu-surface-5) 18%, transparent)",
|
||||
label: "空闲",
|
||||
},
|
||||
running: {
|
||||
stroke: "var(--mu-accent)",
|
||||
fill: "color-mix(in srgb, var(--mu-accent) 18%, transparent)",
|
||||
label: "运行中",
|
||||
},
|
||||
blocked: {
|
||||
stroke: "var(--mu-warn)",
|
||||
fill: "color-mix(in srgb, var(--mu-warn) 18%, transparent)",
|
||||
label: "阻塞",
|
||||
},
|
||||
done: {
|
||||
stroke: "var(--mu-ok)",
|
||||
fill: "color-mix(in srgb, var(--mu-ok) 18%, transparent)",
|
||||
label: "完成",
|
||||
},
|
||||
error: {
|
||||
stroke: "var(--mu-err)",
|
||||
fill: "color-mix(in srgb, var(--mu-err) 18%, transparent)",
|
||||
label: "错误",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_ORDER: ReadonlyArray<AgentStatus> = [
|
||||
"idle",
|
||||
"running",
|
||||
"blocked",
|
||||
"done",
|
||||
"error",
|
||||
];
|
||||
|
||||
interface PlacedNode {
|
||||
node: FlowNode;
|
||||
x: number;
|
||||
y: number;
|
||||
tone: FlowTone;
|
||||
}
|
||||
|
||||
function layout(graph: FlowGraph): {
|
||||
placed: PlacedNode[];
|
||||
edges: FlowEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
if (graph.nodes.length === 0) {
|
||||
return { placed: [], edges: [], width: PAD * 2, height: PAD * 2 };
|
||||
}
|
||||
|
||||
const byId = new Map<string, FlowNode>();
|
||||
graph.nodes.forEach((n) => byId.set(n.id, n));
|
||||
|
||||
const preds = new Map<string, string[]>();
|
||||
graph.nodes.forEach((n) => preds.set(n.id, []));
|
||||
|
||||
const validEdges: FlowEdge[] = [];
|
||||
graph.edges.forEach((e) => {
|
||||
if (e.from === e.to) return;
|
||||
if (!byId.has(e.from) || !byId.has(e.to)) return;
|
||||
preds.get(e.to)!.push(e.from);
|
||||
validEdges.push(e);
|
||||
});
|
||||
|
||||
const memo = new Map<string, number>();
|
||||
const visiting = new Set<string>();
|
||||
const depth = (id: string): number => {
|
||||
const cached = memo.get(id);
|
||||
if (cached !== undefined) return cached;
|
||||
if (visiting.has(id)) return 0;
|
||||
visiting.add(id);
|
||||
let d = 0;
|
||||
for (const p of preds.get(id)!) d = Math.max(d, depth(p) + 1);
|
||||
visiting.delete(id);
|
||||
memo.set(id, d);
|
||||
return d;
|
||||
};
|
||||
|
||||
const byLayer = new Map<number, string[]>();
|
||||
let maxRows = 0;
|
||||
graph.nodes.forEach((n) => {
|
||||
const l = depth(n.id);
|
||||
const arr = byLayer.get(l) ?? [];
|
||||
arr.push(n.id);
|
||||
byLayer.set(l, arr);
|
||||
maxRows = Math.max(maxRows, arr.length);
|
||||
});
|
||||
|
||||
const layers = Array.from(byLayer.keys()).sort((a, b) => a - b);
|
||||
const pos = new Map<string, { x: number; y: number }>();
|
||||
layers.forEach((l, li) => {
|
||||
const ids = byLayer.get(l)!;
|
||||
ids.forEach((id, ri) =>
|
||||
pos.set(id, { x: PAD + li * (NODE_W + GAP_X), y: PAD + ri * (NODE_H + GAP_Y) })
|
||||
);
|
||||
});
|
||||
|
||||
const width = PAD * 2 + layers.length * NODE_W + (layers.length - 1) * GAP_X;
|
||||
const height = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * GAP_Y;
|
||||
|
||||
const placed: PlacedNode[] = graph.nodes.map((n) => {
|
||||
const p = pos.get(n.id)!;
|
||||
return {
|
||||
node: n,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
tone: flowTone[n.status ?? "idle"] ?? flowTone.idle,
|
||||
};
|
||||
});
|
||||
|
||||
return { placed, edges: validEdges, width, height };
|
||||
}
|
||||
|
||||
function clampLabel(label: string): string {
|
||||
return label.length > 20 ? `${label.slice(0, 19)}…` : label;
|
||||
}
|
||||
|
||||
export function FlowGraphView({ graph }: { graph: FlowGraph }): ReactNode {
|
||||
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||
const { placed, edges, width, height } = layout(graph);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="overflow-x-auto rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)] p-3">
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block">
|
||||
<defs>
|
||||
<marker
|
||||
id={markerId}
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="7"
|
||||
markerHeight="7"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--mu-text-lo)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((e, i) => {
|
||||
const s = placed.find((p) => p.node.id === e.from);
|
||||
const t = placed.find((p) => p.node.id === e.to);
|
||||
if (!s || !t) return null;
|
||||
const sx = s.x + NODE_W;
|
||||
const sy = s.y + NODE_H / 2;
|
||||
const tx = t.x;
|
||||
const ty = t.y + NODE_H / 2;
|
||||
const dx = Math.max(24, Math.abs(tx - sx) / 2);
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
|
||||
fill="none"
|
||||
stroke="var(--mu-text-lo)"
|
||||
strokeWidth="1.5"
|
||||
markerEnd={`url(#${markerId})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{placed.map((p) => (
|
||||
<g key={p.node.id}>
|
||||
<rect
|
||||
x={p.x}
|
||||
y={p.y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx="12"
|
||||
fill={p.tone.fill}
|
||||
stroke={p.tone.stroke}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{p.node.kind ? (
|
||||
<text
|
||||
x={p.x + NODE_W / 2}
|
||||
y={p.y + 20}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill="var(--mu-text-lo)"
|
||||
>
|
||||
{p.node.kind}
|
||||
</text>
|
||||
) : null}
|
||||
<text
|
||||
x={p.x + NODE_W / 2}
|
||||
y={p.y + (p.node.kind ? 41 : NODE_H / 2 + 5)}
|
||||
textAnchor="middle"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
fill="var(--mu-text-hi)"
|
||||
>
|
||||
{clampLabel(p.node.label)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||
{STATUS_ORDER.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]"
|
||||
>
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: flowTone[s].stroke }}
|
||||
/>
|
||||
{flowTone[s].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user