Files
Cyrene_v2/frontend/web/src/admin/modules/FlowGraph.tsx
T
AskaEth 14e76b6480
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 流程图方向——Flow 页自下而上(最新在上),Backthink/Agents 图块最新在左
主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。

- 共享布局唯一入口 flow/layout.ts:新增枚举 FlowOrientation(newest-top|newest-left) + layoutFlow(graph, orientation);排布拆成「抽象网格→像素」两步,方向开关只在 toPixelNode/toPixelBand;泳道成道逻辑零改动
- 边几何抽到新文件 flow/edges.ts:只按方向选锚点,带回 fromId/toId(不重排图数据),悬空边如实丢弃
- FlowGraphView 的 orientation 是**必填** prop(漏传=编译错误);画布不限高、无 overflow-y(不内部裁切,纵向随图生长);图例加方向小字
- 三处调用同一套布局:Flow=newest-top、Backthink=newest-left、Agents=newest-left(后者是我按同一档裁定,一行可改)
- 顺带修复:画布最大列现在把「刚开出、还没跑出步骤的泳道」最小宽度算进去(否则泳道框被裁到画布外)
- 测试 593 checks(orientation 逻辑 131 + 渲染 338 + lanes 40 + 既有回归);变异 7 条全红,含「边端点连到错节点」「容器限高裁切」「空泳道被裁」
- 多路:lanes.test 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
2026-10-05 19:51:45 +08:00

302 lines
9.2 KiB
TypeScript

