云端 Agent 此前只能新建节点与连线,既不会排版也不能移动已有节点:模型只能用几十个
update_node 手算坐标,或者让用户自己拖。
- 新增 backend/internal/canvas/layout:把前端既有的画布布局算法(web/src/lib/canvas/
canvas-layout.ts,本 PR 不改该文件)按同一套常量移植到服务端,提供 Kahn 拓扑分层 flow、
媒体泳道 byType、row/column/grid、对齐与等距、分组分带、AABB 重叠检测与空位搜索。
- 新增写工具 canvas_arrange_nodes:snapshotHash 必需,mode∈{auto,flow,byType,row,column,
grid},可传 nodeIds、groups(只做横向位置分带,不建容器、不改层级)、align、gap、dryRun。
只改坐标,不改内容、不建连线、不增删节点;一次最多 50 个节点 / 12 组。锁定节点、背板等
容器、隐藏批次子节点与已归属背板的子节点保持不动并在回执里说明;整批复用现有 mutation
记录与事件管线,审批与撤销走同一条路径。
- add_node 省略坐标时不再落到 (0,0):优先贴合本批连线的对端(输入在左、输出在右),否则
追加到同泳道同类节点之后,冲突则扫描空位;只给一个轴时保留该轴。agentCanvasOp.X/Y 改为
指针以区分"没给坐标"与"坐标 0"。
- update_node 的 patch 新增 x/y(PatchField.Limit 校验 ±1e6),供模型微调单个节点。
- 系统行为策略新增「布局与整理」一节并升版到 8;docs 补工具说明与工具全集;前端同步审批
预览联合类型、面板文案与徽标。
- 工具 schema 预算按实测从 24000 上调到 25000(21 个工具 24788 字节),并改正注释里
"上游约 23.4 KB"的说法(上游 20 个工具实测 22777 字节)。
171 lines
6.1 KiB
Go
171 lines
6.1 KiB
Go
package layout
|
||
|
||
import (
|
||
"math"
|
||
"testing"
|
||
)
|
||
|
||
func node(id, lane string, x, y, w, h float64) Node {
|
||
return Node{ID: id, Lane: lane, X: x, Y: y, Width: w, Height: h}
|
||
}
|
||
|
||
func assertPosition(t *testing.T, positions map[string]Position, id string, x, y float64) {
|
||
t.Helper()
|
||
position, ok := positions[id]
|
||
if !ok {
|
||
t.Fatalf("缺少节点 %s 的位置:%+v", id, positions)
|
||
}
|
||
if math.Abs(position.X-x) > 0.001 || math.Abs(position.Y-y) > 0.001 {
|
||
t.Fatalf("节点 %s 位置 = (%v,%v),期望 (%v,%v)", id, position.X, position.Y, x, y)
|
||
}
|
||
}
|
||
|
||
// 与前端 layoutCanvasAuto 的媒体泳道分支对齐:text / image / video / audio 从上到下分区。
|
||
func TestByLaneKeepsLaneOrderAndAvoidsOverlap(t *testing.T) {
|
||
nodes := []Node{
|
||
node("text-1", "text", 0, 0, 340, 240),
|
||
node("image-1", "image", 900, 500, 720, 405),
|
||
node("video-1", "video", 900, 1200, 720, 405),
|
||
node("audio-1", "audio", 900, 1800, 340, 120),
|
||
}
|
||
positions := ByLane(nodes)
|
||
|
||
assertPosition(t, positions, "text-1", 0, 0)
|
||
// text 泳道一行高 240,之后是本泳道与下一泳道之间的 LaneGap
|
||
assertPosition(t, positions, "image-1", 0, 240+LaneGap)
|
||
// image 泳道一行高 405,其后同样是 LaneGap
|
||
imageBottom := 240.0 + LaneGap + 405
|
||
assertPosition(t, positions, "video-1", 0, imageBottom+LaneGap)
|
||
assertPosition(t, positions, "audio-1", 0, imageBottom+LaneGap+405+LaneGap)
|
||
|
||
if pairs := Overlaps(withPositions(nodes, positions), 0); len(pairs) != 0 {
|
||
t.Fatalf("泳道排布出现重叠:%+v", pairs)
|
||
}
|
||
}
|
||
|
||
// 与前端 layoutCanvasFlow 对齐:依赖方向从左到右,层内同泳道竖向堆叠。
|
||
func TestFlowLaysOutDependenciesLeftToRight(t *testing.T) {
|
||
nodes := []Node{
|
||
node("text-1", "text", 0, 0, 340, 240),
|
||
node("image-1", "image", 0, 400, 720, 405),
|
||
node("video-1", "video", 0, 900, 720, 405),
|
||
}
|
||
edges := []Edge{{From: "text-1", To: "video-1"}, {From: "image-1", To: "video-1"}}
|
||
positions := Flow(nodes, edges)
|
||
|
||
textX := positions["text-1"].X
|
||
imageX := positions["image-1"].X
|
||
videoX := positions["video-1"].X
|
||
if textX != imageX {
|
||
t.Fatalf("同层起点应一致:text=%v image=%v", textX, imageX)
|
||
}
|
||
wantVideoX := textX + 720 + FlowColumnGap
|
||
if math.Abs(videoX-wantVideoX) > 0.001 {
|
||
t.Fatalf("下一层 x = %v,期望 %v", videoX, wantVideoX)
|
||
}
|
||
// 没有入边的自环也要落在第一层,不能死循环
|
||
cyclic := Flow(nodes, append(edges, Edge{From: "video-1", To: "text-1"}))
|
||
if _, ok := cyclic["text-1"]; !ok {
|
||
t.Fatal("含环时第一层节点丢失")
|
||
}
|
||
}
|
||
|
||
func TestAutoFallsBackToLanesWithoutConnections(t *testing.T) {
|
||
nodes := []Node{node("image-1", "image", 0, 0, 720, 405), node("image-2", "image", 800, 0, 720, 405)}
|
||
edges := []Edge{{From: "image-1", To: "missing"}} // 集合外的连线不算
|
||
|
||
positions := Auto(nodes, edges)
|
||
if positions["image-1"].Y != positions["image-2"].Y {
|
||
t.Fatalf("无有效连线时应按泳道横排:%+v", positions)
|
||
}
|
||
|
||
connected := Auto(nodes, []Edge{{From: "image-1", To: "image-2"}})
|
||
if connected["image-1"].X == connected["image-2"].X {
|
||
t.Fatalf("有连线时应分层左右排开:%+v", connected)
|
||
}
|
||
}
|
||
|
||
// Bands 是"只做位置分带"的归类:每组一条带,带间留 gap,组内不重叠。
|
||
func TestBandsSeparatesGroups(t *testing.T) {
|
||
bands := []Band{
|
||
{Label: "A", Mode: ModeByLane, Nodes: []Node{node("a1", "image", 0, 0, 720, 405), node("a2", "video", 800, 0, 720, 405)}},
|
||
{Label: "B", Mode: ModeByLane, Nodes: []Node{node("b1", "image", 0, 3000, 720, 405)}},
|
||
}
|
||
positions := Bands(bands, 120)
|
||
// 带内按泳道排:image 泳道在上,video 泳道在其下方
|
||
if positions["a1"].Y != 0 {
|
||
t.Fatalf("第一条带应从顶部开始:%+v", positions["a1"])
|
||
}
|
||
if positions["a2"].Y <= positions["a1"].Y {
|
||
t.Fatalf("带内不同泳道应上下分开:a1=%v a2=%v", positions["a1"], positions["a2"])
|
||
}
|
||
// 第二整条带排在第一条带之下,且所有带左对齐
|
||
if positions["b1"].Y <= positions["a2"].Y+405 {
|
||
t.Fatalf("第二带应排在第一带下方:a2=%v b1=%v", positions["a2"], positions["b1"])
|
||
}
|
||
if positions["b1"].X != positions["a1"].X {
|
||
t.Fatalf("各带应左对齐:a1=%v b1=%v", positions["a1"], positions["b1"])
|
||
}
|
||
applied := append([]Node{}, bands[0].Nodes...)
|
||
applied = append(applied, bands[1].Nodes...)
|
||
if pairs := Overlaps(withPositions(applied, positions), 0); len(pairs) != 0 {
|
||
t.Fatalf("分带后出现重叠:%+v", pairs)
|
||
}
|
||
}
|
||
|
||
func TestAlignDistributesAndCenters(t *testing.T) {
|
||
nodes := []Node{node("a", "text", 0, 0, 100, 100), node("b", "text", 300, 400, 100, 100), node("c", "text", 900, 900, 100, 100)}
|
||
left := Align(nodes, AlignLeft)
|
||
for _, id := range []string{"a", "b", "c"} {
|
||
if left[id].X != 0 {
|
||
t.Fatalf("%s 未左对齐:%+v", id, left[id])
|
||
}
|
||
}
|
||
distributed := Align(nodes, AlignDistributeY)
|
||
gaps := []float64{
|
||
distributed["b"].Y - (distributed["a"].Y + 100),
|
||
distributed["c"].Y - (distributed["b"].Y + 100),
|
||
}
|
||
if math.Abs(gaps[0]-gaps[1]) > 0.001 {
|
||
t.Fatalf("纵向等距分布不均匀:%v", gaps)
|
||
}
|
||
}
|
||
|
||
func TestFreeSlotAvoidsExistingNodes(t *testing.T) {
|
||
occupied := []Node{node("image-1", "image", 0, 0, 720, 405)}
|
||
|
||
// 优选坐标被占用时让位
|
||
preferred := Position{X: 0, Y: 0}
|
||
slot := FreeSlot(720, 405, "image", occupied, &preferred, 40)
|
||
if slot.X == 0 && slot.Y == 0 {
|
||
t.Fatalf("优选坐标被占用时未让位:%+v", slot)
|
||
}
|
||
candidate := Node{ID: "new", Lane: "image", X: slot.X, Y: slot.Y, Width: 720, Height: 405}
|
||
if pairs := Overlaps(append(occupied, candidate), 0); len(pairs) != 0 {
|
||
t.Fatalf("落位后仍重叠:%+v slot=%+v", pairs, slot)
|
||
}
|
||
|
||
// 空闲的优选坐标直接采用
|
||
free := Position{X: 2000, Y: 0}
|
||
if got := FreeSlot(720, 405, "image", occupied, &free, 40); got != free {
|
||
t.Fatalf("空闲优选坐标未被采用:%+v", got)
|
||
}
|
||
|
||
// 没有任何节点时落在原点附近
|
||
empty := FreeSlot(340, 240, "text", nil, nil, 40)
|
||
if empty.X != 0 || empty.Y != 0 {
|
||
t.Fatalf("空画布落位 = %+v", empty)
|
||
}
|
||
}
|
||
|
||
func withPositions(nodes []Node, positions map[string]Position) []Node {
|
||
applied := make([]Node, 0, len(nodes))
|
||
for _, item := range nodes {
|
||
if position, ok := positions[item.ID]; ok {
|
||
item.X, item.Y = position.X, position.Y
|
||
}
|
||
applied = append(applied, item)
|
||
}
|
||
return applied
|
||
}
|