feat(canvas): 云端 Agent 画布布局 - 新增 canvas_arrange_nodes 与新增节点自动落位
云端 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 字节)。
This commit is contained in:
1 parent
6f45240599
commit
56598108d4
16 files changed
+1894
-33
No files matched your search
@@ -0,0 +1,630 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/canvas/layout"
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 云端 Agent 的节点整理:模型只决定"整理哪些节点 / 怎么归类",几何一律由服务端算。
|
||||
// 一次整理的上限刻意保守:坐标批量改写比内容改写更容易让用户迷失,且审批卡要能一眼看完。
|
||||
const (
|
||||
cloudAgentArrangeMaxNodes = 50
|
||||
cloudAgentArrangeMaxGroups = 12
|
||||
cloudAgentArrangeMaxGap = 400
|
||||
cloudAgentArrangeCoordCap = 1e6
|
||||
)
|
||||
|
||||
type cloudAgentArrangeGroup struct {
|
||||
Label string `json:"label,omitempty"`
|
||||
NodeIDs []string `json:"nodeIds"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
}
|
||||
|
||||
type cloudAgentArrangeArgs struct {
|
||||
SnapshotHash string `json:"snapshotHash"`
|
||||
NodeIDs []string `json:"nodeIds,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Groups []cloudAgentArrangeGroup `json:"groups,omitempty"`
|
||||
Align string `json:"align,omitempty"`
|
||||
Gap float64 `json:"gap,omitempty"`
|
||||
DryRun bool `json:"dryRun,omitempty"`
|
||||
}
|
||||
|
||||
type cloudAgentArrangeMove struct {
|
||||
ID string
|
||||
Title string
|
||||
From layout.Position
|
||||
To layout.Position
|
||||
}
|
||||
|
||||
type cloudAgentArrangePlan struct {
|
||||
Args cloudAgentArrangeArgs
|
||||
Canvas *model.CanvasProject
|
||||
Document map[string]any
|
||||
Positions map[string]layout.Position
|
||||
Moves []cloudAgentArrangeMove
|
||||
Skipped []string
|
||||
BandLabels []string
|
||||
Preview cloudAgentApprovalPreview
|
||||
BeforeHash string
|
||||
Mode layout.Mode
|
||||
}
|
||||
|
||||
// cloudAgentLayoutLane 把节点类型映射到整理泳道(与前端 canvasLayoutLane 的语义一致:
|
||||
// 视频 / 音频 / 图片 / 文本,其余类型归文本泳道)。第二个返回值表示该类型是否参与整理。
|
||||
func cloudAgentLayoutLane(nodeType string) (string, bool) {
|
||||
switch nodeType {
|
||||
case "video":
|
||||
return "video", true
|
||||
case "audio":
|
||||
return "audio", true
|
||||
case "image":
|
||||
return "image", true
|
||||
case "text", "markdown", "script", "batch-table":
|
||||
return "text", true
|
||||
default:
|
||||
// 背板 / 文件夹是容器,插件节点也不参与几何整理。
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// cloudAgentLayoutNodes 把画布文档投影成排布输入。parentID 用于判断顶层节点。
|
||||
func cloudAgentLayoutNodes(doc map[string]any) []layout.Node {
|
||||
raw := creationMaps(doc["nodes"])
|
||||
nodes := make([]layout.Node, 0, len(raw))
|
||||
for _, node := range raw {
|
||||
id := stringValue(node["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
lane, supported := cloudAgentLayoutLane(stringValue(node["type"]))
|
||||
item := layout.Node{ID: id, Type: stringValue(node["type"]), Lane: lane, NoMove: !supported}
|
||||
if position, ok := node["position"].(map[string]any); ok {
|
||||
if value, ok := cloudAgentSafeNumber(position["x"]); ok {
|
||||
item.X, _ = value.(float64)
|
||||
}
|
||||
if value, ok := cloudAgentSafeNumber(position["y"]); ok {
|
||||
item.Y, _ = value.(float64)
|
||||
}
|
||||
}
|
||||
if value, ok := cloudAgentSafeNumber(node["width"]); ok {
|
||||
item.Width, _ = value.(float64)
|
||||
}
|
||||
if value, ok := cloudAgentSafeNumber(node["height"]); ok {
|
||||
item.Height, _ = value.(float64)
|
||||
}
|
||||
if meta, ok := node["metadata"].(map[string]any); ok {
|
||||
item.Locked = meta["locked"] == true
|
||||
}
|
||||
nodes = append(nodes, item)
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// cloudAgentLayoutEdges 只取两端都在画布上的引用连线,用于依赖方向分层。
|
||||
func cloudAgentLayoutEdges(doc map[string]any) []layout.Edge {
|
||||
edges := make([]layout.Edge, 0)
|
||||
for _, edge := range creationMaps(doc["connections"]) {
|
||||
from, to := stringValue(edge["fromNodeId"]), stringValue(edge["toNodeId"])
|
||||
if from == "" || to == "" {
|
||||
continue
|
||||
}
|
||||
edges = append(edges, layout.Edge{From: from, To: to})
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
// cloudAgentLayoutMovable 判断节点能否被 Agent 移动:锁定、容器节点、隐藏批次子节点、
|
||||
// 以及已经归属某个背板的子节点都不动——用户手工摆过的位置和容器层级不是 Agent 的编排对象。
|
||||
func cloudAgentLayoutMovable(node map[string]any) bool {
|
||||
meta, _ := node["metadata"].(map[string]any)
|
||||
if meta["locked"] == true {
|
||||
return false
|
||||
}
|
||||
if _, supported := cloudAgentLayoutLane(stringValue(node["type"])); !supported {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(stringValue(node["parentId"])) != "" {
|
||||
return false
|
||||
}
|
||||
if rootID := strings.TrimSpace(stringValue(meta["batchRootId"])); rootID != "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloudAgentLayoutNodesByID(doc map[string]any) map[string]map[string]any {
|
||||
index := map[string]map[string]any{}
|
||||
for _, node := range creationMaps(doc["nodes"]) {
|
||||
if id := stringValue(node["id"]); id != "" {
|
||||
index[id] = node
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func cloudAgentArrangeModeLabel(mode layout.Mode) string {
|
||||
switch mode {
|
||||
case layout.ModeFlow:
|
||||
return "按依赖分层"
|
||||
case layout.ModeByLane:
|
||||
return "按媒体类型分区"
|
||||
case layout.ModeRow:
|
||||
return "排成一行"
|
||||
case layout.ModeColumn:
|
||||
return "排成一列"
|
||||
case layout.ModeGrid:
|
||||
return "排成网格"
|
||||
default:
|
||||
return "自动整理"
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentArrangeAlignLabel(align layout.AlignMode) string {
|
||||
switch align {
|
||||
case layout.AlignLeft:
|
||||
return "左对齐"
|
||||
case layout.AlignCenterX:
|
||||
return "水平居中"
|
||||
case layout.AlignRight:
|
||||
return "右对齐"
|
||||
case layout.AlignTop:
|
||||
return "顶对齐"
|
||||
case layout.AlignCenterY:
|
||||
return "垂直居中"
|
||||
case layout.AlignBottom:
|
||||
return "底对齐"
|
||||
case layout.AlignDistributeX:
|
||||
return "水平等距"
|
||||
case layout.AlignDistributeY:
|
||||
return "垂直等距"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentArrangeMode(value string) (layout.Mode, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto":
|
||||
return layout.ModeAuto, nil
|
||||
case "flow":
|
||||
return layout.ModeFlow, nil
|
||||
case "bytype", "by_type", "lane", "lanes":
|
||||
return layout.ModeByLane, nil
|
||||
case "row":
|
||||
return layout.ModeRow, nil
|
||||
case "column":
|
||||
return layout.ModeColumn, nil
|
||||
case "grid":
|
||||
return layout.ModeGrid, nil
|
||||
default:
|
||||
return "", BadAuthRequest("整理模式无效:只能是 auto、flow、byType、row、column 或 grid")
|
||||
}
|
||||
}
|
||||
|
||||
func cloudAgentArrangeAlign(value string) (layout.AlignMode, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "left", "centerx", "right", "top", "centery", "bottom", "distributex", "distributey":
|
||||
return layout.AlignMode(strings.ToLower(strings.TrimSpace(value))), nil
|
||||
default:
|
||||
return "", BadAuthRequest("对齐模式无效:只能是 left、centerX、right、top、centerY、bottom、distributeX 或 distributeY")
|
||||
}
|
||||
}
|
||||
|
||||
// cloudAgentArrangeNodes 按 args 选出的节点集合。显式 nodeIds 会逐个校验:
|
||||
// 不存在、被锁定或属于容器/隐藏批次的节点会被跳过并在回执里说明,而不是让整轮失败。
|
||||
func cloudAgentArrangeScope(doc map[string]any, args cloudAgentArrangeArgs) ([]layout.Node, []string, error) {
|
||||
index := cloudAgentLayoutNodesByID(doc)
|
||||
all := cloudAgentLayoutNodes(doc)
|
||||
byID := map[string]layout.Node{}
|
||||
for _, node := range all {
|
||||
byID[node.ID] = node
|
||||
}
|
||||
|
||||
requested := args.NodeIDs
|
||||
if len(args.Groups) > 0 {
|
||||
requested = nil
|
||||
seen := map[string]bool{}
|
||||
for _, group := range args.Groups {
|
||||
for _, id := range group.NodeIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
requested = append(requested, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selected := make([]layout.Node, 0, len(requested))
|
||||
skipped := make([]string, 0)
|
||||
if len(requested) == 0 {
|
||||
for _, node := range all {
|
||||
if node.NoMove {
|
||||
continue
|
||||
}
|
||||
if !cloudAgentLayoutMovable(index[node.ID]) {
|
||||
continue
|
||||
}
|
||||
selected = append(selected, node)
|
||||
}
|
||||
return selected, skipped, nil
|
||||
}
|
||||
for _, id := range requested {
|
||||
node, ok := byID[id]
|
||||
if !ok {
|
||||
return nil, nil, BadAuthRequest(fmt.Sprintf("要整理的节点 %s 不存在,请重新读取画布", id))
|
||||
}
|
||||
if node.NoMove || !cloudAgentLayoutMovable(index[id]) {
|
||||
skipped = append(skipped, id)
|
||||
continue
|
||||
}
|
||||
selected = append(selected, node)
|
||||
}
|
||||
return selected, skipped, nil
|
||||
}
|
||||
|
||||
func cloudAgentArrangePositions(doc map[string]any, args cloudAgentArrangeArgs, selected []layout.Node) (map[string]layout.Position, []string, layout.Mode, error) {
|
||||
index := map[string]layout.Node{}
|
||||
for _, node := range selected {
|
||||
index[node.ID] = node
|
||||
}
|
||||
mode, err := cloudAgentArrangeMode(args.Mode)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
align, err := cloudAgentArrangeAlign(args.Align)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
edges := cloudAgentLayoutEdges(doc)
|
||||
|
||||
positions := map[string]layout.Position{}
|
||||
labels := make([]string, 0, len(args.Groups))
|
||||
if len(args.Groups) > 0 {
|
||||
bands := make([]layout.Band, 0, len(args.Groups))
|
||||
for _, group := range args.Groups {
|
||||
members := make([]layout.Node, 0, len(group.NodeIDs))
|
||||
for _, id := range group.NodeIDs {
|
||||
if node, ok := index[strings.TrimSpace(id)]; ok {
|
||||
members = append(members, node)
|
||||
}
|
||||
}
|
||||
if len(members) == 0 {
|
||||
continue
|
||||
}
|
||||
groupMode, err := cloudAgentArrangeMode(group.Mode)
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
if groupMode == layout.ModeAuto {
|
||||
groupMode = layout.ModeByLane
|
||||
}
|
||||
label := strings.TrimSpace(group.Label)
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("第 %d 组", len(bands)+1)
|
||||
}
|
||||
labels = append(labels, label)
|
||||
if align != "" {
|
||||
// 分组 + 对齐:先在带内对齐,再把整带交给 Bands 排位置。
|
||||
members = applyLayout(members, layout.Align(members, align))
|
||||
}
|
||||
bands = append(bands, layout.Band{Label: label, Nodes: members, Mode: groupMode})
|
||||
}
|
||||
if len(bands) == 0 {
|
||||
return nil, nil, "", BadAuthRequest("分组里没有可整理的节点")
|
||||
}
|
||||
positions = layout.Bands(bands, args.Gap)
|
||||
} else {
|
||||
switch mode {
|
||||
case layout.ModeAuto:
|
||||
positions = layout.Auto(selected, edges)
|
||||
case layout.ModeFlow:
|
||||
positions = layout.Flow(selected, edges)
|
||||
case layout.ModeByLane:
|
||||
positions = layout.ByLane(selected)
|
||||
default:
|
||||
positions = layout.Linear(selected, mode)
|
||||
}
|
||||
if align != "" {
|
||||
positions = mergeLayoutPositions(positions, layout.Align(applyLayout(selected, positions), align))
|
||||
}
|
||||
}
|
||||
// 坐标收敛:四舍五入到两位小数并夹到安全范围,避免浮点噪声进入画布文档。
|
||||
for id, position := range positions {
|
||||
positions[id] = layout.Position{X: clampCoord(position.X), Y: clampCoord(position.Y)}
|
||||
}
|
||||
return positions, labels, mode, nil
|
||||
}
|
||||
|
||||
func clampCoord(value float64) float64 {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return 0
|
||||
}
|
||||
value = math.Round(value*100) / 100
|
||||
if value > cloudAgentArrangeCoordCap {
|
||||
return cloudAgentArrangeCoordCap
|
||||
}
|
||||
if value < -cloudAgentArrangeCoordCap {
|
||||
return -cloudAgentArrangeCoordCap
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func applyLayout(nodes []layout.Node, positions map[string]layout.Position) []layout.Node {
|
||||
applied := make([]layout.Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if position, ok := positions[node.ID]; ok {
|
||||
node.X, node.Y = position.X, position.Y
|
||||
}
|
||||
applied = append(applied, node)
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
func mergeLayoutPositions(base, override map[string]layout.Position) map[string]layout.Position {
|
||||
merged := make(map[string]layout.Position, len(base))
|
||||
for id, position := range base {
|
||||
merged[id] = position
|
||||
}
|
||||
for id, position := range override {
|
||||
merged[id] = position
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// prepareCloudAgentArrangeNodes 复刻 canvas_apply_ops 的准备流程:校验快照、算出新坐标、生成审批预览。
|
||||
// 它不写库;真正落库在 applyCloudAgentArrangeNodes(或在审批通过后的同一条执行路径里)。
|
||||
func prepareCloudAgentArrangeNodes(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentArrangePlan, error) {
|
||||
var args cloudAgentArrangeArgs
|
||||
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
|
||||
return nil, cloudAgentJSONArgumentError(err)
|
||||
}
|
||||
if strings.TrimSpace(args.SnapshotHash) == "" {
|
||||
return nil, BadAuthRequest("整理节点需要先读取画布并传入 snapshotHash")
|
||||
}
|
||||
if len(args.Groups) > cloudAgentArrangeMaxGroups {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("一次最多分成 %d 组", cloudAgentArrangeMaxGroups))
|
||||
}
|
||||
if args.Gap < 0 || args.Gap > cloudAgentArrangeMaxGap {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("组间距必须在 0 到 %d 之间", cloudAgentArrangeMaxGap))
|
||||
}
|
||||
canvas, err := repo.CanvasProjectForUser(userID, canvasID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := creationDocument(canvas.PayloadJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
beforeHash := cloudAgentCanvasHash(doc)
|
||||
if beforeHash != strings.TrimSpace(args.SnapshotHash) {
|
||||
// 与 canvas_apply_ops 同一写法:过期快照是"可纠正的参数错误",运行期把它当工具结果
|
||||
// 交回模型重新读取,而不是走准入失败分支终止整轮。
|
||||
return nil, &cloudAgentFieldArgumentError{
|
||||
error: &cloudAgentArgumentError{creationConflict("画布已变化,本次未整理;请重新读取并重新申请审批")},
|
||||
Field: "snapshotHash", Issue: "stale_snapshot",
|
||||
}
|
||||
}
|
||||
selected, skipped, err := cloudAgentArrangeScope(doc, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(selected) < 2 {
|
||||
return nil, BadAuthRequest("可整理的节点少于两个;单个节点请直接用 update_node 指定坐标")
|
||||
}
|
||||
if len(selected) > cloudAgentArrangeMaxNodes {
|
||||
return nil, BadAuthRequest(fmt.Sprintf("一次最多整理 %d 个节点,请分批或用 nodeIds 指定范围", cloudAgentArrangeMaxNodes))
|
||||
}
|
||||
positions, labels, mode, err := cloudAgentArrangePositions(doc, args, selected)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
moves := make([]cloudAgentArrangeMove, 0, len(positions))
|
||||
for _, node := range selected {
|
||||
position, ok := positions[node.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if math.Abs(position.X-node.X) < 0.01 && math.Abs(position.Y-node.Y) < 0.01 {
|
||||
continue
|
||||
}
|
||||
moves = append(moves, cloudAgentArrangeMove{ID: node.ID, From: layout.Position{X: node.X, Y: node.Y}, To: position})
|
||||
}
|
||||
plan := &cloudAgentArrangePlan{
|
||||
Args: args, Canvas: canvas, Document: doc, Positions: positions,
|
||||
Skipped: skipped, BandLabels: labels, BeforeHash: beforeHash, Mode: mode,
|
||||
}
|
||||
if len(moves) == 0 {
|
||||
return plan, nil
|
||||
}
|
||||
titles := cloudAgentLayoutTitles(doc)
|
||||
for _, move := range moves {
|
||||
move.Title = titles[move.ID]
|
||||
plan.Moves = append(plan.Moves, move)
|
||||
}
|
||||
plan.Preview = cloudAgentArrangePreview(doc, plan)
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func cloudAgentLayoutTitles(doc map[string]any) map[string]string {
|
||||
titles := map[string]string{}
|
||||
for _, node := range creationMaps(doc["nodes"]) {
|
||||
id := stringValue(node["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(stringValue(node["title"]))
|
||||
if title == "" {
|
||||
if capability, ok := cloudAgentNodeCapabilityForType(stringValue(node["type"])); ok {
|
||||
title = capability.Label
|
||||
}
|
||||
}
|
||||
titles[id] = truncateRunes(title, 60)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func cloudAgentArrangePreview(doc map[string]any, plan *cloudAgentArrangePlan) cloudAgentApprovalPreview {
|
||||
modeLabel := cloudAgentArrangeModeLabel(plan.Mode)
|
||||
headline := fmt.Sprintf("整理 %d 个节点的位置(%s)", len(plan.Moves), modeLabel)
|
||||
if len(plan.BandLabels) > 0 {
|
||||
headline = fmt.Sprintf("把 %d 个节点归入 %d 组并整理位置", len(plan.Moves), len(plan.BandLabels))
|
||||
}
|
||||
if alignLabel := cloudAgentArrangeAlignLabel(layout.AlignMode(strings.ToLower(strings.TrimSpace(plan.Args.Align)))); alignLabel != "" {
|
||||
headline += "," + alignLabel
|
||||
}
|
||||
details := make([]string, 0, 6)
|
||||
if len(plan.BandLabels) > 0 {
|
||||
details = append(details, "分组:"+strings.Join(plan.BandLabels, " / "))
|
||||
}
|
||||
details = append(details, fmt.Sprintf("移动 %d 个节点(上限 %d)", len(plan.Moves), cloudAgentArrangeMaxNodes))
|
||||
for index, move := range plan.Moves {
|
||||
if index == 6 {
|
||||
details = append(details, fmt.Sprintf("等共 %d 个节点", len(plan.Moves)))
|
||||
break
|
||||
}
|
||||
details = append(details, fmt.Sprintf("%s → (%g, %g)", move.Title, move.To.X, move.To.Y))
|
||||
}
|
||||
if len(plan.Skipped) > 0 {
|
||||
details = append(details, fmt.Sprintf("%d 个锁定/容器/隐藏节点保持不动", len(plan.Skipped)))
|
||||
}
|
||||
return cloudAgentApprovalPreview{
|
||||
Kind: "arrange_nodes",
|
||||
Title: "整理节点位置",
|
||||
Description: headline + ";只调整坐标,不改内容、不建连线、不改层级。",
|
||||
Items: []cloudAgentApprovalPreviewItem{{
|
||||
Operation: "arrange_nodes",
|
||||
Summary: headline,
|
||||
Details: details,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// applyCloudAgentArrangeNodes 落库并记录一次可撤销的 mutation。
|
||||
func applyCloudAgentArrangeNodes(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) {
|
||||
plan, err := prepareCloudAgentArrangeNodes(repo, userID, canvasID, call)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(plan.Moves) == 0 {
|
||||
return map[string]any{
|
||||
"canvasId": canvasID, "snapshotHash": plan.BeforeHash, "moved": 0,
|
||||
"summary": "节点位置已经是整理后的结果,本次没有改动",
|
||||
}, nil
|
||||
}
|
||||
if plan.Args.DryRun {
|
||||
ids := make([]string, 0, len(plan.Moves))
|
||||
for _, move := range plan.Moves {
|
||||
ids = append(ids, move.ID)
|
||||
}
|
||||
return map[string]any{
|
||||
"canvasId": canvasID, "snapshotHash": plan.BeforeHash, "moved": 0, "wouldMove": len(ids),
|
||||
"nodeIds": ids, "summary": fmt.Sprintf("预演:将移动 %d 个节点,未写入画布", len(ids)),
|
||||
}, nil
|
||||
}
|
||||
before := plan.Canvas.PayloadJSON
|
||||
if err := cloudAgentApplyArrangePositions(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(recorder) > 0 && recorder[0] != nil {
|
||||
if err := recorder[0](repo, cloudAgentMutationInput{
|
||||
UserID: userID, CanvasID: canvasID, StepID: call.ID, Operation: "canvas_arrange_nodes",
|
||||
BeforeSnapshotHash: plan.BeforeHash, AfterSnapshotHash: cloudAgentCanvasHash(plan.Document),
|
||||
BeforeJSON: before, Preview: &plan.Preview,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(plan.Moves))
|
||||
for _, move := range plan.Moves {
|
||||
ids = append(ids, move.ID)
|
||||
}
|
||||
return map[string]any{
|
||||
"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document),
|
||||
"beforeSnapshotHash": plan.BeforeHash, "moved": len(plan.Moves), "nodeIds": ids,
|
||||
"groups": plan.BandLabels, "skipped": plan.Skipped,
|
||||
"summary": fmt.Sprintf("已整理 %d 个节点的位置(%s)", len(plan.Moves), cloudAgentArrangeModeLabel(plan.Mode)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cloudAgentApplyArrangePositions(plan *cloudAgentArrangePlan) error {
|
||||
nodes := creationMaps(plan.Document["nodes"])
|
||||
for index, node := range nodes {
|
||||
position, ok := plan.Positions[stringValue(node["id"])]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
current, _ := node["position"].(map[string]any)
|
||||
if current == nil {
|
||||
current = map[string]any{}
|
||||
}
|
||||
current["x"] = position.X
|
||||
current["y"] = position.Y
|
||||
node["position"] = current
|
||||
nodes[index] = node
|
||||
}
|
||||
plan.Document["nodes"] = nodes
|
||||
return nil
|
||||
}
|
||||
|
||||
// cloudAgentArrangeAddNodePosition 给"没给坐标"的新节点找一个有序的落位:
|
||||
// 优先贴着本批建立连线的对端(生成输入在左、输出在右),否则按泳道追加到同类节点之后。
|
||||
func cloudAgentArrangeAddNodePosition(doc map[string]any, pending []layout.Node, op agentCanvasOp, ops []agentCanvasOp, hint *layout.Position) (layout.Position, bool) {
|
||||
lane, supported := cloudAgentLayoutLane(op.NodeType)
|
||||
if !supported {
|
||||
return layout.Position{}, false
|
||||
}
|
||||
width, height := cloudAgentAddedNodeSize(op.NodeType)
|
||||
occupied := append([]layout.Node{}, cloudAgentLayoutNodes(doc)...)
|
||||
occupied = append(occupied, pending...)
|
||||
|
||||
preferred := hint
|
||||
for _, candidate := range ops {
|
||||
if candidate.Type != "connect_nodes" {
|
||||
continue
|
||||
}
|
||||
if preferred != nil {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case candidate.ToNodeID == op.ID:
|
||||
if source, ok := findLayoutNode(occupied, candidate.FromNodeID); ok {
|
||||
preferred = &layout.Position{X: source.X + source.Width + layout.PlacementGap, Y: source.Y}
|
||||
}
|
||||
case candidate.FromNodeID == op.ID:
|
||||
if target, ok := findLayoutNode(occupied, candidate.ToNodeID); ok {
|
||||
preferred = &layout.Position{X: target.X - width - layout.PlacementGap, Y: target.Y}
|
||||
}
|
||||
}
|
||||
}
|
||||
return layout.FreeSlot(width, height, lane, occupied, preferred, layout.PlacementGap), true
|
||||
}
|
||||
|
||||
func findLayoutNode(nodes []layout.Node, id string) (layout.Node, bool) {
|
||||
for _, node := range nodes {
|
||||
if node.ID == id {
|
||||
return node, true
|
||||
}
|
||||
}
|
||||
return layout.Node{}, false
|
||||
}
|
||||
|
||||
func cloudAgentAddedNodeSize(nodeType string) (float64, float64) {
|
||||
if capability, ok := cloudAgentNodeCapabilityForType(nodeType); ok {
|
||||
return capability.DefaultWidth, capability.DefaultHeight
|
||||
}
|
||||
return 340, 240
|
||||
}
|
||||
Reference in new issue
Block a user