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 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
This commit is contained in:
1 parent
f4adf0b526
commit
14e76b6480
9 files changed
+964
-108
No files matched your search
@@ -172,7 +172,9 @@ export function AgentsFlowBlock({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Surface gap="sm">
|
<Surface gap="sm">
|
||||||
<FlowGraphView graph={graph} />
|
{/* 与后台思考的图块同一档:横向渲染 + 最新在最左(FV0),
|
||||||
|
容器默认露出最左边 ⇒ 打开就先看到最新那拍。 */}
|
||||||
|
<FlowGraphView graph={graph} orientation="newest-left" />
|
||||||
<FlowSnapshotHint sessionId={graph.sessionId ?? sessionId} source={source ?? "live"} />
|
<FlowSnapshotHint sessionId={graph.sessionId ?? sessionId} source={source ?? "live"} />
|
||||||
</Surface>
|
</Surface>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export function BackthinkGraphBlock({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FlowGraphView graph={graph} />
|
{/* 页面区域有限、容器默认只露出最左边 ⇒ 保持横向渲染,但把最新放在最左
|
||||||
|
(FV0;时序自右向左,箭头随之指向左边)。 */}
|
||||||
|
<FlowGraphView graph={graph} orientation="newest-left" />
|
||||||
<FlowSnapshotHint sessionId={sessionId} source={source ?? "live"} />
|
<FlowSnapshotHint sessionId={sessionId} source={source ?? "live"} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export function FlowBody({
|
|||||||
<StatusStrip graph={shown} />
|
<StatusStrip graph={shown} />
|
||||||
<FlowGraphView
|
<FlowGraphView
|
||||||
graph={shown}
|
graph={shown}
|
||||||
|
orientation="newest-top"
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onNodeClick={(n) => setSelectedId(n.id)}
|
onNodeClick={(n) => setSelectedId(n.id)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,34 +6,53 @@ import {
|
|||||||
STATUS_ORDER,
|
STATUS_ORDER,
|
||||||
statusOf,
|
statusOf,
|
||||||
} from "./flow/tone";
|
} from "./flow/tone";
|
||||||
import { layoutFlow, NODE_W, NODE_H, type PlacedBand, type PlacedEdge, type PlacedNode } from "./flow/layout";
|
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).
|
/** Lightweight SVG directed-graph renderer for a FlowGraph (no graph deps).
|
||||||
* Left-to-right layered layout: a node's column is its longest-path depth from
|
* A node's step is its longest-path depth from a root; the steps advance along the
|
||||||
* a root, and the rows of one column stack top-down. Sub-agent branches are
|
* **time axis** and the newest one always sits at the canvas origin (top or left, see
|
||||||
* drawn in their own framed lanes below the trunk (design/19 §2), so parallel
|
* `FlowOrientation` in flow/layout.ts). Sub-agent branches are drawn in their own framed
|
||||||
* branches read as separate swimlanes instead of interleaving. Edges are cubic
|
* lanes beside the trunk (design/19 §2), so parallel branches read as separate swimlanes
|
||||||
* beziers with an arrowhead; node stroke/fill is coded by status and a running
|
* instead of interleaving. Edges are cubic beziers with an arrowhead anchored on the
|
||||||
* node pulses with the panel's existing `.mu-blink` (no new animation dep). */
|
* 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({
|
export function FlowGraphView({
|
||||||
graph,
|
graph,
|
||||||
|
orientation,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
selectedId,
|
selectedId,
|
||||||
}: {
|
}: {
|
||||||
graph: FlowGraph;
|
graph: FlowGraph;
|
||||||
|
/** 渲染方向(flow/layout.ts):`newest-top` = 时序自下而上(整页 Flow);
|
||||||
|
* `newest-left` = 时序自右向左(嵌在页面里的图块,容器默认露出最左边)。 */
|
||||||
|
orientation: FlowOrientation;
|
||||||
onNodeClick?: (node: FlowNode) => void;
|
onNodeClick?: (node: FlowNode) => void;
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
const markerId = useId().replace(/[^a-zA-Z0-9]/g, "");
|
||||||
const laneMarkerId = `${markerId}L`;
|
const laneMarkerId = `${markerId}L`;
|
||||||
const { nodes, edges, bands, lanes, width, height } = layoutFlow(graph);
|
const layout = layoutFlow(graph, orientation);
|
||||||
const placedOf = new Map(nodes.map((p) => [p.node.id, p]));
|
const { nodes, bands, lanes, width, height } = layout;
|
||||||
|
const shapes = edgeShapes(layout);
|
||||||
const laneRoots = new Set(lanes.map((l) => l.id));
|
const laneRoots = new Set(lanes.map((l) => l.id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<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">
|
<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">
|
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="block">
|
||||||
<defs>
|
<defs>
|
||||||
<marker
|
<marker
|
||||||
@@ -64,12 +83,10 @@ export function FlowGraphView({
|
|||||||
<LaneFrame key={band.id} band={band} />
|
<LaneFrame key={band.id} band={band} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{edges.map((e, i) => (
|
{shapes.map((shape, i) => (
|
||||||
<EdgePath
|
<EdgePath
|
||||||
key={`${e.edge.from}-${e.edge.to}-${i}`}
|
key={`${shape.fromId}-${shape.toId}-${i}`}
|
||||||
edge={e}
|
shape={shape}
|
||||||
from={placedOf.get(e.edge.from)}
|
|
||||||
to={placedOf.get(e.edge.to)}
|
|
||||||
markerId={markerId}
|
markerId={markerId}
|
||||||
laneMarkerId={laneMarkerId}
|
laneMarkerId={laneMarkerId}
|
||||||
/>
|
/>
|
||||||
@@ -88,42 +105,32 @@ export function FlowGraphView({
|
|||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<Legend hasLanes={lanes.length > 0} />
|
<Legend hasLanes={lanes.length > 0} orientation={orientation} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** EdgePath draws one cubic bezier between two placed nodes; edges that run into
|
/** 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. */
|
* 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({
|
function EdgePath({
|
||||||
edge,
|
shape,
|
||||||
from,
|
|
||||||
to,
|
|
||||||
markerId,
|
markerId,
|
||||||
laneMarkerId,
|
laneMarkerId,
|
||||||
}: {
|
}: {
|
||||||
edge: PlacedEdge;
|
shape: EdgeShape;
|
||||||
from?: PlacedNode;
|
|
||||||
to?: PlacedNode;
|
|
||||||
markerId: string;
|
markerId: string;
|
||||||
laneMarkerId: string;
|
laneMarkerId: string;
|
||||||
}): ReactNode {
|
}): ReactNode {
|
||||||
if (!from || !to) return null;
|
|
||||||
const sx = from.x + NODE_W;
|
|
||||||
const sy = from.y + NODE_H / 2;
|
|
||||||
const tx = to.x;
|
|
||||||
const ty = to.y + NODE_H / 2;
|
|
||||||
const dx = Math.max(24, Math.abs(tx - sx) / 2);
|
|
||||||
const inLane = edge.lane !== undefined;
|
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
d={`M ${sx} ${sy} C ${sx + dx} ${sy}, ${tx - dx} ${ty}, ${tx} ${ty}`}
|
d={shape.d}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
stroke={shape.inLane ? "var(--mu-accent)" : "var(--mu-text-lo)"}
|
||||||
strokeOpacity={inLane ? 0.7 : 1}
|
strokeOpacity={shape.inLane ? 0.7 : 1}
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeDasharray={inLane ? "5 3" : undefined}
|
strokeDasharray={shape.inLane ? "5 3" : undefined}
|
||||||
markerEnd={`url(#${inLane ? laneMarkerId : markerId})`}
|
markerEnd={`url(#${shape.inLane ? laneMarkerId : markerId})`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -245,8 +252,15 @@ function NodeGroup({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legend explains the status colors and the lane edge style. */
|
/** Legend explains the status colors, the lane edge style, and which end of the time
|
||||||
function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
|
* axis is the newest one (the direction is reversed for the whole-page view). */
|
||||||
|
function Legend({
|
||||||
|
hasLanes,
|
||||||
|
orientation,
|
||||||
|
}: {
|
||||||
|
hasLanes: boolean;
|
||||||
|
orientation: FlowOrientation;
|
||||||
|
}): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-3 px-1">
|
<div className="flex flex-wrap items-center gap-3 px-1">
|
||||||
{STATUS_ORDER.map((s) => (
|
{STATUS_ORDER.map((s) => (
|
||||||
@@ -274,6 +288,9 @@ function Legend({ hasLanes }: { hasLanes: boolean }): ReactNode {
|
|||||||
泳道内步骤
|
泳道内步骤
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] text-[var(--mu-text-lo)]">
|
||||||
|
{DIRECTION_HINT[orientation]}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NODE_H, NODE_W, type FlowLayout, type FlowOrientation, type PlacedNode } from "./layout";
|
||||||
|
|
||||||
|
/** 一条边的像素几何(FV0):锚点贴在**节点边框**上,方向由布局方向决定 ——
|
||||||
|
* 最新在上 ⇒ 从更早那拍的上边出去、从更新那拍的下边进来;最新在左 ⇒ 左出右进。
|
||||||
|
* `fromId` / `toId` 原样带回图数据的 id,便于逐边核对「这条线确实连在正确的两个节点之间」。 */
|
||||||
|
export interface EdgeShape {
|
||||||
|
/** 图数据里的起点 id(绝不改写、绝不重排)。 */
|
||||||
|
fromId: string;
|
||||||
|
/** 图数据里的终点 id(时序上更新的那一端)。 */
|
||||||
|
toId: string;
|
||||||
|
/** 该边所属泳道(undefined = 主干)。 */
|
||||||
|
lane?: string;
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
/** SVG path 的 d 属性。 */
|
||||||
|
d: string;
|
||||||
|
inLane: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** edgeShapes resolves every placed edge into a pixel path. 端点缺一的边整条丢弃
|
||||||
|
* (如实丢,不伪造节点)—— layout 已先按「两个端点都在图里」过滤过,这里是第二道闸。 */
|
||||||
|
export function edgeShapes(layout: FlowLayout): EdgeShape[] {
|
||||||
|
const placedOf = new Map(layout.nodes.map((p) => [p.node.id, p]));
|
||||||
|
const shapes: EdgeShape[] = [];
|
||||||
|
layout.edges.forEach((e) => {
|
||||||
|
const from = placedOf.get(e.edge.from);
|
||||||
|
const to = placedOf.get(e.edge.to);
|
||||||
|
if (!from || !to) return;
|
||||||
|
const a = anchors(from, to, layout.orientation);
|
||||||
|
shapes.push({
|
||||||
|
fromId: e.edge.from,
|
||||||
|
toId: e.edge.to,
|
||||||
|
lane: e.lane,
|
||||||
|
sx: a.sx,
|
||||||
|
sy: a.sy,
|
||||||
|
tx: a.tx,
|
||||||
|
ty: a.ty,
|
||||||
|
d: a.d,
|
||||||
|
inLane: e.lane !== undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** anchors picks the border anchor of both nodes plus the cubic control points, so the
|
||||||
|
* arrowhead always points along the time axis of the current orientation. */
|
||||||
|
function anchors(
|
||||||
|
from: PlacedNode,
|
||||||
|
to: PlacedNode,
|
||||||
|
orientation: FlowOrientation,
|
||||||
|
): { sx: number; sy: number; tx: number; ty: number; d: string } {
|
||||||
|
if (orientation === "newest-top") {
|
||||||
|
// 时序自下而上:从更早那拍的上边出发,进更新那拍的下边。
|
||||||
|
const sx = from.x + NODE_W / 2;
|
||||||
|
const sy = from.y;
|
||||||
|
const tx = to.x + NODE_W / 2;
|
||||||
|
const ty = to.y + NODE_H;
|
||||||
|
const dy = Math.max(24, Math.abs(sy - ty) / 2);
|
||||||
|
return { sx, sy, tx, ty, d: `M ${sx} ${sy} C ${sx} ${sy - dy}, ${tx} ${ty + dy}, ${tx} ${ty}` };
|
||||||
|
}
|
||||||
|
// 时序自右向左:从更早那拍的左边出发,进更新那拍的右边。
|
||||||
|
const sx = from.x;
|
||||||
|
const sy = from.y + NODE_H / 2;
|
||||||
|
const tx = to.x + NODE_W;
|
||||||
|
const ty = to.y + NODE_H / 2;
|
||||||
|
const dx = Math.max(24, Math.abs(sx - tx) / 2);
|
||||||
|
return { sx, sy, tx, ty, d: `M ${sx} ${sy} C ${sx - dx} ${sy}, ${tx + dx} ${ty}, ${tx} ${ty}` };
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ function parallel(): FlowGraph {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- trunk only: no lane, columns unchanged (no P1 regression) ---------------
|
// --- trunk only: no lane, one column grid (no P1 regression) ----------------
|
||||||
|
|
||||||
{
|
{
|
||||||
const g = trunkOnly();
|
const g = trunkOnly();
|
||||||
@@ -99,29 +99,29 @@ function parallel(): FlowGraph {
|
|||||||
const at = new Map(nodes.map((p) => [p.node.id, p]));
|
const at = new Map(nodes.map((p) => [p.node.id, p]));
|
||||||
same(bands.length, 1, "trunk graph has exactly one band");
|
same(bands.length, 1, "trunk graph has exactly one band");
|
||||||
same(bands[0].framed, false, "the trunk band is not framed");
|
same(bands[0].framed, false, "the trunk band is not framed");
|
||||||
same(at.get("input")!.x, PAD, "input sits in the first column");
|
// FV0:默认方向 newest-left ⇒ 时序自右向左,最新的一拍(output)落在最左。
|
||||||
|
same(at.get("output")!.x, PAD, "the newest step sits leftmost");
|
||||||
ok(
|
ok(
|
||||||
at.get("output")!.x > at.get("tool1")!.x &&
|
at.get("input")!.x > at.get("llm1")!.x &&
|
||||||
at.get("tool1")!.x > at.get("llm1")!.x &&
|
at.get("llm1")!.x > at.get("tool1")!.x &&
|
||||||
at.get("llm1")!.x > at.get("input")!.x,
|
at.get("tool1")!.x > at.get("output")!.x,
|
||||||
"trunk columns grow left to right",
|
"the time axis runs right to left (newest on the left)",
|
||||||
);
|
);
|
||||||
same(
|
same(
|
||||||
[at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y],
|
[at.get("input")!.y, at.get("llm1")!.y, at.get("tool1")!.y, at.get("output")!.y],
|
||||||
[PAD, PAD, PAD, PAD],
|
[PAD, PAD, PAD, PAD],
|
||||||
"a single-row trunk keeps one row",
|
"a single-row trunk keeps one row",
|
||||||
);
|
);
|
||||||
// P1 pixel parity: with no lane in play the geometry is exactly the old
|
// 同一块最长路径列网格的**镜像**:方向只决定「哪端是最新」,列距/行距分毫不动。
|
||||||
// longest-path column grid, so P2 cannot regress the trunk view.
|
|
||||||
same(
|
same(
|
||||||
nodes.map((p) => [p.node.id, p.x, p.y]),
|
nodes.map((p) => [p.node.id, p.x, p.y]),
|
||||||
[
|
[
|
||||||
["input", PAD, PAD],
|
["input", PAD + 3 * (NODE_W + GAP_X), PAD],
|
||||||
["llm1", PAD + (NODE_W + GAP_X), PAD],
|
["llm1", PAD + 2 * (NODE_W + GAP_X), PAD],
|
||||||
["tool1", PAD + 2 * (NODE_W + GAP_X), PAD],
|
["tool1", PAD + (NODE_W + GAP_X), PAD],
|
||||||
["output", PAD + 3 * (NODE_W + GAP_X), PAD],
|
["output", PAD, PAD],
|
||||||
],
|
],
|
||||||
"trunk-only layout matches the P1 column grid",
|
"trunk-only layout is the same column grid, mirrored (newest-left)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,21 +187,22 @@ function parallel(): FlowGraph {
|
|||||||
const framed = bands.filter((b) => b.framed);
|
const framed = bands.filter((b) => b.framed);
|
||||||
same(framed.length, 2, "each lane gets its own framed band");
|
same(framed.length, 2, "each lane gets its own framed band");
|
||||||
|
|
||||||
// Lane steps start one column right of their sub-agent root.
|
// Lane steps start one column to the *newer* side of their sub-agent root — in
|
||||||
|
// newest-left that side is the left one (smaller x).
|
||||||
same(
|
same(
|
||||||
at.get("llmA")!.x - at.get("subA")!.x,
|
at.get("subA")!.x - at.get("llmA")!.x,
|
||||||
NODE_W + GAP_X,
|
NODE_W + GAP_X,
|
||||||
"lane A's first step starts one column right of its root",
|
"lane A's first step starts one column left (newer) of its root",
|
||||||
);
|
);
|
||||||
same(
|
same(
|
||||||
at.get("toolA")!.x,
|
at.get("llmA")!.x,
|
||||||
at.get("llmA")!.x + NODE_W + GAP_X,
|
at.get("toolA")!.x + NODE_W + GAP_X,
|
||||||
"lane A chains toolA after llmA",
|
"lane A chains toolA after llmA (toolA is newer ⇒ further left)",
|
||||||
);
|
);
|
||||||
same(
|
same(
|
||||||
at.get("llmB")!.x - at.get("subB")!.x,
|
at.get("subB")!.x - at.get("llmB")!.x,
|
||||||
NODE_W + GAP_X,
|
NODE_W + GAP_X,
|
||||||
"lane B's step starts one column right of its root",
|
"lane B's step starts one column left (newer) of its root",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Lanes are stacked below the trunk and never share rows with each other.
|
// Lanes are stacked below the trunk and never share rows with each other.
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
import type { AgentStatus, FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
||||||
import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes";
|
import { groupFlowLanes, type FlowGrouping, type FlowLane } from "./lanes";
|
||||||
|
|
||||||
/** Lane-aware left-to-right layout for the flow graph (design/19 §2). The trunk
|
/** 渲染方向(FV0):时序沿哪条轴、朝哪边推进 —— 「最新的一拍」永远落在画布坐标**最小**的一侧,
|
||||||
* keeps the P1 "longest path depth" columns; every sub-agent lane gets its own
|
* 于是容器默认露出的那一角就是最新的时刻。
|
||||||
* horizontal band below the trunk, its steps starting one column right of the
|
* - `newest-top`:时序**自下而上**,泳道沿垂直方向(向右)并排 —— 整页 Flow 用;
|
||||||
* sub-agent root and chaining rightwards. Bands never share rows, so parallel
|
* 画布纵向自动拓高(向下滚动看历史,向上看最新)。
|
||||||
* branches cannot overlap — the canvas just grows (and scrolls) instead. */
|
* - `newest-left`:时序**自右向左**,泳道向下叠加 —— 嵌在页面里的图块(Backthink / Agents)
|
||||||
|
* 用;横向滚动容器的可视区默认露出最左边,因此最新在最左。
|
||||||
|
*
|
||||||
|
* 两种方向**共用同一套排布**:先按「时序轴(step)× 堆叠轴(stack)」算抽象网格,
|
||||||
|
* 再由 `toPixelNode` / `toPixelBand` 一次性映射成像素(`newest-top` 把两轴对调)。
|
||||||
|
* 方向只影响这一处映射与边的锚点(见 flow/edges.ts),**不复制第二套布局**。 */
|
||||||
|
export type FlowOrientation = "newest-top" | "newest-left";
|
||||||
|
|
||||||
export const NODE_W = 148;
|
export const NODE_W = 148;
|
||||||
export const NODE_H = 52;
|
export const NODE_H = 52;
|
||||||
export const GAP_X = 64;
|
export const GAP_X = 64;
|
||||||
export const GAP_Y = 30;
|
export const GAP_Y = 30;
|
||||||
export const PAD = 20;
|
export const PAD = 20;
|
||||||
/** Vertical gap between two bands. */
|
/** Gap between two bands, along the stack axis. */
|
||||||
export const BAND_GAP = 26;
|
export const BAND_GAP = 26;
|
||||||
/** Padding between a lane frame and the nodes it holds. */
|
/** Padding between a lane frame and the nodes it holds. */
|
||||||
export const FRAME_PAD = 10;
|
export const FRAME_PAD = 10;
|
||||||
@@ -51,8 +57,11 @@ export interface FlowLayout {
|
|||||||
edges: PlacedEdge[];
|
edges: PlacedEdge[];
|
||||||
bands: PlacedBand[];
|
bands: PlacedBand[];
|
||||||
lanes: FlowLane[];
|
lanes: FlowLane[];
|
||||||
|
/** 画布像素尺寸:`newest-left` 是「宽 × 高」,`newest-top` 是同一块网格转置后的「宽 × 高」。 */
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
/** 本次布局用的方向(边的锚点由渲染层按它决定,回传以避免两处口径不一致)。 */
|
||||||
|
orientation: FlowOrientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BandPlan {
|
interface BandPlan {
|
||||||
@@ -68,22 +77,89 @@ interface BandPlan {
|
|||||||
toCol: number;
|
toCol: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** layoutFlow computes the lane-banded geometry of a graph. */
|
/** 轴度量:时序轴(step)与堆叠轴(stack)各用哪套尺寸/间隙。
|
||||||
export function layoutFlow(graph: FlowGraph): FlowLayout {
|
* `newest-top` 把两轴对调 ⇒ 节点在时序轴上占的是**高度**,在堆叠轴上占的是**宽度**。 */
|
||||||
|
interface AxisMetric {
|
||||||
|
stepLen: number;
|
||||||
|
stepGap: number;
|
||||||
|
stackLen: number;
|
||||||
|
stackGap: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 抽象网格坐标(尚未决定横竖),像素化在 toPixelNode / toPixelBand 里做。 */
|
||||||
|
interface GridNode {
|
||||||
|
node: FlowNode;
|
||||||
|
step: number;
|
||||||
|
stack: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridBox {
|
||||||
|
id?: string;
|
||||||
|
label: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
stepStart: number;
|
||||||
|
stepSize: number;
|
||||||
|
stackStart: number;
|
||||||
|
stackSize: number;
|
||||||
|
framed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowGrid {
|
||||||
|
nodes: GridNode[];
|
||||||
|
boxes: GridBox[];
|
||||||
|
stackExtent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** layoutFlow computes the lane-banded geometry of a graph in the given direction.
|
||||||
|
* 默认 `newest-left`:嵌在页面里的图块(Backthink / Agents)用横向、最新在左;
|
||||||
|
* 整页 Flow 显式传 `newest-top`(时序自下而上、容器纵向自动拓高)。 */
|
||||||
|
export function layoutFlow(
|
||||||
|
graph: FlowGraph,
|
||||||
|
orientation: FlowOrientation = "newest-left",
|
||||||
|
): FlowLayout {
|
||||||
const grouping = groupFlowLanes(graph);
|
const grouping = groupFlowLanes(graph);
|
||||||
if (grouping.byId.size === 0) {
|
if (grouping.byId.size === 0) {
|
||||||
return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2 };
|
return { nodes: [], edges: [], bands: [], lanes: [], width: PAD * 2, height: PAD * 2, orientation };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metric = axisMetric(orientation);
|
||||||
const index = orderIndex(grouping.nodes);
|
const index = orderIndex(grouping.nodes);
|
||||||
const plans = planBands(grouping);
|
const plans = planBands(grouping);
|
||||||
const { nodes, bands, width, height } = placeBands(plans, index);
|
const maxCol = canvasMaxCol(plans);
|
||||||
|
const grid = buildGrid(plans, index, metric, maxCol);
|
||||||
|
const nodes = grid.nodes.map((n) => toPixelNode(n, orientation));
|
||||||
|
const bands = grid.boxes.map((b) => toPixelBand(b, orientation));
|
||||||
const placed = new Set(nodes.map((p) => p.node.id));
|
const placed = new Set(nodes.map((p) => p.node.id));
|
||||||
const edges: PlacedEdge[] = grouping.edges
|
const edges: PlacedEdge[] = grouping.edges
|
||||||
.filter((e) => placed.has(e.from) && placed.has(e.to))
|
.filter((e) => placed.has(e.from) && placed.has(e.to))
|
||||||
.map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) }));
|
.map((edge) => ({ edge, lane: grouping.bandOf.get(edge.to) }));
|
||||||
|
|
||||||
return { nodes, edges, bands, lanes: grouping.lanes, width, height };
|
const stepExtent = axisExtent(maxCol, metric.stepLen, metric.stepGap);
|
||||||
|
const size =
|
||||||
|
orientation === "newest-top"
|
||||||
|
? { width: grid.stackExtent, height: stepExtent }
|
||||||
|
: { width: stepExtent, height: grid.stackExtent };
|
||||||
|
|
||||||
|
return { nodes, edges, bands, lanes: grouping.lanes, ...size, orientation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** axisMetric picks which pixel size / gap each axis uses for the direction. */
|
||||||
|
function axisMetric(orientation: FlowOrientation): AxisMetric {
|
||||||
|
return orientation === "newest-top"
|
||||||
|
? { stepLen: NODE_H, stepGap: GAP_Y, stackLen: NODE_W, stackGap: GAP_X }
|
||||||
|
: { stepLen: NODE_W, stepGap: GAP_X, stackLen: NODE_H, stackGap: GAP_Y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** axisExtent is the total pixel length of one axis (PAD on both ends). */
|
||||||
|
function axisExtent(maxCol: number, len: number, gap: number): number {
|
||||||
|
return PAD * 2 + (maxCol + 1) * len + maxCol * gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** canvasMaxCol is the last column the canvas must cover: the widest band, including the
|
||||||
|
* minimum span of an idle lane — otherwise a lane frame that just opened (no step yet)
|
||||||
|
* would be drawn outside the canvas and clipped. */
|
||||||
|
function canvasMaxCol(plans: BandPlan[]): number {
|
||||||
|
return plans.reduce((max, p) => Math.max(max, p.toCol), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** orderIndex maps every node id to its graph-order position. */
|
/** orderIndex maps every node id to its graph-order position. */
|
||||||
@@ -138,25 +214,25 @@ function planBands(grouping: FlowGrouping): BandPlan[] {
|
|||||||
return plans;
|
return plans;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** placeBands turns the plans into pixel geometry: bands stack top-down (lanes
|
/** buildGrid lays the planned bands onto the abstract step × stack grid: bands are
|
||||||
* framed, with a header row for their label) and every node gets its own slot. */
|
* placed one after another along the stack axis (lane frames carry a label row), and
|
||||||
function placeBands(
|
* the siblings of one step occupy successive slots along that same axis. */
|
||||||
|
function buildGrid(
|
||||||
plans: BandPlan[],
|
plans: BandPlan[],
|
||||||
index: Map<string, number>,
|
index: Map<string, number>,
|
||||||
): { nodes: PlacedNode[]; bands: PlacedBand[]; width: number; height: number } {
|
metric: AxisMetric,
|
||||||
const pos = new Map<string, PlacedNode>();
|
maxCol: number,
|
||||||
const bands: PlacedBand[] = [];
|
): FlowGrid {
|
||||||
/** Top of the next band (its frame top, header included). */
|
const nodes: GridNode[] = [];
|
||||||
let limitY = PAD;
|
const boxes: GridBox[] = [];
|
||||||
let contentBottom = PAD;
|
const stepPitch = metric.stepLen + metric.stepGap;
|
||||||
let maxCol = 0;
|
const stackPitch = metric.stackLen + metric.stackGap;
|
||||||
|
let stackCursor = PAD;
|
||||||
|
|
||||||
plans.forEach((plan) => {
|
plans.forEach((plan) => {
|
||||||
const framed = plan.id !== undefined;
|
const framed = plan.id !== undefined;
|
||||||
const frameY = limitY;
|
const stackSize = bandStackSize(plan.rows, metric, framed);
|
||||||
const contentY = framed ? frameY + LANE_HEADER + FRAME_PAD : frameY;
|
const contentStack = stackCursor + (framed ? LANE_HEADER + FRAME_PAD : 0);
|
||||||
const bandHeight = rowsHeight(plan.rows);
|
|
||||||
const frameHeight = framed ? bandHeight + LANE_HEADER + FRAME_PAD * 2 : bandHeight;
|
|
||||||
|
|
||||||
const byCol = new Map<number, FlowNode[]>();
|
const byCol = new Map<number, FlowNode[]>();
|
||||||
plan.members.forEach((n) => {
|
plan.members.forEach((n) => {
|
||||||
@@ -164,41 +240,64 @@ function placeBands(
|
|||||||
const arr = byCol.get(col) ?? [];
|
const arr = byCol.get(col) ?? [];
|
||||||
arr.push(n);
|
arr.push(n);
|
||||||
byCol.set(col, arr);
|
byCol.set(col, arr);
|
||||||
maxCol = Math.max(maxCol, col);
|
|
||||||
});
|
});
|
||||||
byCol.forEach((arr, col) => {
|
byCol.forEach((arr, col) => {
|
||||||
arr
|
arr
|
||||||
.sort((a, b) => index.get(a.id)! - index.get(b.id)!)
|
.sort((a, b) => index.get(a.id)! - index.get(b.id)!)
|
||||||
.forEach((n, row) => {
|
.forEach((n, row) => {
|
||||||
pos.set(n.id, {
|
nodes.push({
|
||||||
node: n,
|
node: n,
|
||||||
x: PAD + col * (NODE_W + GAP_X),
|
step: stepPos(col, maxCol, stepPitch),
|
||||||
y: contentY + row * (NODE_H + GAP_Y),
|
stack: contentStack + row * stackPitch,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
bands.push({
|
boxes.push({
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
label: plan.lane ? plan.lane.root.label : "主干",
|
label: plan.lane ? plan.lane.root.label : "主干",
|
||||||
status: plan.lane ? plan.lane.status : "idle",
|
status: plan.lane ? plan.lane.status : "idle",
|
||||||
x: PAD + plan.fromCol * (NODE_W + GAP_X) - (framed ? FRAME_PAD : 0),
|
stepStart: stepPos(plan.toCol, maxCol, stepPitch) - (framed ? FRAME_PAD : 0),
|
||||||
y: frameY,
|
stepSize: (plan.toCol - plan.fromCol) * stepPitch + metric.stepLen + (framed ? FRAME_PAD * 2 : 0),
|
||||||
width: (plan.toCol - plan.fromCol + 1) * (NODE_W + GAP_X) - GAP_X + (framed ? FRAME_PAD * 2 : 0),
|
stackStart: stackCursor,
|
||||||
height: frameHeight,
|
stackSize,
|
||||||
framed,
|
framed,
|
||||||
});
|
});
|
||||||
|
|
||||||
contentBottom = frameY + frameHeight;
|
stackCursor += stackSize + BAND_GAP;
|
||||||
limitY = contentBottom + BAND_GAP;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
nodes.sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!);
|
||||||
nodes: [...pos.values()].sort((a, b) => index.get(a.node.id)! - index.get(b.node.id)!),
|
return { nodes, boxes, stackExtent: stackCursor - BAND_GAP + PAD };
|
||||||
bands,
|
}
|
||||||
width: PAD * 2 + (maxCol + 1) * NODE_W + maxCol * GAP_X,
|
|
||||||
height: Math.max(contentBottom + PAD, PAD * 2),
|
/** stepPos projects a step (column) onto the step axis: the bigger the column, the newer
|
||||||
};
|
* the moment, the **smaller** the coordinate — newest ends up at the top / on the left. */
|
||||||
|
function stepPos(col: number, maxCol: number, stepPitch: number): number {
|
||||||
|
return PAD + (maxCol - col) * stepPitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bandStackSize is the size a band takes on the stack axis (a lane frame also carries
|
||||||
|
* its label row and the inner padding). */
|
||||||
|
function bandStackSize(rows: number, metric: AxisMetric, framed: boolean): number {
|
||||||
|
const content = rows * metric.stackLen + (rows - 1) * metric.stackGap;
|
||||||
|
return framed ? content + LANE_HEADER + FRAME_PAD * 2 : content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toPixelNode is the only direction switch for nodes: `newest-top` swaps both axes. */
|
||||||
|
function toPixelNode(grid: GridNode, orientation: FlowOrientation): PlacedNode {
|
||||||
|
return orientation === "newest-top"
|
||||||
|
? { node: grid.node, x: grid.stack, y: grid.step }
|
||||||
|
: { node: grid.node, x: grid.step, y: grid.stack };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toPixelBand is the same direction switch for band frames. */
|
||||||
|
function toPixelBand(box: GridBox, orientation: FlowOrientation): PlacedBand {
|
||||||
|
const rect =
|
||||||
|
orientation === "newest-top"
|
||||||
|
? { x: box.stackStart, y: box.stepStart, width: box.stackSize, height: box.stepSize }
|
||||||
|
: { x: box.stepStart, y: box.stackStart, width: box.stepSize, height: box.stackSize };
|
||||||
|
return { id: box.id, label: box.label, status: box.status, framed: box.framed, ...rect };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** computeBandColumns fills a band's per-node depth (longest path inside the
|
/** computeBandColumns fills a band's per-node depth (longest path inside the
|
||||||
@@ -235,8 +334,3 @@ function computeBandColumns(plan: BandPlan, edges: FlowEdge[]): void {
|
|||||||
});
|
});
|
||||||
plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1;
|
plan.rows = perCol.size > 0 ? Math.max(...perCol.values()) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** rowsHeight is the pixel height of rows stacked node cards. */
|
|
||||||
function rowsHeight(rows: number): number {
|
|
||||||
return rows * NODE_H + (rows - 1) * GAP_Y;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// 方向(FV0)的**真实渲染**测试:主人报的是「看得见」的体验(最新的必须在最上 / 最左、
|
||||||
|
// 容器高度要跟着图长),所以这里不信逻辑返回值 —— 直接从 SSR 出来的 SVG 里把**节点矩形**
|
||||||
|
// 与**边路径**解析出来,核对:
|
||||||
|
// ① Flow 页(整页)最新的一拍在**最上**(时序自下而上);
|
||||||
|
// ② 主动思考 / Agents 的图块保持横向渲染,但最新的一拍在**最左**;
|
||||||
|
// ③ 每条边都连在**正确的两个节点**之间(端点贴在正确节点的正确边框中点,箭头指向更新的那端);
|
||||||
|
// ④ 画布高度随图生长、容器不限高(不内部裁切)。
|
||||||
|
//
|
||||||
|
// 本项目没有 jsdom ⇒ 用 react-dom/server 渲染 **props 驱动**的组件(同
|
||||||
|
// flowSnapshot.render.test.ts 头注释的理由)。
|
||||||
|
//
|
||||||
|
// 跑法:
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/flow/orientation.render.test.ts \
|
||||||
|
// --bundle --platform=node --format=cjs \
|
||||||
|
// --outfile=/tmp/cyrene-flow-orientation-model.test.cjs \
|
||||||
|
// && node /tmp/cyrene-flow-orientation-model.test.cjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||||
|
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
||||||
|
import { BACKTHINK_SESSION_ID, MAIN_SESSION_ID } from "../../../store/sessionCache";
|
||||||
|
import { AgentsFlowBlock } from "../Agents";
|
||||||
|
import { BackthinkGraphBlock } from "../Backthink";
|
||||||
|
import { FlowBody } from "../Flow";
|
||||||
|
import { NODE_H, NODE_W, layoutFlow } from "./layout";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function has(haystack: string, needle: string): boolean {
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string, label: string, kind: string): FlowNode {
|
||||||
|
return { id, label, kind, status: "done" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph {
|
||||||
|
return { id: "web-fv0", sessionId: MAIN_SESSION_ID, nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「输入 → 思考 → 工具 → 回复」主干;追加一拍用来验证画布继续长高。 */
|
||||||
|
function chain(extra = 0): FlowGraph {
|
||||||
|
const nodes = [
|
||||||
|
node("input", "哨兵输入", "input"),
|
||||||
|
node("llm1", "哨兵思考", "llm"),
|
||||||
|
node("tool1", "哨兵工具", "tool"),
|
||||||
|
node("output", "哨兵回复", "output"),
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ from: "input", to: "llm1" },
|
||||||
|
{ from: "llm1", to: "tool1" },
|
||||||
|
{ from: "tool1", to: "output" },
|
||||||
|
];
|
||||||
|
for (let i = 0; i < extra; i += 1) {
|
||||||
|
const prev = nodes[nodes.length - 1].id;
|
||||||
|
nodes.push(node(`output${i + 2}`, `哨兵追加${i + 1}`, "output"));
|
||||||
|
edges.push({ from: prev, to: `output${i + 2}` });
|
||||||
|
}
|
||||||
|
return graph(nodes, edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主干 + 一条并行子代理泳道。 */
|
||||||
|
function withLane(): FlowGraph {
|
||||||
|
return graph(
|
||||||
|
[
|
||||||
|
node("input", "哨兵输入", "input"),
|
||||||
|
node("t1", "哨兵工具", "tool"),
|
||||||
|
node("subA", "哨兵子代理", "subagent"),
|
||||||
|
node("llmA", "哨兵子思考", "llm"),
|
||||||
|
node("toolA", "哨兵子工具", "tool"),
|
||||||
|
node("output", "哨兵回复", "output"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ from: "input", to: "t1" },
|
||||||
|
{ from: "t1", to: "subA" },
|
||||||
|
{ from: "subA", to: "llmA" },
|
||||||
|
{ from: "llmA", to: "toolA" },
|
||||||
|
{ from: "t1", to: "output" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** canvasOf slices out just the graph canvas: the page markup also carries Icon <svg>s
|
||||||
|
* whose paths would otherwise be mistaken for graph edges. */
|
||||||
|
function canvasOf(markup: string): string {
|
||||||
|
const start = markup.indexOf("data-flow-canvas");
|
||||||
|
ok(start >= 0, "必须渲染出图布容器");
|
||||||
|
const end = markup.indexOf("</svg>", start);
|
||||||
|
ok(end > start, "画布必须含 svg");
|
||||||
|
return markup.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rectOf reads the坐标 of the card of the node whose label is `label`
|
||||||
|
* (the title line precedes the card rect in the markup). */
|
||||||
|
function rectOf(markup: string, label: string): { x: number; y: number } {
|
||||||
|
const canvas = canvasOf(markup);
|
||||||
|
const anchor = canvas.indexOf(`<title>${label} · `);
|
||||||
|
ok(anchor >= 0, `节点 ${label} 必须被画出来`);
|
||||||
|
const m = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)"/.exec(canvas.slice(anchor));
|
||||||
|
ok(m !== null, `节点 ${label} 必须有坐标`);
|
||||||
|
return { x: Number(m![1]), y: Number(m![2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** edgePaths returns the rendered cubic edge paths, in draw order (marker shapes
|
||||||
|
* use a straight "L" path and are therefore skipped). */
|
||||||
|
function edgePaths(markup: string): string[] {
|
||||||
|
const canvas = canvasOf(markup);
|
||||||
|
const out: string[] = [];
|
||||||
|
const re = /d="(M -?[\d.]+ -?[\d.]+ C [^"]+)"/g;
|
||||||
|
let m = re.exec(canvas);
|
||||||
|
while (m !== null) {
|
||||||
|
out.push(m[1]);
|
||||||
|
m = re.exec(canvas);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** curve parses a cubic path into anchors + control points. */
|
||||||
|
function curve(d: string): {
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
c1x: number;
|
||||||
|
c1y: number;
|
||||||
|
c2x: number;
|
||||||
|
c2y: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
} {
|
||||||
|
const [head, rest] = d.replace("M ", "").split(" C ");
|
||||||
|
const [sx, sy] = head.split(" ").map(Number);
|
||||||
|
const [c1, c2, tail] = rest.split(", ");
|
||||||
|
const [c1x, c1y] = c1.split(" ").map(Number);
|
||||||
|
const [c2x, c2y] = c2.split(" ").map(Number);
|
||||||
|
const [tx, ty] = tail.split(" ").map(Number);
|
||||||
|
return { sx, sy, c1x, c1y, c2x, c2y, tx, ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tagAt returns the whole opening tag that carries `needle` (for class inspection). */
|
||||||
|
function tagAt(markup: string, needle: string): string {
|
||||||
|
const at = markup.indexOf(needle);
|
||||||
|
ok(at >= 0, `必须存在带 ${needle} 的容器`);
|
||||||
|
const start = markup.lastIndexOf("<", at);
|
||||||
|
const end = markup.indexOf(">", at);
|
||||||
|
return markup.slice(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** svgSize reads the rendered canvas size (of the graph canvas, not an Icon). */
|
||||||
|
function svgSize(markup: string): { width: number; height: number } {
|
||||||
|
const m = /<svg width="(-?[\d.]+)" height="(-?[\d.]+)"/.exec(canvasOf(markup));
|
||||||
|
ok(m !== null, "必须画出 svg 画布");
|
||||||
|
return { width: Number(m![1]), height: Number(m![2]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** checkContainer asserts the canvas grows with the graph and never clips vertically. */
|
||||||
|
function checkContainer(markup: string, orientation: "newest-top" | "newest-left", what: string): void {
|
||||||
|
const tag = tagAt(markup, "data-flow-canvas");
|
||||||
|
ok(has(tag, "overflow-x-auto"), `${what}:画布只在横向需要时滚动`);
|
||||||
|
ok(!has(tag, "max-h") && !has(tag, "overflow-hidden"), `${what}:容器不得限高/裁切`);
|
||||||
|
ok(!has(tag, "overflow-y"), `${what}:容器纵向随图生长(不内部滚动截断)`);
|
||||||
|
ok(tag.indexOf("<svg") < 0, `${what}:画布容器是外层 div,不把限高带进 svg`);
|
||||||
|
void orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** checkEdges asserts每一条边都画在正确的两个节点之间(逐边核对端点 id 对应的矩形)。 */
|
||||||
|
function checkEdges(
|
||||||
|
markup: string,
|
||||||
|
g: FlowGraph,
|
||||||
|
orientation: "newest-top" | "newest-left",
|
||||||
|
what: string,
|
||||||
|
): void {
|
||||||
|
const paths = edgePaths(markup);
|
||||||
|
ok(
|
||||||
|
paths.length === g.edges.length,
|
||||||
|
`${what}:${g.edges.length} 条边必须全部画出来(实际 ${paths.length})`,
|
||||||
|
);
|
||||||
|
const labelOf = new Map(g.nodes.map((n) => [n.id, n.label]));
|
||||||
|
g.edges.forEach((e, i) => {
|
||||||
|
const from = rectOf(markup, labelOf.get(e.from)!);
|
||||||
|
const to = rectOf(markup, labelOf.get(e.to)!);
|
||||||
|
const c = curve(paths[i]);
|
||||||
|
if (orientation === "newest-top") {
|
||||||
|
ok(
|
||||||
|
c.sx === from.x + NODE_W / 2 && c.sy === from.y,
|
||||||
|
`${what}:边 ${i}(${e.from}→${e.to})起点贴在 ${e.from} 的上边中点`,
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
c.tx === to.x + NODE_W / 2 && c.ty === to.y + NODE_H,
|
||||||
|
`${what}:边 ${i}(${e.from}→${e.to})终点贴在 ${e.to} 的下边中点`,
|
||||||
|
);
|
||||||
|
ok(c.c1y < c.sy && c.c2y > c.ty, `${what}:边 ${i} 箭头自下而上,指向更新的节点`);
|
||||||
|
} else {
|
||||||
|
ok(
|
||||||
|
c.sx === from.x && c.sy === from.y + NODE_H / 2,
|
||||||
|
`${what}:边 ${i}(${e.from}→${e.to})起点贴在 ${e.from} 的左边中点`,
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
c.tx === to.x + NODE_W && c.ty === to.y + NODE_H / 2,
|
||||||
|
`${what}:边 ${i}(${e.from}→${e.to})终点贴在 ${e.to} 的右边中点`,
|
||||||
|
);
|
||||||
|
ok(c.c1x < c.sx && c.c2x > c.tx, `${what}:边 ${i} 箭头自右向左,指向更新的节点`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 全链路流程页:时序自下而上,最新在最上,容器只向高处生长 ────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = chain();
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(FlowBody, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }),
|
||||||
|
);
|
||||||
|
ok(has(html, "哨兵回复") && has(html, "哨兵输入"), "Flow 页把该轮的节点都画出来");
|
||||||
|
|
||||||
|
const yInput = rectOf(html, "哨兵输入").y;
|
||||||
|
const yLlm = rectOf(html, "哨兵思考").y;
|
||||||
|
const yTool = rectOf(html, "哨兵工具").y;
|
||||||
|
const yOut = rectOf(html, "哨兵回复").y;
|
||||||
|
ok(yOut < yTool && yTool < yLlm && yLlm < yInput, "Flow 页:最新的一拍在最上,旧的在下面");
|
||||||
|
ok(
|
||||||
|
rectOf(html, "哨兵输入").x === rectOf(html, "哨兵回复").x,
|
||||||
|
"Flow 页:时序沿纵向推进(同一拍不横向铺开)",
|
||||||
|
);
|
||||||
|
ok(has(html, "最新在最上"), "Flow 页必须写明「最新在最上」(方向反了要让人看得懂)");
|
||||||
|
ok(has(html, "时序自下而上"), "Flow 页必须写明时序自下而上");
|
||||||
|
|
||||||
|
const size = svgSize(html);
|
||||||
|
const layout = layoutFlow(g, "newest-top");
|
||||||
|
ok(size.height === layout.height, "Flow 页画布高度 = 布局高度(图多高就多高)");
|
||||||
|
ok(size.width === layout.width, "Flow 页画布宽度 = 布局宽度");
|
||||||
|
ok(yOut === layout.nodes.find((n) => n.node.id === "output")!.y, "Flow 页最新节点的 y 就是布局给的上沿");
|
||||||
|
ok(yInput + NODE_H <= size.height, "Flow 页最旧节点的下沿仍在画布内(没有裁掉历史)");
|
||||||
|
checkContainer(html, "newest-top", "Flow 页");
|
||||||
|
checkEdges(html, g, "newest-top", "Flow 页");
|
||||||
|
|
||||||
|
const taller = renderToStaticMarkup(
|
||||||
|
createElement(FlowBody, { graph: chain(1), sessionId: MAIN_SESSION_ID, source: "live" }),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
svgSize(taller).height > size.height,
|
||||||
|
"Flow 页:多一拍 ⇒ 容器更高(高度自动拓高,不是固定框内裁切)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 主动思考页的图块:保持横向渲染,但最新在最左 ───────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = chain();
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(BackthinkGraphBlock, {
|
||||||
|
graph: g,
|
||||||
|
sessionId: BACKTHINK_SESSION_ID,
|
||||||
|
source: "snapshot",
|
||||||
|
wsStatus: "connected",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const xInput = rectOf(html, "哨兵输入").x;
|
||||||
|
const xLlm = rectOf(html, "哨兵思考").x;
|
||||||
|
const xTool = rectOf(html, "哨兵工具").x;
|
||||||
|
const xOut = rectOf(html, "哨兵回复").x;
|
||||||
|
ok(xOut < xTool && xTool < xLlm && xLlm < xInput, "后台思考图块:最新的一拍在最左,旧的在右边");
|
||||||
|
ok(
|
||||||
|
rectOf(html, "哨兵输入").y === rectOf(html, "哨兵回复").y,
|
||||||
|
"后台思考图块:仍是横向渲染(同一拍同一行),没有改成竖版",
|
||||||
|
);
|
||||||
|
ok(has(html, "最新在最左"), "后台思考图块必须写明「最新在最左」");
|
||||||
|
ok(has(html, "时序自右向左"), "后台思考图块必须写明时序自右向左");
|
||||||
|
|
||||||
|
const layout = layoutFlow(g, "newest-left");
|
||||||
|
ok(rectOf(html, "哨兵回复").x === layout.nodes.find((n) => n.node.id === "output")!.x, "图块最新节点的 x 就是布局给的最左端");
|
||||||
|
checkContainer(html, "newest-left", "后台思考图块");
|
||||||
|
checkEdges(html, g, "newest-left", "后台思考图块");
|
||||||
|
|
||||||
|
// Agents 页的图块同一档(同一套布局代码、同一个方向值)。
|
||||||
|
const agents = renderToStaticMarkup(
|
||||||
|
createElement(AgentsFlowBlock, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }),
|
||||||
|
);
|
||||||
|
ok(has(agents, "最新在最左"), "Agents 页图块与后台思考图块同一方向(最新在最左)");
|
||||||
|
checkEdges(agents, g, "newest-left", "Agents 页图块");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 有泳道时:方向仍成立、泳道仍成道、边仍连对 ─────────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = withLane();
|
||||||
|
const top = renderToStaticMarkup(
|
||||||
|
createElement(FlowBody, { graph: g, sessionId: MAIN_SESSION_ID, source: "live" }),
|
||||||
|
);
|
||||||
|
ok(has(top, "泳道 · 哨兵子代理"), "Flow 页必须把子代理画成独立泳道");
|
||||||
|
const frame = /<rect x="(-?[\d.]+)" y="(-?[\d.]+)" width="([\d.]+)" height="([\d.]+)"[^>]*stroke-dasharray="6 4"/.exec(top);
|
||||||
|
ok(frame !== null, "泳道必须有框");
|
||||||
|
const fx = Number(frame![1]);
|
||||||
|
const fy = Number(frame![2]);
|
||||||
|
const fw = Number(frame![3]);
|
||||||
|
const fh = Number(frame![4]);
|
||||||
|
["哨兵子思考", "哨兵子工具"].forEach((label) => {
|
||||||
|
const r = rectOf(top, label);
|
||||||
|
ok(
|
||||||
|
r.x >= fx && r.x + NODE_W <= fx + fw && r.y >= fy && r.y + NODE_H <= fy + fh,
|
||||||
|
`Flow 页:${label} 落在自己的泳道框内(方向反转后仍成道)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ok(
|
||||||
|
rectOf(top, "哨兵子思考").y < rectOf(top, "哨兵子代理").y,
|
||||||
|
"Flow 页:泳道内的步骤比泳道根更新(更靠上)",
|
||||||
|
);
|
||||||
|
checkEdges(top, g, "newest-top", "Flow 页(带泳道)");
|
||||||
|
|
||||||
|
const left = renderToStaticMarkup(
|
||||||
|
createElement(BackthinkGraphBlock, {
|
||||||
|
graph: g,
|
||||||
|
sessionId: BACKTHINK_SESSION_ID,
|
||||||
|
source: "live",
|
||||||
|
wsStatus: "connected",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
rectOf(left, "哨兵子思考").x < rectOf(left, "哨兵子代理").x,
|
||||||
|
"后台思考图块:泳道内的步骤比泳道根更新(更靠左)",
|
||||||
|
);
|
||||||
|
checkEdges(left, g, "newest-left", "后台思考图块(带泳道)");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`flow orientation render test: ${checks} checks passed`);
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
// 方向(FV0)逻辑测试:两种方向**共用同一套布局代码**(flow/layout.ts 的 `FlowOrientation`),
|
||||||
|
// 这里钉住四条不能退让的事实:
|
||||||
|
// ① 时序朝向 —— `newest-top` 最新的节点在**最上**,`newest-left` 最新的在**最左**;
|
||||||
|
// ② 画布随图生长且**不裁切** —— 每个节点、每个泳道框都落在画布尺寸内;
|
||||||
|
// ③ 连线正确性 —— 每条边仍连在**正确的两个节点**之间(逐边核对端点 id 与像素锚点),
|
||||||
|
// 端点缺一的边如实丢弃、不伪造;
|
||||||
|
// ④ 泳道仍成道 —— 泳道框罩住自己的成员,且落在主干**更新的一侧**。
|
||||||
|
//
|
||||||
|
// 跑法(同 lanes.test.ts,用 vite 自带的 esbuild,不加依赖):
|
||||||
|
//
|
||||||
|
// cd frontend/web \
|
||||||
|
// && ./node_modules/.bin/esbuild src/admin/modules/flow/orientation.test.ts \
|
||||||
|
// --bundle --platform=node --format=esm \
|
||||||
|
// --outfile=/tmp/cyrene-flow-orientation.test.mjs \
|
||||||
|
// && node /tmp/cyrene-flow-orientation.test.mjs
|
||||||
|
//
|
||||||
|
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||||
|
|
||||||
|
import { GAP_X, GAP_Y, NODE_H, NODE_W, PAD, layoutFlow, type FlowLayout } from "./layout";
|
||||||
|
import { edgeShapes } from "./edges";
|
||||||
|
import type { FlowEdge, FlowGraph, FlowNode } from "../../../lib/types";
|
||||||
|
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
function ok(cond: boolean, what: string): void {
|
||||||
|
checks += 1;
|
||||||
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function same<T>(got: T, want: T, what: string): void {
|
||||||
|
const g = JSON.stringify(got);
|
||||||
|
const w = JSON.stringify(want);
|
||||||
|
ok(g === w, `${what} — got ${g}, want ${w}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string, kind: string, status?: FlowNode["status"]): FlowNode {
|
||||||
|
return { id, label: `节点-${id}`, kind, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function graph(nodes: FlowNode[], edges: FlowEdge[]): FlowGraph {
|
||||||
|
return { id: "web-fv0", nodes, edges, sessionId: "web" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条「输入 → 思考 → 工具 → 回复」的主干链(4 拍,无泳道)。 */
|
||||||
|
function chain(extra = 0): FlowGraph {
|
||||||
|
const nodes = [
|
||||||
|
node("input", "input", "done"),
|
||||||
|
node("llm1", "llm", "done"),
|
||||||
|
node("tool1", "tool", "done"),
|
||||||
|
node("output", "output", "done"),
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ from: "input", to: "llm1" },
|
||||||
|
{ from: "llm1", to: "tool1" },
|
||||||
|
{ from: "tool1", to: "output" },
|
||||||
|
];
|
||||||
|
for (let i = 0; i < extra; i += 1) {
|
||||||
|
const prev = nodes[nodes.length - 1].id;
|
||||||
|
const id = `output${i + 2}`;
|
||||||
|
nodes.push(node(id, "output", "done"));
|
||||||
|
edges.push({ from: prev, to: id });
|
||||||
|
}
|
||||||
|
return graph(nodes, edges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主干 + 一条并行子代理泳道(泳道的每一步都连在泳道根之后)。 */
|
||||||
|
function withLane(): FlowGraph {
|
||||||
|
return graph(
|
||||||
|
[
|
||||||
|
node("input", "input", "done"),
|
||||||
|
node("t1", "tool", "done"),
|
||||||
|
node("subA", "subagent", "running"),
|
||||||
|
node("llmA", "llm", "running"),
|
||||||
|
node("toolA", "tool", "done"),
|
||||||
|
node("output", "output", "done"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ from: "input", to: "t1" },
|
||||||
|
{ from: "t1", to: "subA" },
|
||||||
|
{ from: "subA", to: "llmA" },
|
||||||
|
{ from: "llmA", to: "toolA" },
|
||||||
|
{ from: "t1", to: "output" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 泳道只开了头、还没有步骤(实时图上很常见)——泳道框不能被画到画布外。 */
|
||||||
|
function idleLane(): FlowGraph {
|
||||||
|
return graph(
|
||||||
|
[node("input", "input", "done"), node("subA", "subagent", "running")],
|
||||||
|
[{ from: "input", to: "subA" }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(layout: FlowLayout): Map<string, { x: number; y: number }> {
|
||||||
|
return new Map(layout.nodes.map((p) => [p.node.id, { x: p.x, y: p.y }]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fits asserts every card / band frame lies inside the canvas (nothing is clipped). */
|
||||||
|
function fits(layout: FlowLayout, what: string): void {
|
||||||
|
layout.nodes.forEach((p) => {
|
||||||
|
ok(
|
||||||
|
p.x >= 0 && p.y >= 0 && p.x + NODE_W <= layout.width && p.y + NODE_H <= layout.height,
|
||||||
|
`${what}: 节点 ${p.node.id} 必须整块落在画布内(不裁切)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
layout.bands.forEach((b) => {
|
||||||
|
ok(
|
||||||
|
b.x >= 0 && b.y >= 0 && b.x + b.width <= layout.width && b.y + b.height <= layout.height,
|
||||||
|
`${what}: 泳道框 ${b.id ?? "主干"} 必须整块落在画布内(不裁切)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** curve parses a cubic path back into its two anchors and two control points. */
|
||||||
|
function curve(d: string): {
|
||||||
|
sx: number;
|
||||||
|
sy: number;
|
||||||
|
c1x: number;
|
||||||
|
c1y: number;
|
||||||
|
c2x: number;
|
||||||
|
c2y: number;
|
||||||
|
tx: number;
|
||||||
|
ty: number;
|
||||||
|
} {
|
||||||
|
const [head, rest] = d.replace("M ", "").split(" C ");
|
||||||
|
const [sx, sy] = head.split(" ").map(Number);
|
||||||
|
const [c1, c2, tail] = rest.split(", ");
|
||||||
|
const [c1x, c1y] = c1.split(" ").map(Number);
|
||||||
|
const [c2x, c2y] = c2.split(" ").map(Number);
|
||||||
|
const [tx, ty] = tail.split(" ").map(Number);
|
||||||
|
return { sx, sy, c1x, c1y, c2x, c2y, tx, ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 时序方向:newest-top 最新在上;newest-left 最新在左 ──────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = chain();
|
||||||
|
const top = layoutFlow(g, "newest-top");
|
||||||
|
const left = layoutFlow(g, "newest-left");
|
||||||
|
same(top.orientation, "newest-top", "newest-top 布局回传自己的方向");
|
||||||
|
same(left.orientation, "newest-left", "newest-left 布局回传自己的方向");
|
||||||
|
|
||||||
|
// 两种方向画的是同一张图:节点集合/数量/顺序分毫不差(不丢节点、不造节点)。
|
||||||
|
same(
|
||||||
|
top.nodes.map((p) => p.node.id),
|
||||||
|
left.nodes.map((p) => p.node.id),
|
||||||
|
"方向不改变节点集合与顺序",
|
||||||
|
);
|
||||||
|
same(top.nodes.length, g.nodes.length, "方向不丢节点");
|
||||||
|
same(left.bands.length, top.bands.length, "方向不改变泳道/主干的分带");
|
||||||
|
|
||||||
|
const t = at(top);
|
||||||
|
same(
|
||||||
|
[t.get("input")!.y, t.get("llm1")!.y, t.get("tool1")!.y, t.get("output")!.y],
|
||||||
|
[PAD + 3 * (NODE_H + GAP_Y), PAD + 2 * (NODE_H + GAP_Y), PAD + (NODE_H + GAP_Y), PAD],
|
||||||
|
"newest-top:时序自下而上,最新的一拍贴画布上沿",
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
t.get("input")!.y > t.get("llm1")!.y &&
|
||||||
|
t.get("llm1")!.y > t.get("tool1")!.y &&
|
||||||
|
t.get("tool1")!.y > t.get("output")!.y,
|
||||||
|
"newest-top:越新越靠上(最新一定在最上方)",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[t.get("input")!.x, t.get("llm1")!.x, t.get("tool1")!.x, t.get("output")!.x],
|
||||||
|
[PAD, PAD, PAD, PAD],
|
||||||
|
"newest-top:单拍主干占一列(横向不铺开)",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[top.width, top.height],
|
||||||
|
[PAD * 2 + NODE_W, PAD * 2 + 4 * NODE_H + 3 * GAP_Y],
|
||||||
|
"newest-top:画布 = 单列宽 × 四拍高(容器纵向自动拓高)",
|
||||||
|
);
|
||||||
|
|
||||||
|
const l = at(left);
|
||||||
|
same(
|
||||||
|
[l.get("input")!.x, l.get("llm1")!.x, l.get("tool1")!.x, l.get("output")!.x],
|
||||||
|
[PAD + 3 * (NODE_W + GAP_X), PAD + 2 * (NODE_W + GAP_X), PAD + (NODE_W + GAP_X), PAD],
|
||||||
|
"newest-left:时序自右向左,最新的一拍贴画布左沿",
|
||||||
|
);
|
||||||
|
ok(
|
||||||
|
l.get("input")!.x > l.get("llm1")!.x &&
|
||||||
|
l.get("llm1")!.x > l.get("tool1")!.x &&
|
||||||
|
l.get("tool1")!.x > l.get("output")!.x,
|
||||||
|
"newest-left:越新越靠左(容器默认露出最左边 ⇒ 先看到最新)",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[l.get("input")!.y, l.get("llm1")!.y, l.get("tool1")!.y, l.get("output")!.y],
|
||||||
|
[PAD, PAD, PAD, PAD],
|
||||||
|
"newest-left:单拍主干占一行(保持横向渲染方式)",
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[left.width, left.height],
|
||||||
|
[PAD * 2 + 4 * NODE_W + 3 * GAP_X, PAD * 2 + NODE_H],
|
||||||
|
"newest-left:画布 = 四拍宽 × 单行高",
|
||||||
|
);
|
||||||
|
|
||||||
|
fits(top, "newest-top 主干");
|
||||||
|
fits(left, "newest-left 主干");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ② 容器随图生长:多一拍就多一段高度,且永远装得下 ─────────────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const four = layoutFlow(chain(), "newest-top");
|
||||||
|
const five = layoutFlow(chain(1), "newest-top");
|
||||||
|
same(
|
||||||
|
five.height - four.height,
|
||||||
|
NODE_H + GAP_Y,
|
||||||
|
"newest-top:多一拍 ⇒ 画布高一个「节点高 + 行距」,不挤在固定框里",
|
||||||
|
);
|
||||||
|
same(five.width, four.width, "newest-top:多一拍不改变横向尺寸(主干是一条竖列)");
|
||||||
|
fits(five, "newest-top 五拍");
|
||||||
|
fits(layoutFlow(chain(1), "newest-left"), "newest-left 五拍");
|
||||||
|
|
||||||
|
// 只开了头、还没有步骤的泳道:框也必须落在画布内(旧代码会把它画出画布右沿)。
|
||||||
|
fits(layoutFlow(idleLane(), "newest-top"), "newest-top 空泳道");
|
||||||
|
fits(layoutFlow(idleLane(), "newest-left"), "newest-left 空泳道");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ③ 连线正确性:逐边核对端点 id 与像素锚点(两个方向都核对) ───────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = withLane();
|
||||||
|
(["newest-top", "newest-left"] as const).forEach((orientation) => {
|
||||||
|
const layout = layoutFlow(g, orientation);
|
||||||
|
const placed = at(layout);
|
||||||
|
const shapes = edgeShapes(layout);
|
||||||
|
|
||||||
|
same(
|
||||||
|
shapes.map((s) => `${s.fromId}>${s.toId}`),
|
||||||
|
g.edges.map((e) => `${e.from}>${e.to}`),
|
||||||
|
`${orientation}:每条边都画出来,且顺序与图数据一致(不丢边、不重排)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
shapes.forEach((s) => {
|
||||||
|
const from = placed.get(s.fromId);
|
||||||
|
const to = placed.get(s.toId);
|
||||||
|
ok(from !== undefined && to !== undefined, `${orientation}:边 ${s.fromId}>${s.toId} 的端点都在图里`);
|
||||||
|
const c = curve(s.d);
|
||||||
|
same([c.sx, c.sy], [s.sx, s.sy], `${orientation}:path 起点 = 声明起点`);
|
||||||
|
if (orientation === "newest-top") {
|
||||||
|
// 更早那拍在上方节点的**下沿**,更新那拍从**下沿**进 —— 箭头指向时间前进的方向。
|
||||||
|
same(
|
||||||
|
[c.sx, c.sy],
|
||||||
|
[from!.x + NODE_W / 2, from!.y],
|
||||||
|
`${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的上边中点`,
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[c.tx, c.ty],
|
||||||
|
[to!.x + NODE_W / 2, to!.y + NODE_H],
|
||||||
|
`${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的下边中点`,
|
||||||
|
);
|
||||||
|
ok(c.c1y < c.sy && c.c2y > c.ty, `${orientation}:箭头自下而上,指向更新的节点`);
|
||||||
|
} else {
|
||||||
|
same(
|
||||||
|
[c.sx, c.sy],
|
||||||
|
[from!.x, from!.y + NODE_H / 2],
|
||||||
|
`${orientation}:边 ${s.fromId}>${s.toId} 起点贴在 ${s.fromId}(更早那拍)的左边中点`,
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
[c.tx, c.ty],
|
||||||
|
[to!.x + NODE_W, to!.y + NODE_H / 2],
|
||||||
|
`${orientation}:边 ${s.fromId}>${s.toId} 终点贴在 ${s.toId}(更新那拍)的右边中点`,
|
||||||
|
);
|
||||||
|
ok(c.c1x < c.sx && c.c2x > c.tx, `${orientation}:箭头自右向左,指向更新的节点`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 泳道内的边仍然标成泳道边(虚线 + accent),主干边仍是实线。
|
||||||
|
const byKey = new Map(shapes.map((s) => [`${s.fromId}>${s.toId}`, s.inLane]));
|
||||||
|
same(byKey.get("subA>llmA"), true, `${orientation}:泳道内步骤仍是泳道边`);
|
||||||
|
same(byKey.get("t1>output"), false, `${orientation}:主干续接仍是主干边`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 端点缺失的边如实丢弃:3 条边里 1 条悬空 ⇒ 只画 2 条(不伪造节点补上)。
|
||||||
|
const dangling = graph(
|
||||||
|
[node("a", "llm", "done"), node("b", "output", "done")],
|
||||||
|
[
|
||||||
|
{ from: "a", to: "ghost" },
|
||||||
|
{ from: "a", to: "b" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
same(
|
||||||
|
edgeShapes(layoutFlow(dangling, "newest-top")).map((s) => `${s.fromId}>${s.toId}`),
|
||||||
|
["a>b"],
|
||||||
|
"端点缺失的边被丢掉,不伪造",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ④ 泳道仍成道:泳道框罩住自己的成员,且落在主干更新的一侧 ─────────────────
|
||||||
|
|
||||||
|
{
|
||||||
|
const g = withLane();
|
||||||
|
const top = layoutFlow(g, "newest-top");
|
||||||
|
same(top.lanes.map((l) => l.id), ["subA"], "子代理各自成道");
|
||||||
|
const framed = top.bands.filter((b) => b.framed);
|
||||||
|
same(framed.length, 1, "泳道有独立的框");
|
||||||
|
const t = at(top);
|
||||||
|
const frame = framed[0];
|
||||||
|
ok(frame.x > t.get("input")!.x, "newest-top:泳道在主干右侧(时序轴之外并排),不挤占时序轴");
|
||||||
|
["llmA", "toolA"].forEach((id) => {
|
||||||
|
const p = t.get(id)!;
|
||||||
|
ok(
|
||||||
|
p.x >= frame.x && p.x + NODE_W <= frame.x + frame.width &&
|
||||||
|
p.y >= frame.y && p.y + NODE_H <= frame.y + frame.height,
|
||||||
|
`newest-top:${id} 落在自己的泳道框内(仍成道)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ok(t.get("llmA")!.y < t.get("subA")!.y, "newest-top:泳道第一步比泳道根更新(更靠上)");
|
||||||
|
ok(t.get("toolA")!.y < t.get("llmA")!.y, "newest-top:泳道内链路继续向新的一端推进");
|
||||||
|
fits(top, "newest-top 带泳道");
|
||||||
|
|
||||||
|
const left = layoutFlow(g, "newest-left");
|
||||||
|
const l = at(left);
|
||||||
|
const lane = left.bands.filter((b) => b.framed)[0];
|
||||||
|
ok(lane.y > l.get("input")!.y + NODE_H, "newest-left:泳道叠在主干下方,不与主干同拍混排");
|
||||||
|
["llmA", "toolA"].forEach((id) => {
|
||||||
|
const p = l.get(id)!;
|
||||||
|
ok(
|
||||||
|
p.x >= lane.x && p.x + NODE_W <= lane.x + lane.width &&
|
||||||
|
p.y >= lane.y && p.y + NODE_H <= lane.y + lane.height,
|
||||||
|
`newest-left:${id} 落在自己的泳道框内(仍成道)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ok(l.get("llmA")!.x < l.get("subA")!.x, "newest-left:泳道第一步比泳道根更新(更靠左)");
|
||||||
|
fits(left, "newest-left 带泳道");
|
||||||
|
|
||||||
|
// 没有两个节点落在同一个格子上(方向镜像不能让卡片重叠)。
|
||||||
|
([top, left] as const).forEach((layout) => {
|
||||||
|
const slots = new Set(layout.nodes.map((p) => `${p.x},${p.y}`));
|
||||||
|
same(slots.size, layout.nodes.length, "每个节点都有自己的格子(不重叠)");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`flow/orientation.test.ts OK — ${checks} checks`);
|
||||||
Reference in new issue
Block a user