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 "", cloudAgentFieldError("mode", "invalid_value", "整理模式无效:只能是 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 "", cloudAgentFieldError("align", "invalid_value", "对齐模式无效:只能是 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, cloudAgentFieldError("nodeIds", "invalid_value", 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, "", cloudAgentFieldError("groups", "invalid_value", "分组里没有可整理的节点") } 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, cloudAgentFieldError("snapshotHash", "required", "整理节点需要先读取画布并传入 snapshotHash") } if len(args.Groups) > cloudAgentArrangeMaxGroups { return nil, cloudAgentFieldError("groups", "item_count", fmt.Sprintf("一次最多分成 %d 组", cloudAgentArrangeMaxGroups)) } if args.Gap < 0 || args.Gap > cloudAgentArrangeMaxGap { return nil, cloudAgentFieldError("gap", "invalid_value", 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, cloudAgentFieldError("nodeIds", "invalid_value", "可整理的节点少于两个;单个节点请直接用 update_node 指定坐标") } if len(selected) > cloudAgentArrangeMaxNodes { return nil, cloudAgentFieldError("nodeIds", "item_count", 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 }