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
@@ -6,6 +6,7 @@ import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components
|
|||||||
import { sendUp } from "../../lib/ws";
|
import { sendUp } from "../../lib/ws";
|
||||||
import { useAuditStore } from "../audit";
|
import { useAuditStore } from "../audit";
|
||||||
import { Icon } from "../../components/Icon";
|
import { Icon } from "../../components/Icon";
|
||||||
|
import { FlowGraphView } from "./FlowGraph";
|
||||||
|
|
||||||
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
|
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
|
||||||
idle: { label: "空闲", tone: "neutral" },
|
idle: { label: "空闲", tone: "neutral" },
|
||||||
@@ -15,8 +16,21 @@ const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
|
|||||||
error: { label: "错误", tone: "err" },
|
error: { label: "错误", tone: "err" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) => void }): React.ReactNode {
|
function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
|
||||||
const meta = statusTone[agent.status] ?? statusTone.idle;
|
const meta = statusTone[agent.status] ?? statusTone.idle;
|
||||||
|
const log = useAuditStore((s) => s.log);
|
||||||
|
const [hint, setHint] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const query = () => {
|
||||||
|
const ok = sendUp({ type: "task_output_query", data: { taskId: agent.id } });
|
||||||
|
log(`agent.query:${agent.id}`, ok ? "ok" : "error");
|
||||||
|
setHint(ok ? "已请求上下文,输出见下方「任务日志」。" : "WS 未连接,无法查询。");
|
||||||
|
};
|
||||||
|
const kill = () => {
|
||||||
|
log(`agent.kill:${agent.id}`, "error");
|
||||||
|
setHint("终止子代理需后端 M10+ 能力接入,当前为只读展示,未执行任何操作。");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface className="space-y-2">
|
<Surface className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -40,13 +54,14 @@ function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) =
|
|||||||
))}
|
))}
|
||||||
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
|
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="ghost" onClick={() => sendUp({ type: "task_output_query", data: { taskId: agent.id } })}>
|
<Button size="sm" variant="ghost" onClick={query}>
|
||||||
上下文查询
|
上下文查询
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => onKill(agent.id)}>
|
<Button size="sm" variant="outline" onClick={kill}>
|
||||||
终止
|
终止
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{hint ? <div className="text-xs text-[var(--mu-text-lo)]">{hint}</div> : null}
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,6 +73,7 @@ export function AgentsModule(): React.ReactNode {
|
|||||||
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
|
const clearTaskLog = useAppStore((s) => s.clearTaskLog);
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
const [tab, setTab] = useState<"main" | "sub">("main");
|
const [tab, setTab] = useState<"main" | "sub">("main");
|
||||||
|
const [respawnHint, setRespawnHint] = useState(false);
|
||||||
|
|
||||||
const subAgents = agents.filter((a) => a.id !== "main");
|
const subAgents = agents.filter((a) => a.id !== "main");
|
||||||
const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents;
|
const list = tab === "main" ? agents.filter((a) => a.id === "main") : subAgents;
|
||||||
@@ -71,8 +87,8 @@ export function AgentsModule(): React.ReactNode {
|
|||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
log("agent.respawn", "ok");
|
log("agent.respawn", "error");
|
||||||
alert("重派(占位):后端 M10+ 提供子代理重派。");
|
setRespawnHint(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重派
|
重派
|
||||||
@@ -80,22 +96,21 @@ export function AgentsModule(): React.ReactNode {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{respawnHint ? (
|
||||||
|
<Notice tone="warn">子代理重派需后端 M10+ 能力接入,当前未启用;可先通过「上下文查询」与任务日志查看已运行子代理。</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<TabPill active={tab === "main"} onClick={() => setTab("main")}>主会话</TabPill>
|
<TabPill active={tab === "main"} onClick={() => setTab("main")}>主会话</TabPill>
|
||||||
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
|
<TabPill active={tab === "sub"} onClick={() => setTab("sub")}>子代理 ({subAgents.length})</TabPill>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{flowGraph ? (
|
{flowGraph ? (
|
||||||
<Surface className="flex flex-wrap items-center gap-3">
|
<Surface gap="sm">
|
||||||
{flowGraph.nodes.map((n, i) => (
|
<FlowGraphView graph={flowGraph} />
|
||||||
<div key={n.id} className="flex items-center gap-3">
|
|
||||||
<Tag tone={n.status === "running" ? "accent" : "neutral"}>{n.label}</Tag>
|
|
||||||
{i < flowGraph.nodes.length - 1 ? <span className="text-[var(--mu-text-lo)]">→</span> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Surface>
|
</Surface>
|
||||||
) : (
|
) : (
|
||||||
<Notice>流程图占位 —— 连接 /ws/chat 后按 graph_update 事件渲染。</Notice>
|
<Notice>暂无流程图数据 —— 连接 /ws/chat 后按 graph_update 事件实时渲染。</Notice>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{list.length === 0 ? (
|
{list.length === 0 ? (
|
||||||
@@ -103,7 +118,7 @@ export function AgentsModule(): React.ReactNode {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{list.map((a) => (
|
{list.map((a) => (
|
||||||
<AgentCard key={a.id} agent={a} onKill={(id) => { log(`agent.kill:${id}`, "ok"); alert(`终止 ${id}(占位)`); }} />
|
<AgentCard key={a.id} agent={a} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +138,9 @@ export function AgentsModule(): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<Notice>子代理链上下文查询、终止/重派、流程图导出需后端 M10+ 提供。</Notice>
|
<Notice>
|
||||||
|
任务日志实时来自 task_output 事件;「上下文查询」通过 task_output_query 上行帧查询真实输出(已接后端)。终止 / 重派需后端 M10+ 提供,当前为只读展示。
|
||||||
|
</Notice>
|
||||||
</Surface>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { Notice, PageHeader } from "../components";
|
|||||||
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
|
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
|
||||||
import type { BackthinkItem } from "../../lib/types";
|
import type { BackthinkItem } from "../../lib/types";
|
||||||
import { Icon } from "../../components/Icon";
|
import { Icon } from "../../components/Icon";
|
||||||
|
import { FlowGraphView } from "./FlowGraph";
|
||||||
|
|
||||||
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||||
const markPushed = useAppStore((s) => s.markBackthinkPushed);
|
const markPushed = useAppStore((s) => s.markBackthinkPushed);
|
||||||
|
const flowGraph = useAppStore((s) => s.flowGraph);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,9 +37,13 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
|||||||
{open ? "收起链路" : "查看完整链路导图"}
|
{open ? "收起链路" : "查看完整链路导图"}
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
|
flowGraph ? (
|
||||||
链路导图占位 —— 后端 M10+ 提供思维链 / 流程图(graph_update 事件已接入实时通道)。
|
<FlowGraphView graph={flowGraph} />
|
||||||
</div>
|
) : (
|
||||||
|
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-8 text-center text-xs text-[var(--mu-text-lo)]">
|
||||||
|
暂无链路导图数据 —— 等待 graph_update 事件。
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
@@ -47,6 +53,7 @@ export function BackthinkModule(): React.ReactNode {
|
|||||||
const backthink = useAppStore((s) => s.backthink);
|
const backthink = useAppStore((s) => s.backthink);
|
||||||
const log = useAuditStore((s) => s.log);
|
const log = useAuditStore((s) => s.log);
|
||||||
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
|
const [filter, setFilter] = useState<"all" | "notify" | "silent">("all");
|
||||||
|
const [interveneHint, setInterveneHint] = useState(false);
|
||||||
|
|
||||||
const items = backthink.filter((b) => {
|
const items = backthink.filter((b) => {
|
||||||
if (filter === "notify") return b.shouldNotify === true;
|
if (filter === "notify") return b.shouldNotify === true;
|
||||||
@@ -63,8 +70,8 @@ export function BackthinkModule(): React.ReactNode {
|
|||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
log("backthink.intervene", "ok");
|
log("backthink.intervene", "error");
|
||||||
alert("干预(占位):后端 M10+ 提供打断/重派。");
|
setInterveneHint(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
干预
|
干预
|
||||||
@@ -72,6 +79,10 @@ export function BackthinkModule(): React.ReactNode {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{interveneHint ? (
|
||||||
|
<Notice tone="warn">对后台思考的打断 / 重派需后端 M10+ 能力接入,当前未启用;此处仅展示已产生的思考记录。</Notice>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
|
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>全部</FilterPill>
|
||||||
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
|
<FilterPill active={filter === "notify"} onClick={() => setFilter("notify")}>需打扰</FilterPill>
|
||||||
@@ -88,7 +99,7 @@ export function BackthinkModule(): React.ReactNode {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时);导图与干预为后端 M10+ 提供。</Notice>
|
<Notice>数据源:/ws/chat 下行 backthink / thinking 与 graph_update 事件(实时)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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