import { useId } from "react";
import type { ReactNode } from "react";
import type { FlowGraph, FlowNode } from "../../lib/types";
import {
FLOW_TONE,
STATUS_ORDER,
statusOf,
} from "./flow/tone";
import { layoutFlow, NODE_W, NODE_H, type FlowOrientation, type PlacedBand } from "./flow/layout";
import { edgeShapes, type EdgeShape } from "./flow/edges";
/** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps).
* A node's step is its longest-path depth from a root; the steps advance along the
* **time axis** and the newest one always sits at the canvas origin (top or left, see
* `FlowOrientation` in flow/layout.ts). Sub-agent branches are drawn in their own framed
* lanes beside the trunk (design/19 §2), so parallel branches read as separate swimlanes
* instead of interleaving. Edges are cubic beziers with an arrowhead anchored on the
* border the direction dictates (flow/edges.ts); node stroke/fill is coded by status and
* a running node pulses with the panel's existing `.mu-blink` (no new animation dep).
*
* 容器**不限高**:图多高就多高(主人要求「容器高度自动拓高」,不在内部裁切);横向仅在
* 泳道把画布撑宽时滚动(`newest-top` 下就是这种情形),纵向永远随图生长。 */
/** 方向提示(画在图例里):让「哪端是最新」写在脸上,不靠用户自己猜。 */
const DIRECTION_HINT: Record<FlowOrientation, string> = {
"newest-top": "时序自下而上 ↑ · 最新在最上",
"newest-left": "时序自右向左 ← · 最新在最左",
};
export function FlowGraphView({
graph,
orientation,
onNodeClick,
selectedId,
}: {
graph: FlowGraph;
/** 渲染方向(flow/layout.ts):`newest-top` = 时序自下而上(整页 Flow);
* `newest-left` = 时序自右向左(嵌在页面里的图块,容器默认露出最左边)。 */
orientation: FlowOrientation;
onNodeClick?: (node: FlowNode) => void;
selectedId?: string | null;
}): ReactNode {
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
const laneMarkerId = `${markerId}L`;
const layout = layoutFlow(graph, orientation);
const { nodes, bands, lanes, width, height } = layout;
const shapes = edgeShapes(layout);
const laneRoots = new Set(lanes.map((l) => l.id));
return (
<div className="space-y-2">
<div
data-flow-canvas="1"
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>
<marker
id={laneMarkerId}
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-accent)" />
</marker>
</defs>
{bands.filter((b) => b.framed).map((band) => (
<LaneFrame key={band.id} band={band} />
))}
{shapes.map((shape, i) => (
<EdgePath
key={`${shape.fromId}-${shape.toId}-${i}`}
shape={shape}
markerId={markerId}
laneMarkerId={laneMarkerId}
/>
))}
{nodes.map((p) => (
<NodeGroup
key={p.node.id}
node={p.node}
x={p.x}
y={p.y}
selected={selectedId === p.node.id}
lane={laneRoots.has(p.node.id)}
onNodeClick={onNodeClick}
/>
))}
</svg>
</div>
<Legend hasLanes={lanes.length > 0} orientation={orientation} />
</div>
);
}
/** EdgePath draws one cubic bezier between the two placed nodes; edges that run into
* a lane are dashed and accent-colored so the trunk stays readable. The endpoints come
* from `edgeShapes` — the component never recomputes (nor guesses) them. */
function EdgePath({
shape,
markerId,
laneMarkerId,
}: {
shape: EdgeShape;
markerId: string;
laneMarkerId: string;
}): ReactNode {
return (
<path
d={shape.d}
fill="none"
stroke={shape.inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
strokeOpacity={shape.inLane ? 0.7 : 1}
strokeWidth="1.5"
strokeDasharray={shape.inLane ? "5 3" : undefined}
markerEnd={`url(#${shape.inLane ? laneMarkerId : markerId})`}
/>
);
}
/** LaneFrame draws one sub-agent swimlane container: a dashed Miuix surface with
* a header carrying the sub-agent label and its live status. */
function LaneFrame({ band }: { band: PlacedBand }): ReactNode {
const tone = FLOW_TONE[band.status];
const running = band.status === "running";
return (
<g>
<rect
x={band.x}
y={band.y}
width={band.width}
height={band.height}
rx="16"
fill="color-mix(in srgb, var(--mu-accent) 5%, transparent)"
stroke="var(--mu-glass-border)"
strokeDasharray="6 4"
strokeWidth="1"
/>
<text
x={band.x + 12}
y={band.y + 15}
fontSize="10"
fill="var(--mu-text-mid)"
>
泳道 · {clampLabel(band.label, 24)}
</text>
<circle
cx={band.x + band.width - 14}
cy={band.y + 11}
r="4"
fill={tone.stroke}
className={running ? "mu-blink" : undefined}
/>
</g>
);
}
/** NodeGroup draws one node card: a status-colored rounded rect plus kind/label
* text and a status dot. A running node carries a blinking halo so 运行中 is
* visible at a glance without animating the text. */
function NodeGroup({
node,
x,
y,
selected,
lane,
onNodeClick,
}: {
node: FlowNode;
x: number;
y: number;
selected: boolean;
lane: boolean;
onNodeClick?: (node: FlowNode) => void;
}): ReactNode {
const status = statusOf(node);
const tone = FLOW_TONE[status];
return (
<g
onClick={onNodeClick ? () => onNodeClick(node) : undefined}
className={onNodeClick ? "cursor-pointer" : undefined}
>
<title>{`${node.label} · ${tone.label}`}</title>
{status === "running" ? (
<rect
x={x - 4}
y={y - 4}
width={NODE_W + 8}
height={NODE_H + 8}
rx="16"
fill="none"
stroke={tone.stroke}
strokeWidth="2"
className="mu-blink"
/>
) : null}
<rect
x={x}
y={y}
width={NODE_W}
height={NODE_H}
rx="12"
fill={tone.fill}
stroke={tone.stroke}
strokeWidth={selected ? 3 : 1.5}
/>
<circle cx={x + NODE_W - 12} cy={y + 11} r="3.5" fill={tone.stroke} />
{lane ? (
<text x={x + 10} y={y + 15} fontSize="9" fill="var(--mu-accent)">
泳道根
</text>
) : null}
{node.kind ? (
<text
x={x + NODE_W / 2}
y={y + 20}
textAnchor="middle"
fontSize="9"
fill="var(--mu-text-lo)"
>
{node.kind}
</text>
) : null}
<text
x={x + NODE_W / 2}
y={y + (node.kind ? 41 : NODE_H / 2 + 5)}
textAnchor="middle"
fontSize="12"
fontWeight="500"
fill="var(--mu-text-hi)"
>
{clampLabel(node.label, 20)}
</text>
</g>
);
}
/** Legend explains the status colors, the lane edge style, and which end of the time
* axis is the newest one (the direction is reversed for the whole-page view). */
function Legend({
hasLanes,
orientation,
}: {
hasLanes: boolean;
orientation: FlowOrientation;
}): ReactNode {
return (
<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: FLOW_TONE[s].stroke }} />
{FLOW_TONE[s].label}
</span>
))}
{hasLanes ? (
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
<svg width="22" height="8" aria-hidden="true">
<line
x1="0"
y1="4"
x2="22"
y2="4"
stroke="var(--mu-accent)"
strokeWidth="1.5"
strokeDasharray="5 3"
/>
</svg>
泳道内步骤
</span>
) : null}
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
{DIRECTION_HINT[orientation]}
</span>
</div>
);
}
/** clampLabel shortens a node label to fit the fixed-width node card. */
function clampLabel(label: string, max: number): string {
return label.length > max ? `${label.slice(0, max - 1)}…` : label;
}