主人要求:① 全链路流程页从下往上渲染(时序自下而上 ⇒ 最新一定在最上)+ 容器高度自动拓高;② 主动思考页保持原渲染方式但最新放最左(容器默认露最左);③ 追加:改布局后多路渲染仍要合适。 - 共享布局唯一入口 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 期望随新方向镜像更新,泳道仍成道;并行路数据互相覆盖的已知问题归后续分桶切片
338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
// 方向(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`);
|