mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:12:00 +00:00
75be5f485e
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。
后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。
前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。
顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。
新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。
285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
181 lines
6.8 KiB
Python
181 lines
6.8 KiB
Python
"""自动排版。
|
||
|
||
以前只有"按最长路径分层、同层按 id 字典序竖排"两条规则:并行的分支谁上谁下
|
||
是随机的(`out_no` 排在 `out_yes` 上面,因为 "n" < "y"),层间距写死 300,
|
||
两个节点扇出到同一个目标时连线还会叠在一起。这些测试守的是重写之后的四条
|
||
不变量:并行同层、分支按语义顺序、同列不重叠、走廊按车道需求留宽。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from app.engine.layout import NODE_W, auto_layout
|
||
from app.engine.schema import GraphSpec
|
||
|
||
|
||
def _graph(nodes, edges=()):
|
||
return GraphSpec.model_validate({
|
||
"nodes": [
|
||
{"id": n["id"], "type": n["type"], "position": {"x": 0, "y": 0},
|
||
"data": {"label": n.get("label", ""), "config": n.get("config", {})}}
|
||
for n in nodes
|
||
],
|
||
"edges": [
|
||
{"source": e[0], "target": e[1], **({"sourceHandle": e[2]} if len(e) > 2 else {})}
|
||
for e in edges
|
||
],
|
||
})
|
||
|
||
|
||
def _parallel_graph():
|
||
"""一个入口扇出三条并行的 agent,再汇进同一个节点。"""
|
||
return _graph(
|
||
[
|
||
{"id": "in", "type": "input"},
|
||
{"id": "a1", "type": "agent"},
|
||
{"id": "a2", "type": "agent"},
|
||
{"id": "a3", "type": "agent"},
|
||
{"id": "join", "type": "llm"},
|
||
],
|
||
[("in", "a1"), ("in", "a2"), ("in", "a3"), ("a1", "join"), ("a2", "join"), ("a3", "join")],
|
||
)
|
||
|
||
|
||
def test_parallel_branches_share_a_column() -> None:
|
||
"""并行的三条支路必须落在同一列:它们之间没有依赖,谁也不该排到谁后面。"""
|
||
spec = auto_layout(_parallel_graph())
|
||
xs = {n.id: n.position.x for n in spec.nodes}
|
||
assert xs["a1"] == xs["a2"] == xs["a3"]
|
||
assert xs["in"] < xs["a1"] < xs["join"]
|
||
|
||
|
||
def test_parallel_branches_are_stacked_without_overlap() -> None:
|
||
spec = auto_layout(_parallel_graph())
|
||
ys = sorted(n.position.y for n in spec.nodes if n.id.startswith("a"))
|
||
assert len(set(ys)) == 3
|
||
# 同列相邻节点的纵向间距要够一个节点高度,否则卡片会叠在一起
|
||
assert ys[1] - ys[0] >= 60
|
||
assert ys[2] - ys[1] >= 60
|
||
|
||
|
||
def test_branch_cases_keep_their_declared_order() -> None:
|
||
"""分支的 yes/no 是语义顺序,不能按 id 字典序排("no" < "yes" 会反过来)。"""
|
||
spec = _graph(
|
||
[
|
||
{"id": "gate", "type": "branch",
|
||
"config": {"cases": [{"key": "yes"}, {"key": "no"}]}},
|
||
{"id": "out_no", "type": "output"},
|
||
{"id": "out_yes", "type": "output"},
|
||
],
|
||
[("gate", "out_yes", "yes"), ("gate", "out_no", "no")],
|
||
)
|
||
laid = auto_layout(spec)
|
||
pos = {n.id: n.position for n in laid.nodes}
|
||
assert pos["out_yes"].y < pos["out_no"].y
|
||
assert pos["out_yes"].x == pos["out_no"].x
|
||
|
||
|
||
def test_loop_body_stays_right_of_the_loop_node() -> None:
|
||
"""回边不能被当成普通边参与分层——那样循环体会跑到循环节点左边去。"""
|
||
spec = _graph(
|
||
[
|
||
{"id": "start", "type": "input"},
|
||
{"id": "each", "type": "loop"},
|
||
{"id": "body", "type": "llm"},
|
||
{"id": "collect", "type": "transform"},
|
||
{"id": "done", "type": "output"},
|
||
],
|
||
[
|
||
("start", "each"),
|
||
("each", "body", "body"),
|
||
("body", "collect"),
|
||
("collect", "each"),
|
||
("each", "done", "done"),
|
||
],
|
||
)
|
||
laid = auto_layout(spec)
|
||
pos = {n.id: n.position for n in laid.nodes}
|
||
assert pos["start"].x < pos["each"].x < pos["body"].x < pos["collect"].x
|
||
# 循环体出口排在结束出口上面
|
||
assert pos["body"].y < pos["done"].y
|
||
|
||
|
||
def test_corridor_grows_with_lane_demand() -> None:
|
||
"""扇出的线越多,走廊越宽——宽度不够,前端的车道就铺不开,线会叠回去。"""
|
||
narrow = auto_layout(_parallel_graph())
|
||
wide = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}, {"id": "join", "type": "llm"}]
|
||
+ [{"id": f"m{i}", "type": "llm"} for i in range(8)],
|
||
[("in", f"m{i}") for i in range(8)] + [(f"m{i}", "join") for i in range(8)],
|
||
))
|
||
|
||
def corridor(spec) -> float:
|
||
xs = sorted({n.position.x for n in spec.nodes})
|
||
return min(b - a for a, b in zip(xs, xs[1:])) - NODE_W
|
||
|
||
assert corridor(wide) > corridor(narrow)
|
||
|
||
|
||
def test_long_edges_do_not_widen_a_single_corridor_forever() -> None:
|
||
"""走廊有上限。20 路扇出也不该把图撑到屏幕外面去。"""
|
||
spec = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}]
|
||
+ [{"id": f"m{i}", "type": "llm"} for i in range(20)],
|
||
[("in", f"m{i}") for i in range(20)],
|
||
))
|
||
xs = sorted({n.position.x for n in spec.nodes})
|
||
assert xs[1] - xs[0] <= NODE_W + 460
|
||
|
||
|
||
def test_same_column_never_overlaps() -> None:
|
||
"""一整列节点两两之间必须留出至少一个卡片的高度。"""
|
||
spec = auto_layout(_graph(
|
||
[{"id": "in", "type": "input"}] + [{"id": f"n{i}", "type": "llm"} for i in range(12)],
|
||
[("in", f"n{i}") for i in range(12)],
|
||
))
|
||
ys = sorted(n.position.y for n in spec.nodes if n.id != "in")
|
||
gaps = [b - a for a, b in zip(ys, ys[1:])]
|
||
assert gaps and min(gaps) >= 60
|
||
|
||
|
||
def test_layout_is_stable_and_idempotent() -> None:
|
||
"""同一张图排两次必须得到同一份坐标,否则保存后一刷新就跳。"""
|
||
first = auto_layout(_parallel_graph()).model_dump(mode="json")
|
||
second = auto_layout(GraphSpec.model_validate(first)).model_dump(mode="json")
|
||
assert [(n["id"], n["position"]) for n in first["nodes"]] == \
|
||
[(n["id"], n["position"]) for n in second["nodes"]]
|
||
|
||
|
||
def test_layout_does_not_touch_the_graph_itself() -> None:
|
||
"""排版只改坐标:节点、边、配置一个都不能丢,也不能多出来。"""
|
||
before = _parallel_graph()
|
||
ids = [(n.id, str(n.type)) for n in before.nodes]
|
||
edges = [(e.source, e.target, e.sourceHandle or "") for e in before.edges]
|
||
after = auto_layout(before)
|
||
assert [(n.id, str(n.type)) for n in after.nodes] == ids
|
||
assert [(e.source, e.target, e.sourceHandle or "") for e in after.edges] == edges
|
||
|
||
|
||
def test_cycle_does_not_hang_or_collapse() -> None:
|
||
"""没有任何入口的纯环图也得排完,不能死循环。"""
|
||
spec = _graph(
|
||
[{"id": "x", "type": "llm"}, {"id": "y", "type": "llm"}],
|
||
[("x", "y"), ("y", "x")],
|
||
)
|
||
laid = auto_layout(spec)
|
||
assert {n.id for n in laid.nodes} == {"x", "y"}
|
||
|
||
|
||
def test_dangling_edges_and_empty_graph_are_survivable() -> None:
|
||
assert auto_layout(GraphSpec()).nodes == []
|
||
spec = auto_layout(_graph(
|
||
[{"id": "a", "type": "llm"}],
|
||
[("a", "ghost"), ("ghost", "a")],
|
||
))
|
||
assert spec.nodes[0].position.x >= 0
|
||
|
||
|
||
def test_self_loop_is_kept() -> None:
|
||
spec = auto_layout(_graph([{"id": "a", "type": "llm"}], [("a", "a")]))
|
||
assert spec.nodes[0].position.x >= 0
|
||
assert len(spec.edges) == 1
|