diff --git a/backend/internal/app/cloud_agent_approval_preview.go b/backend/internal/app/cloud_agent_approval_preview.go index 57bcd3163..870d3659c 100644 --- a/backend/internal/app/cloud_agent_approval_preview.go +++ b/backend/internal/app/cloud_agent_approval_preview.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "infinite-canvas/backend/internal/canvas/capability" + "infinite-canvas/backend/internal/canvas/layout" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" ) @@ -113,7 +114,33 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud if !ok { return nil, BadAuthRequest("不支持的节点类型") } - node := creationAddedNode(CreationCanvasOp{Type: op.Type, ID: op.ID, NodeType: op.NodeType, Title: title, X: &op.X, Y: &op.Y, Metadata: capability.Metadata(content)}) + x, y := op.X, op.Y + if x == nil || y == nil { + // 没有(完整)坐标时不落到原点:按泳道与依赖关系算一个空位, + // 保证同一批新增的多个节点也不会互相重叠。模型只给了一个轴时保留它。 + pending := cloudAgentLayoutNodes(map[string]any{"nodes": nodes}) + var hint *layout.Position + if x != nil || y != nil { + hint = &layout.Position{} + if x != nil { + hint.X = *x + } + if y != nil { + hint.Y = *y + } + } + if slot, ok := cloudAgentArrangeAddNodePosition(doc, pending, op, ops, hint); ok { + if x == nil { + value := slot.X + x = &value + } + if y == nil { + value := slot.Y + y = &value + } + } + } + node := creationAddedNode(CreationCanvasOp{Type: op.Type, ID: op.ID, NodeType: op.NodeType, Title: title, X: x, Y: y, Metadata: capability.Metadata(content)}) nodes = append(nodes, node) nodeTitle := cloudAgentApprovalNodeTitle(node, capability.Label) items = append(items, cloudAgentApprovalPreviewItem{ diff --git a/backend/internal/app/cloud_agent_canvas_events.go b/backend/internal/app/cloud_agent_canvas_events.go index 1458c4ab3..7b5b4e267 100644 --- a/backend/internal/app/cloud_agent_canvas_events.go +++ b/backend/internal/app/cloud_agent_canvas_events.go @@ -26,12 +26,65 @@ func cloudAgentObjectChanges(before, after any) []map[string]any { for _, item := range creationMaps(after) { old := previous[stringValue(item["id"])] if !reflect.DeepEqual(old, item) { - changes = append(changes, map[string]any{"before": old, "after": item}) + oldFields, newFields := cloudAgentShrinkChange(old, item) + changes = append(changes, map[string]any{"before": oldFields, "after": newFields}) } } return changes } +// cloudAgentGeometryFields 是"只改位置/尺寸"的字段集合。 +var cloudAgentGeometryFields = []string{"position", "width", "height", "zIndex"} + +// cloudAgentShrinkChange 把"只动了几何"的节点变更缩成几何增量。 +// +// 画布增量在前端是**三方合并**(按字段合并,未出现的字段保持本地值),所以只发发生变化的 +// 几何字段既安全又必要:一次整理 50 个节点时,若每个节点都带完整正文(分镜行、Markdown +// 正文可达十几 KB),单条 canvas_updated 事件就会超过 validateCloudAgentRuntime 的 +// 单事件 128KiB 上限("Agent runtime event payload is too large"),整轮直接终止。 +// 只有"除几何外完全一致"时才缩:带内容/metadata 变化的节点仍发完整 before/after, +// 以免影响前端对生成任务状态的保护逻辑。 +func cloudAgentShrinkChange(before, after map[string]any) (map[string]any, map[string]any) { + if before == nil || after == nil { + return before, after + } + for key, value := range after { + if cloudAgentContainsString(cloudAgentGeometryFields, key) { + continue + } + if !reflect.DeepEqual(before[key], value) { + return before, after + } + } + for key, value := range before { + if cloudAgentContainsString(cloudAgentGeometryFields, key) { + continue + } + if _, exists := after[key]; !exists && value != nil { + return before, after + } + } + shrunkBefore := map[string]any{"id": after["id"]} + shrunkAfter := map[string]any{"id": after["id"]} + for _, key := range cloudAgentGeometryFields { + final, hasFinal := after[key] + initial, hasInitial := before[key] + if !hasFinal && !hasInitial { + continue + } + if reflect.DeepEqual(initial, final) { + continue + } + if hasInitial { + shrunkBefore[key] = initial + } + if hasFinal { + shrunkAfter[key] = final + } + } + return shrunkBefore, shrunkAfter +} + func emitCloudAgentCanvasChange(repo *repository.Repository, runID string, state *cloudAgentRuntime, input cloudAgentMutationInput) error { before, err := creationDocument(input.BeforeJSON) if err != nil { diff --git a/backend/internal/app/cloud_agent_layout.go b/backend/internal/app/cloud_agent_layout.go new file mode 100644 index 000000000..df6acabb9 --- /dev/null +++ b/backend/internal/app/cloud_agent_layout.go @@ -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 "", 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 +} diff --git a/backend/internal/app/cloud_agent_layout_patch_test.go b/backend/internal/app/cloud_agent_layout_patch_test.go new file mode 100644 index 000000000..69f8fbc73 --- /dev/null +++ b/backend/internal/app/cloud_agent_layout_patch_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// geometryPatchFixture 造一对"只差位置"的重节点:正文很大,几何变化很小。 +func geometryPatchFixture() (map[string]any, map[string]any) { + heavy := strings.Repeat("分镜行正文", 2000) + node := func(x, y float64, rows []any) map[string]any { + return map[string]any{ + "id": "sb-1", "type": "script", "title": "第一场", + "position": map[string]any{"x": x, "y": y}, "width": 920.0, "height": 360.0, + "metadata": map[string]any{"storyboard": map[string]any{"rows": rows}, "status": "idle"}, + } + } + return node(0, 0, []any{heavy}), node(120, 480, []any{heavy}) +} + +// 位置/尺寸变化只发几何增量:一次整理几十个分镜节点时,完整 before/after 会让单条 +// canvas_updated 事件超过 validateCloudAgentRuntime 的 128KiB 单事件上限,整轮直接终止。 +func TestCloudAgentGeometryOnlyChangeShrinksPatch(t *testing.T) { + before, after := geometryPatchFixture() + shrunkBefore, shrunkAfter := cloudAgentShrinkChange(before, after) + raw, _ := json.Marshal([]map[string]any{{"before": shrunkBefore, "after": shrunkAfter}}) + if len(raw) > 512 { + t.Fatalf("几何增量仍然过大(%d 字节):%s", len(raw), raw) + } + if shrunkBefore["id"] != "sb-1" || shrunkAfter["id"] != "sb-1" { + t.Fatalf("增量必须带 id:%+v %+v", shrunkBefore, shrunkAfter) + } + if _, ok := shrunkAfter["position"]; !ok { + t.Fatalf("几何增量缺少 position:%+v", shrunkAfter) + } + if _, ok := shrunkAfter["metadata"]; ok { + t.Fatalf("几何增量不应携带正文字段:%+v", shrunkAfter) + } + + // 新增节点(before 为空)没有可比对的旧值,不缩。 + if _, kept := cloudAgentShrinkChange(nil, after); kept["metadata"] == nil { + t.Fatalf("新增节点不应被缩减:%+v", kept) + } +} + +// 内容变化(正文 / metadata)仍发完整 before/after:前端按字段三方合并,缩成几何增量会 +// 丢掉内容变化,也会影响前端对生成任务状态的保护逻辑。 +func TestCloudAgentContentChangeKeepsFullPatch(t *testing.T) { + before, after := geometryPatchFixture() + contentAfter := map[string]any{ + "id": "sb-1", "type": "script", "title": "第一场", + "position": after["position"], "width": 920.0, "height": 360.0, + "metadata": map[string]any{"storyboard": map[string]any{"rows": []any{"改过的行"}}, "status": "idle"}, + } + keptBefore, keptAfter := cloudAgentShrinkChange(before, contentAfter) + if _, ok := keptAfter["metadata"]; !ok { + t.Fatalf("内容变化被错误地缩成几何增量:%+v", keptAfter) + } + if keptBefore["id"] != before["id"] || keptBefore["metadata"] == nil { + t.Fatalf("内容变化应保留完整 before:%+v", keptBefore) + } + + // 键被删掉时同样不缩:只发几何字段会让前端无法区分"消失的字段"与"保持本地值"。 + keyRemoved := map[string]any{"id": "sb-1", "type": "script", "title": "第一场", "position": after["position"]} + deletedBefore, deletedAfter := cloudAgentShrinkChange(before, keyRemoved) + if !reflect.DeepEqual(deletedBefore, before) || !reflect.DeepEqual(deletedAfter, keyRemoved) { + t.Fatalf("缺键的变更不应被缩成几何增量:%+v %+v", deletedBefore, deletedAfter) + } +} diff --git a/backend/internal/app/cloud_agent_layout_test.go b/backend/internal/app/cloud_agent_layout_test.go new file mode 100644 index 000000000..9383beec9 --- /dev/null +++ b/backend/internal/app/cloud_agent_layout_test.go @@ -0,0 +1,340 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + + "infinite-canvas/backend/internal/canvas/layout" + "infinite-canvas/backend/internal/model" +) + +// layoutFixture 造一张用于整理测试的画布:文本 / 图片 / 视频三类节点挤在同一个角落。 +func layoutFixture(t *testing.T, nodes []map[string]any) (*Service, map[string]any) { + t.Helper() + s, db, _, _ := creationTestService(t) + doc := map[string]any{"nodes": nodes, "connections": []any{}} + raw, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := db.Create(&model.CanvasProject{ID: "layout-canvas", UserID: "user", PayloadJSON: string(raw)}).Error; err != nil { + t.Fatal(err) + } + return s, doc +} + +func layoutNode(id, nodeType string, x, y float64, metadata ...map[string]any) map[string]any { + meta := map[string]any{"content": "内容-" + id} + if len(metadata) > 0 { + for key, value := range metadata[0] { + meta[key] = value + } + } + return map[string]any{ + "id": id, "type": nodeType, "title": "节点-" + id, + "position": map[string]any{"x": x, "y": y}, + "width": 300.0, "height": 200.0, + "metadata": meta, + } +} + +func arrangeCall(t *testing.T, args map[string]any) cloudAgentCall { + t.Helper() + raw, err := json.Marshal(args) + if err != nil { + t.Fatal(err) + } + call := cloudAgentCall{ID: "arrange-1"} + call.Function.Name = "canvas_arrange_nodes" + call.Function.Arguments = string(raw) + return call +} + +func canvasPositions(t *testing.T, s *Service) map[string]layout.Position { + t.Helper() + canvas, err := s.repo.CanvasProjectForUser("user", "layout-canvas") + if err != nil { + t.Fatal(err) + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + t.Fatal(err) + } + positions := map[string]layout.Position{} + for _, node := range creationMaps(doc["nodes"]) { + x, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["x"]) + y, _ := cloudAgentSafeNumber(node["position"].(map[string]any)["y"]) + xf, _ := x.(float64) + yf, _ := y.(float64) + positions[stringValue(node["id"])] = layout.Position{X: xf, Y: yf} + } + return positions +} + +func TestCloudAgentArrangeNodesLaysOutByLane(t *testing.T) { + nodes := []map[string]any{ + layoutNode("text-1", "text", 0, 0), + layoutNode("text-2", "text", 10, 10), + layoutNode("image-1", "image", 20, 20), + layoutNode("video-1", "video", 30, 30), + } + s, doc := layoutFixture(t, nodes) + policy := mustRuntimePolicy(t, s) + + result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": creationHash(doc), + "mode": "byType", + }), policy) + if err != nil { + t.Fatal(err) + } + receipt, _ := result.(map[string]any) + // 布局以当前左上角为锚点:已经在锚点上的 text-1 不需要移动,moved 只统计真正变动的节点。 + if moved, _ := receipt["moved"].(int); moved < 3 { + t.Fatalf("整理节点数 = %v,期望至少 3:%+v", receipt["moved"], receipt) + } + + positions := canvasPositions(t, s) + if positions["text-1"].Y >= positions["image-1"].Y || positions["image-1"].Y >= positions["video-1"].Y { + t.Fatalf("未按媒体泳道分区:%+v", positions) + } + if positions["text-1"].X != positions["image-1"].X { + t.Fatalf("各泳道应左对齐:%+v", positions) + } + applied := []layout.Node{ + {ID: "text-1", X: positions["text-1"].X, Y: positions["text-1"].Y, Width: 300, Height: 200}, + {ID: "text-2", X: positions["text-2"].X, Y: positions["text-2"].Y, Width: 300, Height: 200}, + {ID: "image-1", X: positions["image-1"].X, Y: positions["image-1"].Y, Width: 300, Height: 200}, + {ID: "video-1", X: positions["video-1"].X, Y: positions["video-1"].Y, Width: 300, Height: 200}, + } + if pairs := layout.Overlaps(applied, 0); len(pairs) != 0 { + t.Fatalf("整理后仍有重叠:%+v", pairs) + } + if hash, _ := receipt["snapshotHash"].(string); hash == "" || hash == receipt["beforeSnapshotHash"] { + t.Fatalf("整理后快照应变化:%+v", receipt) + } +} + +func TestCloudAgentArrangeNodesKeepsLockedAndContainers(t *testing.T) { + nodes := []map[string]any{ + layoutNode("text-1", "text", 0, 0), + layoutNode("text-2", "text", 5, 5), + layoutNode("locked-1", "text", 400, 400, map[string]any{"locked": true}), + layoutNode("frame-1", "frame", 800, 800), + } + s, doc := layoutFixture(t, nodes) + policy := mustRuntimePolicy(t, s) + + if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": creationHash(doc), + "mode": "byType", + }), policy); err != nil { + t.Fatal(err) + } + positions := canvasPositions(t, s) + if positions["locked-1"] != (layout.Position{X: 400, Y: 400}) { + t.Fatalf("锁定节点被移动:%+v", positions["locked-1"]) + } + if positions["frame-1"] != (layout.Position{X: 800, Y: 800}) { + t.Fatalf("背板被移动:%+v", positions["frame-1"]) + } + if positions["text-2"].Y == 5 && positions["text-2"].X == 5 { + t.Fatalf("可整理节点没有移动:%+v", positions) + } +} + +func TestCloudAgentArrangeNodesGroupsMakeBands(t *testing.T) { + nodes := []map[string]any{ + layoutNode("a-1", "image", 0, 0), + layoutNode("a-2", "image", 10, 10), + layoutNode("b-1", "video", 20, 20), + layoutNode("b-2", "video", 30, 30), + layoutNode("left-out", "text", 900, 900), + } + s, doc := layoutFixture(t, nodes) + policy := mustRuntimePolicy(t, s) + + result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": creationHash(doc), + "groups": []any{ + map[string]any{"label": "A 场景", "nodeIds": []any{"a-1", "a-2"}, "mode": "grid"}, + map[string]any{"label": "B 场景", "nodeIds": []any{"b-1", "b-2"}, "mode": "grid"}, + }, + "gap": 120, + }), policy) + if err != nil { + t.Fatal(err) + } + receipt, _ := result.(map[string]any) + groups, _ := receipt["groups"].([]string) + if len(groups) != 2 || groups[0] != "A 场景" { + t.Fatalf("分组信息缺失:%+v", receipt["groups"]) + } + positions := canvasPositions(t, s) + if positions["b-1"].Y <= positions["a-1"].Y { + t.Fatalf("两组未分带:a=%+v b=%+v", positions["a-1"], positions["b-1"]) + } + if positions["left-out"] != (layout.Position{X: 900, Y: 900}) { + t.Fatalf("未参与分组的节点被移动:%+v", positions["left-out"]) + } +} + +func TestCloudAgentArrangeNodesDryRunDoesNotWrite(t *testing.T) { + nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)} + s, doc := layoutFixture(t, nodes) + policy := mustRuntimePolicy(t, s) + + result, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": creationHash(doc), + "mode": "row", + "dryRun": true, + }), policy) + if err != nil { + t.Fatal(err) + } + receipt, _ := result.(map[string]any) + if wouldMove, _ := receipt["wouldMove"].(int); wouldMove < 1 { + t.Fatalf("预演结果 = %+v", receipt) + } + if moved, _ := receipt["moved"].(int); moved != 0 { + t.Fatalf("预演不应写入:%+v", receipt) + } + positions := canvasPositions(t, s) + if positions["text-2"] != (layout.Position{X: 5, Y: 5}) { + t.Fatalf("预演不应写入画布:%+v", positions) + } +} + +func TestCloudAgentArrangeNodesRejectsStaleSnapshotAndTooManyNodes(t *testing.T) { + nodes := []map[string]any{layoutNode("text-1", "text", 0, 0), layoutNode("text-2", "text", 5, 5)} + s, _ := layoutFixture(t, nodes) + policy := mustRuntimePolicy(t, s) + + // 过期快照按上游的字段错误口径归类(issue=stale_snapshot),运行期据此把它当可纠正的 + // 工具结果交回模型重新读取,而不是终止整轮。 + var staleErr *cloudAgentFieldArgumentError + if _, err := applyCloudAgentArrangeNodes(s.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": "stale-hash", + "mode": "row", + }), policy); !errors.As(err, &staleErr) || staleErr.Issue != "stale_snapshot" || staleErr.Field != "snapshotHash" { + t.Fatalf("陈旧快照应被归类为过期快照:%v", err) + } + + many := make([]map[string]any, 0, cloudAgentArrangeMaxNodes+1) + for index := 0; index <= cloudAgentArrangeMaxNodes; index++ { + many = append(many, layoutNode(fmt.Sprintf("text-%d", index), "text", float64(index), 0)) + } + s2, doc2 := layoutFixture(t, many) + // 参数契约类错误同样按字段错误归类:模型因此拿到"哪个字段错、怎么改"的可纠正回执, + // 而不是被走准入失败分支判死整轮。 + var countErr *cloudAgentFieldArgumentError + if _, err := applyCloudAgentArrangeNodes(s2.repo, "user", "layout-canvas", arrangeCall(t, map[string]any{ + "snapshotHash": creationHash(doc2), + "mode": "row", + }), mustRuntimePolicy(t, s2)); !errors.As(err, &countErr) || countErr.Field != "nodeIds" || countErr.Issue != "item_count" { + t.Fatalf("超限应按 nodeIds/item_count 归类:%v", err) + } +} + +// add_node 不带坐标时由服务端按泳道自动落位(作为生成输入时排到目标左侧): +// agentCanvasOp.X/Y 的指针语义保证"不传坐标"与"坐标 0"是两件事。 +func TestCloudAgentAddedNodeWithoutCoordinatesLandsOrdered(t *testing.T) { + nodes := []map[string]any{ + layoutNode("text-1", "text", 0, 0), + layoutNode("image-1", "image", 0, 400), + } + _, doc := layoutFixture(t, nodes) + + // 不带坐标:服务端按泳道落位,不能落在 (0,0) 与已有节点重叠。 + ops := []agentCanvasOp{ + {Type: "add_node", ID: "text-new", NodeType: "text", Title: stringPtr("新增文本")}, + } + if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil { + t.Fatal(err) + } + added := nodeByID(t, doc, "text-new") + position := positionOf(t, added) + if position.X == 0 && position.Y == 0 { + t.Fatalf("新增节点落在原点:%+v", position) + } + if position.X == 0 && position.Y == 400 { + t.Fatalf("新增节点与已有图片重叠:%+v", position) + } + + // 带连线:新节点作为生成输入时放在目标左侧。 + ops = []agentCanvasOp{ + {Type: "add_node", ID: "text-input", NodeType: "text", Title: stringPtr("输入")}, + {Type: "connect_nodes", ID: "edge-new", FromNodeID: "text-input", ToNodeID: "image-1"}, + } + if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil { + t.Fatal(err) + } + input := positionOf(t, nodeByID(t, doc, "text-input")) + if input.X >= 0 { + t.Fatalf("作为生成输入的新节点应排到目标左侧:%+v", input) + } + if input.Y != 400 { + t.Fatalf("新节点应与目标同高:%+v", input) + } + + // 显式坐标优先。 + x, y := 1234.0, 5678.0 + ops = []agentCanvasOp{{Type: "add_node", ID: "text-fixed", NodeType: "text", X: &x, Y: &y}} + if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil { + t.Fatal(err) + } + if got := positionOf(t, nodeByID(t, doc, "text-fixed")); got != (layout.Position{X: 1234, Y: 5678}) { + t.Fatalf("显式坐标未被采用:%+v", got) + } +} + +func TestCloudAgentUpdateNodePositionPatch(t *testing.T) { + nodes := []map[string]any{layoutNode("text-1", "text", 0, 0)} + _, doc := layoutFixture(t, nodes) + + ops := []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 480.0, "y": 260.0}}} + if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil { + t.Fatal(err) + } + if got := positionOf(t, nodeByID(t, doc, "text-1")); got != (layout.Position{X: 480, Y: 260}) { + t.Fatalf("坐标未写入:%+v", got) + } + + ops = []agentCanvasOp{{Type: "update_node", ID: "text-1", Patch: map[string]any{"x": 1e9}}} + if _, err := applyCloudAgentCanvasPlan(doc, ops); err == nil { + t.Fatal("越界坐标应被拒绝") + } +} + +func nodeByID(t *testing.T, doc map[string]any, id string) map[string]any { + t.Helper() + for _, node := range creationMaps(doc["nodes"]) { + if stringValue(node["id"]) == id { + return node + } + } + t.Fatalf("节点 %s 不存在", id) + return nil +} + +func positionOf(t *testing.T, node map[string]any) layout.Position { + t.Helper() + position, _ := node["position"].(map[string]any) + x, _ := cloudAgentSafeNumber(position["x"]) + y, _ := cloudAgentSafeNumber(position["y"]) + xf, _ := x.(float64) + yf, _ := y.(float64) + return layout.Position{X: xf, Y: yf} +} + +// mustRuntimePolicy 读取运行时策略,供整理工具落库时使用。 +func mustRuntimePolicy(t *testing.T, s *Service) RuntimePolicySetting { + t.Helper() + policy, err := s.RuntimePolicy() + if err != nil { + t.Fatal(err) + } + return policy +} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 12b379b96..dcce01653 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -1217,6 +1217,12 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c if err == nil { preview = batchPlan.Preview } + case "canvas_arrange_nodes": + arrangePlan, err := prepareCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = arrangePlan.Preview + } default: canvasPlan, err := prepareCloudAgentCanvasMutation(repo, run.UserID, state.Request.CanvasID, call) mutationErr = err @@ -1305,6 +1311,8 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c } case call.Function.Name == "canvas_apply_ops": result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) + case call.Function.Name == "canvas_arrange_nodes": + result, toolErr = applyCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard": result, toolErr = applyCloudAgentStoryboardMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) case call.Function.Name == "canvas_edit_batch_table": diff --git a/backend/internal/app/cloud_agent_tool_schema_budget_test.go b/backend/internal/app/cloud_agent_tool_schema_budget_test.go index a0ba20e67..941aafd6f 100644 --- a/backend/internal/app/cloud_agent_tool_schema_budget_test.go +++ b/backend/internal/app/cloud_agent_tool_schema_budget_test.go @@ -52,9 +52,11 @@ func TestCloudAgentToolSchemaStaysCompact(t *testing.T) { t.Fatal("canvas_apply_ops 未暴露") } - // 体积预算:当前 20 个工具约 22.4 KB,这里留 ~7% 余量;新增工具或字段时请重新测量并 - // 有意识地调整这个数字,而不是让 schema 悄悄膨胀(它每一步都要发、还在前缀最前面)。 - if len(raw) > 24000 { - t.Fatalf("平台工具 schema 体积 %d 字节超出预算 24000:请压缩描述或显式调整预算", len(raw)) + // 体积预算:上游 20 个工具实测 22,777 字节(预算 24000);本 PR 新增 canvas_arrange_nodes + // 并把描述、patch 字段补齐后实测 21 个工具 24,788 字节,因此把预算显式上调到 25000 + // (约 0.9% 余量)。新增工具或字段时请重新测量并有意识地调整这个数字,而不是让 schema + // 悄悄膨胀(它每一步都要发、还在前缀最前面)。 + if len(raw) > 25000 { + t.Fatalf("平台工具 schema 体积 %d 字节超出预算 25000:请压缩描述或显式调整预算", len(raw)) } } diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index 0a47b0bc2..3cf03dd4c 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -442,7 +442,22 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma {"properties": map[string]any{"type": map[string]any{"const": "connect_nodes"}}, "required": []string{"fromNodeId", "toNodeId"}}, }, } - add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType,update_node 还需要按节点能力清单填写 patch,connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops") + add("canvas_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType(可给 x/y 指定位置;省略坐标时服务端按画布内容自动落位,不会叠在原点),update_node 还需要按节点能力清单填写 patch(可含 x/y 移动节点),connect_nodes 还需要 fromNodeId 与 toNodeId。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。批量整理位置用 canvas_arrange_nodes,不要用几十项 update_node 手工算坐标。", map[string]any{"snapshotHash": str("canvas_get_state返回的snapshotHash"), "ops": map[string]any{"type": "array", "maxItems": 20, "items": opItem}}, "snapshotHash", "ops") + add("canvas_arrange_nodes", "整理画布节点位置:只改坐标,不改内容、不建连线、不增删节点,先读画布并传 snapshotHash。mode 省略即 auto(有连线按依赖分层,否则按媒体类型分区)。groups 为横向分带(label 展示名,可覆盖整组 mode)。nodeIds 省略则整理全部可整理节点(跳过锁定节点、容器、批次子节点与已归属背板者)。align 对齐/等距,dryRun 只预演;一次最多 50 个节点,只挪单个节点用 update_node 的 x/y。", map[string]any{ + "snapshotHash": str("最近一次画布读取的 snapshotHash"), + "nodeIds": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxNodes, "items": str("节点ID;省略=全部可整理")}, + "mode": map[string]any{"type": "string", "enum": []any{"auto", "flow", "byType", "row", "column", "grid"}, "description": "auto=有连线按依赖否则按类型;flow=按依赖分层;byType=按类型分区;row/column/grid=线性或网格"}, + "groups": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxGroups, "items": map[string]any{ + "type": "object", "properties": map[string]any{ + "label": str("分组展示名"), + "nodeIds": map[string]any{"type": "array", "maxItems": cloudAgentArrangeMaxNodes, "items": str("节点ID")}, + "mode": map[string]any{"type": "string", "enum": []any{"byType", "flow", "row", "column", "grid"}}, + }, "required": []string{"nodeIds"}, "additionalProperties": false, + }}, + "align": map[string]any{"type": "string", "enum": []any{"left", "centerX", "right", "top", "centerY", "bottom", "distributeX", "distributeY"}}, + "gap": map[string]any{"type": "number", "minimum": 0, "maximum": cloudAgentArrangeMaxGap, "description": "分带间距(像素)"}, + "dryRun": map[string]any{"type": "boolean", "description": "true 只预演不写入"}, + }, "snapshotHash") } if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 { add("generate_media", "提交媒体生成:准备草稿和引用连线,独立审批通过后提交收费任务,auto也需要审批。仅创建节点、编辑提示词或连线使用 canvas_apply_ops。生成前读取画布和按实际参考素材筛选的模型目录,参数需符合返回的时长、画幅和音频能力。可续用空闲且无任务、无产物的草稿;其他运行的草稿需原运行已结束且清理完成。已绑定任务或已有产物的节点不能覆盖,原任务状态和错误可从 generation 或 task_get 读取。sourceNodeId 是文本输入;referenceNodeIds 是媒体输入;referenceTransientIds 只接受标注工具返回的临时引用,不接受任意URL。准入错误按返回的 reason 修正;已提交任务失败应告知用户,重新生成需用户明确要求并重新审批。", map[string]any{ @@ -496,7 +511,7 @@ func cloudAgentToolAllowed(req CloudAgentRequest, name string) bool { return false } func cloudAgentWrite(name string) bool { - return name == "canvas_apply_ops" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table" + return name == "canvas_apply_ops" || name == "canvas_arrange_nodes" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table" } func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { @@ -881,16 +896,18 @@ type agentCanvasArgs struct { } type agentCanvasOp struct { - Type string `json:"type"` - ID string `json:"id"` - NodeType string `json:"nodeType"` - Title *string `json:"title"` - Content *string `json:"content"` - Patch map[string]any `json:"patch"` - X float64 `json:"x"` - Y float64 `json:"y"` - FromNodeID string `json:"fromNodeId"` - ToNodeID string `json:"toNodeId"` + Type string `json:"type"` + ID string `json:"id"` + NodeType string `json:"nodeType"` + Title *string `json:"title"` + Content *string `json:"content"` + Patch map[string]any `json:"patch"` + // X/Y 为指针:nil 表示模型没有指定坐标,服务端按画布内容自动落位(不再落到原点重叠)。 + // 指针语义与 canvas/capability/builtin.go 的 positionPatchFields 一致(坐标是可选的数字)。 + X *float64 `json:"x"` + Y *float64 `json:"y"` + FromNodeID string `json:"fromNodeId"` + ToNodeID string `json:"toNodeId"` } // Explicit node creation and edges only; no generic metadata, media URL or deletion. diff --git a/backend/internal/canvas/capability/builtin.go b/backend/internal/canvas/capability/builtin.go index 9802f023c..ceb84676f 100644 --- a/backend/internal/canvas/capability/builtin.go +++ b/backend/internal/canvas/capability/builtin.go @@ -3,6 +3,8 @@ package capability const ( maxAgentNodeTitleRunes = 240 maxAgentNodeContentRunes = 16000 + // 坐标绝对值上限:与 app 侧整理工具收敛几何值的范围一致。 + maxAgentNodeCoordLimit = 1e6 ) func BuiltinRegistry() *Registry { @@ -60,9 +62,15 @@ func BuiltinRegistry() *Registry { InputKind: "text", Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}}, CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable", - PatchFields: map[string]PatchField{ - "title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes}, - }, + PatchFields: func() map[string]PatchField { + fields := map[string]PatchField{ + "title": {Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "批量创作表标题", MaxRunes: maxAgentNodeTitleRunes}, + } + for key, field := range positionPatchFields() { + fields[key] = field + } + return fields + }(), CreateMetadata: func(string) map[string]any { return map[string]any{ "status": "idle", @@ -151,7 +159,7 @@ func generatedMediaSemantics(nodeType string) generatedMediaCapabilitySemantics } func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField { - return map[string]PatchField{ + fields := map[string]PatchField{ "title": { Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes, }, @@ -159,6 +167,19 @@ func editableNodeFields(contentPath, contentLabel, contentDescription string) ma Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes, }, } + for key, field := range positionPatchFields() { + fields[key] = field + } + return fields +} + +// positionPatchFields 是坐标字段:模型可以直接指定节点位置(微调),批量整理走 canvas_arrange_nodes。 +// 坐标上限与整理侧的收敛范围一致,避免写进离谱的几何值。 +func positionPatchFields() map[string]PatchField { + return map[string]PatchField{ + "x": {Path: "position.x", Kind: patchKindNumber, Label: "横坐标", Order: 30, Limit: maxAgentNodeCoordLimit, Description: "画布横坐标(像素);与 y 一起移动节点,通常用 canvas_arrange_nodes 批量整理"}, + "y": {Path: "position.y", Kind: patchKindNumber, Label: "纵坐标", Order: 31, Limit: maxAgentNodeCoordLimit, Description: "画布纵坐标(像素);与 x 一起移动节点,通常用 canvas_arrange_nodes 批量整理"}, + } } func generatedMetadata(prompt string) map[string]any { diff --git a/backend/internal/canvas/capability/descriptor.go b/backend/internal/canvas/capability/descriptor.go index 2090ab449..727c31c7e 100644 --- a/backend/internal/canvas/capability/descriptor.go +++ b/backend/internal/canvas/capability/descriptor.go @@ -2,6 +2,7 @@ package capability import ( "fmt" + "math" "sort" "strings" "unicode" @@ -23,10 +24,12 @@ type ConnectionPolicy struct { } type PatchField struct { - Path string - Kind string - Label string - Order int + Path string + Kind string + Label string + Order int + // Limit > 0 时表示数值字段的绝对值上限(坐标用),避免模型写入离谱的几何值。 + Limit float64 Description string MaxRunes int } @@ -107,9 +110,16 @@ func (d Descriptor) ValidatePatch(patch map[string]any) error { return fmt.Errorf("%s 字段 %s 超出长度限制", d.Label, key) } case "number": - if _, ok := value.(float64); !ok { + number, ok := value.(float64) + if !ok { return fmt.Errorf("%s 字段 %s 必须是数字", d.Label, key) } + if math.IsNaN(number) || math.IsInf(number, 0) { + return fmt.Errorf("%s 字段 %s 不是有效数字", d.Label, key) + } + if field.Limit > 0 && math.Abs(number) > field.Limit { + return fmt.Errorf("%s 字段 %s 超出允许范围(±%g)", d.Label, key, field.Limit) + } case "boolean": if _, ok := value.(bool); !ok { return fmt.Errorf("%s 字段 %s 必须是布尔值", d.Label, key) diff --git a/backend/internal/canvas/layout/layout.go b/backend/internal/canvas/layout/layout.go new file mode 100644 index 000000000..c5b8e512c --- /dev/null +++ b/backend/internal/canvas/layout/layout.go @@ -0,0 +1,627 @@ +// Package layout 是画布节点排布的确定性几何实现,供云端 Agent 的"整理 / 归类 / 新增落位"使用。 +// +// 这里刻意与前端 web/src/lib/canvas/canvas-layout.ts 保持同一套常量与算法语义: +// 前端"自动整理节点"按钮与 Agent 的整理工具必须给出同样的结果,否则同一个画布在两条 +// 路径下会得到不同的布局。修改任一侧常量或算法时,另一侧要同步(两侧各自有黄金用例)。 +// +// 本包只做几何,不读取画布文档、不判断权限、不写库。 +package layout + +import "sort" + +const ( + // 与 canvas-layout.ts 的常量一一对应。 + FlowColumnGap = 120 + LaneGap = 96 + LaneNodeGap = 36 + GridNodeGap = 32 + // 新增节点与已有节点之间的最小空隙(落位避让用)。 + PlacementGap = 40 +) + +// LaneOrder 是泳道的稳定顺序,决定从上到下的分区次序。 +var LaneOrder = []string{"text", "image", "video", "audio"} + +// Node 是排布所需的节点快照。Width/Height 为 0 时按默认尺寸补齐。 +type Node struct { + ID string + Type string + Lane string + X, Y float64 + Width float64 + Height float64 + Locked bool + NoMove bool // 容器节点(背板/文件夹)本身不参与整理 + Movable bool +} + +// Position 是排布结果。 +type Position struct { + X float64 + Y float64 +} + +// Edge 是一条有向引用连线(来源 → 目标),用于依赖方向分层。 +type Edge struct { + From string + To string +} + +// Mode 是整理模式。 +type Mode string + +const ( + ModeAuto Mode = "auto" + ModeFlow Mode = "flow" + ModeByLane Mode = "byType" + ModeRow Mode = "row" + ModeColumn Mode = "column" + ModeGrid Mode = "grid" +) + +// AlignMode 是对齐 / 等距分布模式。 +type AlignMode string + +const ( + AlignLeft AlignMode = "left" + AlignCenterX AlignMode = "centerX" + AlignRight AlignMode = "right" + AlignTop AlignMode = "top" + AlignCenterY AlignMode = "centerY" + AlignBottom AlignMode = "bottom" + AlignDistributeX AlignMode = "distributeX" + AlignDistributeY AlignMode = "distributeY" +) + +func (n Node) width() float64 { + if n.Width > 0 { + return n.Width + } + return 340 +} + +func (n Node) height() float64 { + if n.Height > 0 { + return n.Height + } + return 240 +} + +func minX(nodes []Node) float64 { + value := nodes[0].X + for _, node := range nodes[1:] { + if node.X < value { + value = node.X + } + } + return value +} + +func minY(nodes []Node) float64 { + value := nodes[0].Y + for _, node := range nodes[1:] { + if node.Y < value { + value = node.Y + } + } + return value +} + +func maxWidth(nodes []Node) float64 { + value := 0.0 + for _, node := range nodes { + if node.width() > value { + value = node.width() + } + } + return value +} + +func maxHeight(nodes []Node) float64 { + value := 0.0 + for _, node := range nodes { + if node.height() > value { + value = node.height() + } + } + return value +} + +// byPosition 复刻前端的稳定次序:先按 y,再按 x。 +func byPosition(nodes []Node) []Node { + sorted := append([]Node(nil), nodes...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Y != sorted[j].Y { + return sorted[i].Y < sorted[j].Y + } + return sorted[i].X < sorted[j].X + }) + return sorted +} + +// Auto 复刻 layoutCanvasAuto:节点之间有连线时按依赖分层,否则按媒体类型泳道。 +func Auto(nodes []Node, edges []Edge) map[string]Position { + if len(nodes) < 2 { + return map[string]Position{} + } + if connected(nodes, edges) { + return Flow(nodes, edges) + } + return ByLane(nodes) +} + +func connected(nodes []Node, edges []Edge) bool { + ids := make(map[string]bool, len(nodes)) + for _, node := range nodes { + ids[node.ID] = true + } + for _, edge := range edges { + if ids[edge.From] && ids[edge.To] { + return true + } + } + return false +} + +// Flow 复刻 layoutCanvasFlow:Kahn 拓扑分层从左到右,层内按泳道竖排。 +// 环上的节点留在第 0 层,避免布局死循环。 +func Flow(nodes []Node, edges []Edge) map[string]Position { + result := map[string]Position{} + if len(nodes) == 0 { + return result + } + index := make(map[string]Node, len(nodes)) + for _, node := range nodes { + index[node.ID] = node + } + inbound := make(map[string]int, len(nodes)) + outbound := make(map[string][]string, len(nodes)) + for _, node := range nodes { + inbound[node.ID] = 0 + } + for _, edge := range edges { + if _, ok := index[edge.From]; !ok { + continue + } + if _, ok := index[edge.To]; !ok { + continue + } + inbound[edge.To]++ + outbound[edge.From] = append(outbound[edge.From], edge.To) + } + + queue := make([]string, 0, len(nodes)) + for _, node := range nodes { + if inbound[node.ID] == 0 { + queue = append(queue, node.ID) + } + } + layer := map[string]int{} + for _, id := range queue { + layer[id] = 0 + } + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + for _, target := range outbound[id] { + if layer[target] < layer[id]+1 { + layer[target] = layer[id] + 1 + } + inbound[target]-- + if inbound[target] == 0 { + queue = append(queue, target) + } + } + } + for _, node := range nodes { + if _, ok := layer[node.ID]; !ok { + layer[node.ID] = 0 + } + } + + left, top := minX(nodes), minY(nodes) + layerIDs := make([]int, 0, len(nodes)) + seen := map[int]bool{} + for _, node := range nodes { + if !seen[layer[node.ID]] { + seen[layer[node.ID]] = true + layerIDs = append(layerIDs, layer[node.ID]) + } + } + sort.Ints(layerIDs) + layerX := map[int]float64{} + x := left + for _, value := range layerIDs { + members := nodesInLayer(nodes, layer, value) + layerX[value] = x + x += maxWidth(members) + FlowColumnGap + } + + laneTop := top + for _, lane := range LaneOrder { + lanes := laneNodes(nodes, lane) + if len(lanes) == 0 { + continue + } + stacks := map[int][]Node{} + stackOrder := make([]int, 0, len(lanes)) + for _, node := range lanes { + value := layer[node.ID] + if _, ok := stacks[value]; !ok { + stackOrder = append(stackOrder, value) + } + stacks[value] = append(stacks[value], node) + } + laneHeight := 0.0 + for _, stack := range stacks { + height := 0.0 + for _, node := range stack { + height += node.height() + } + height += float64(max(0, len(stack)-1)) * LaneNodeGap + if height > laneHeight { + laneHeight = height + } + } + sort.Ints(stackOrder) + for _, value := range stackOrder { + stack := byPosition(stacks[value]) + y := laneTop + for _, node := range stack { + result[node.ID] = Position{X: layerX[value], Y: y} + y += node.height() + LaneNodeGap + } + } + laneTop += laneHeight + LaneGap + } + return result +} + +func nodesInLayer(nodes []Node, layer map[string]int, value int) []Node { + subset := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if layer[node.ID] == value { + subset = append(subset, node) + } + } + return subset +} + +func laneNodes(nodes []Node, lane string) []Node { + subset := make([]Node, 0, len(nodes)) + for _, node := range nodes { + if node.Lane == lane { + subset = append(subset, node) + } + } + return subset +} + +// ByLane 复刻 layoutCanvasNodesByMediaType:每个泳道内部按网格排布(最多 4 列)。 +func ByLane(nodes []Node) map[string]Position { + result := map[string]Position{} + if len(nodes) == 0 { + return result + } + left, top := minX(nodes), minY(nodes) + laneTop := top + for _, lane := range LaneOrder { + lanes := byPosition(laneNodes(nodes, lane)) + if len(lanes) == 0 { + continue + } + columns := min(4, max(1, ceilSqrt(len(lanes)))) + cellWidth := maxWidth(lanes) + GridNodeGap + cellHeight := maxHeight(lanes) + GridNodeGap + for index, node := range lanes { + result[node.ID] = Position{ + X: left + float64(index%columns)*cellWidth, + Y: laneTop + float64(index/columns)*cellHeight, + } + } + rows := float64(ceilDiv(len(lanes), columns)) + laneTop += rows*cellHeight - GridNodeGap + LaneGap + } + return result +} + +// Linear 复刻 layoutCanvasNodes:单行 / 单列 / 方阵。 +func Linear(nodes []Node, mode Mode) map[string]Position { + result := map[string]Position{} + if len(nodes) == 0 { + return result + } + sorted := byPosition(nodes) + left, top := minX(nodes), minY(nodes) + switch mode { + case ModeRow: + x := left + for _, node := range sorted { + result[node.ID] = Position{X: x, Y: top} + x += node.width() + GridNodeGap + } + case ModeColumn: + y := top + for _, node := range sorted { + result[node.ID] = Position{X: left, Y: y} + y += node.height() + GridNodeGap + } + default: + columns := max(1, int(ceilSqrt(len(sorted)))) + cellWidth := maxWidth(sorted) + GridNodeGap + cellHeight := maxHeight(sorted) + GridNodeGap + for index, node := range sorted { + result[node.ID] = Position{ + X: left + float64(index%columns)*cellWidth, + Y: top + float64(index/columns)*cellHeight, + } + } + } + return result +} + +// Align 复刻 alignCanvasNodes:对齐与等距分布。 +func Align(nodes []Node, mode AlignMode) map[string]Position { + result := map[string]Position{} + if len(nodes) < 2 { + return result + } + left, top := minX(nodes), minY(nodes) + right, bottom := 0.0, 0.0 + for index, node := range nodes { + if index == 0 || node.X+node.width() > right { + right = node.X + node.width() + } + if index == 0 || node.Y+node.height() > bottom { + bottom = node.Y + node.height() + } + } + centerX, centerY := (left+right)/2, (top+bottom)/2 + + switch mode { + case AlignDistributeX: + sorted := append([]Node(nil), nodes...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].X < sorted[j].X }) + total := 0.0 + for _, node := range sorted { + total += node.width() + } + gap := (right - left - total) / float64(max(1, len(sorted)-1)) + x := left + for _, node := range sorted { + result[node.ID] = Position{X: x, Y: node.Y} + x += node.width() + gap + } + return result + case AlignDistributeY: + sorted := append([]Node(nil), nodes...) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Y < sorted[j].Y }) + total := 0.0 + for _, node := range sorted { + total += node.height() + } + gap := (bottom - top - total) / float64(max(1, len(sorted)-1)) + y := top + for _, node := range sorted { + result[node.ID] = Position{X: node.X, Y: y} + y += node.height() + gap + } + return result + } + + for _, node := range nodes { + x, y := node.X, node.Y + switch mode { + case AlignLeft: + x = left + case AlignCenterX: + x = centerX - node.width()/2 + case AlignRight: + x = right - node.width() + case AlignTop: + y = top + case AlignCenterY: + y = centerY - node.height()/2 + case AlignBottom: + y = bottom - node.height() + } + result[node.ID] = Position{X: x, Y: y} + } + return result +} + +// Band 是一组节点("归类"后的一个分组)。Bands 按给定顺序从上到下排成带状。 +type Band struct { + Label string + Nodes []Node + Mode Mode +} + +// Bands 把分组排成横向带:每组占一条带,带内按各自模式排布,带间留 gap(默认 LaneGap)。 +// 这是"只做位置分带"的实现:不改变节点层级,也不创建容器节点。 +func Bands(bands []Band, gap float64) map[string]Position { + result := map[string]Position{} + if len(bands) == 0 { + return result + } + if gap <= 0 { + gap = LaneGap + } + all := make([]Node, 0) + for _, band := range bands { + all = append(all, band.Nodes...) + } + if len(all) == 0 { + return result + } + left, top := minX(all), minY(all) + bandTop := top + for _, band := range bands { + if len(band.Nodes) == 0 { + continue + } + mode := band.Mode + if mode == "" || mode == ModeAuto { + mode = ModeByLane + } + var positions map[string]Position + switch mode { + case ModeFlow: + positions = Flow(band.Nodes, nil) + case ModeByLane: + positions = ByLane(band.Nodes) + default: + positions = Linear(band.Nodes, mode) + } + // 把这一带整体移到带起点:纵向从 bandTop 开始,横向统一从 left 开始。 + bandLeft, bandTopValue := 0.0, 0.0 + first := true + for _, position := range positions { + if first { + bandLeft, bandTopValue, first = position.X, position.Y, false + continue + } + if position.X < bandLeft { + bandLeft = position.X + } + if position.Y < bandTopValue { + bandTopValue = position.Y + } + } + height := 0.0 + for id, position := range positions { + node := findNode(band.Nodes, id) + result[id] = Position{X: left + (position.X - bandLeft), Y: bandTop + (position.Y - bandTopValue)} + if bottom := result[id].Y + node.height() - bandTop; bottom > height { + height = bottom + } + } + bandTop += height + gap + } + return result +} + +func findNode(nodes []Node, id string) Node { + for _, node := range nodes { + if node.ID == id { + return node + } + } + return Node{} +} + +// Bounds 计算一组节点的包围盒(含尺寸)。 +func Bounds(nodes []Node) (left, top, right, bottom float64, ok bool) { + if len(nodes) == 0 { + return 0, 0, 0, 0, false + } + left, top = minX(nodes), minY(nodes) + for index, node := range nodes { + if index == 0 || node.X+node.width() > right { + right = node.X + node.width() + } + if index == 0 || node.Y+node.height() > bottom { + bottom = node.Y + node.height() + } + } + return left, top, right, bottom, true +} + +// overlaps 判断两个节点(含额外空隙)是否相交。 +func overlaps(first, second Node, gap float64) bool { + return first.X < second.X+second.width()+gap && + second.X < first.X+first.width()+gap && + first.Y < second.Y+second.height()+gap && + second.Y < first.Y+first.height()+gap +} + +// Overlaps 返回相互重叠(含 gap 余量)的节点对,用于回执里的自查提示。 +func Overlaps(nodes []Node, gap float64) [][2]string { + pairs := make([][2]string, 0) + for index := 0; index < len(nodes); index++ { + for next := index + 1; next < len(nodes); next++ { + if overlaps(nodes[index], nodes[next], gap) { + pairs = append(pairs, [2]string{nodes[index].ID, nodes[next].ID}) + } + } + } + return pairs +} + +// FreeSlot 为新增节点找一块不与已有节点重叠的空位。 +// +// 策略:先按泳道在同类节点右侧找空位;冲突则沿该泳道向右、再向下扫描(步长为节点尺寸 + 空隙)。 +// occupied 是画布现有节点(含本批已落位的新节点);preferred 非空时优先尝试该坐标(依赖落位)。 +func FreeSlot(width, height float64, lane string, occupied []Node, preferred *Position, gap float64) Position { + if width <= 0 { + width = 340 + } + if height <= 0 { + height = 240 + } + if gap <= 0 { + gap = PlacementGap + } + candidate := Node{Width: width, Height: height} + fits := func(x, y float64) bool { + candidate.X, candidate.Y = x, y + for _, node := range occupied { + if overlaps(candidate, node, gap) { + return false + } + } + return true + } + if preferred != nil && fits(preferred.X, preferred.Y) { + return *preferred + } + + // 起点:该泳道已有节点的最右下方;没有同类节点时退回画布左下角。 + sameLane := laneNodes(occupied, lane) + startX, startY := 0.0, 0.0 + if len(sameLane) > 0 { + startY = minY(sameLane) + startX = 0 + for _, node := range sameLane { + if right := node.X + node.width() + gap; right > startX { + startX = right + } + } + } else if len(occupied) > 0 { + _, _, right, _, _ := Bounds(occupied) + startX = right + gap + startY = minY(occupied) + } + if preferred != nil { + startX, startY = preferred.X, preferred.Y + } + stepX, stepY := width+gap, height+gap + for column := 0; column < 64; column++ { + for row := 0; row < 64; row++ { + x := startX + float64(column)*stepX + y := startY + float64(row)*stepY + if fits(x, y) { + return Position{X: x, Y: y} + } + } + } + return Position{X: startX, Y: startY} +} + +func ceilSqrt(value int) int { + if value <= 0 { + return 0 + } + root := 1 + for root*root < value { + root++ + } + return root +} + +func ceilDiv(value, divisor int) int { + if divisor <= 0 { + return 0 + } + return (value + divisor - 1) / divisor +} diff --git a/backend/internal/canvas/layout/layout_test.go b/backend/internal/canvas/layout/layout_test.go new file mode 100644 index 000000000..15e198ddc --- /dev/null +++ b/backend/internal/canvas/layout/layout_test.go @@ -0,0 +1,170 @@ +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 +} diff --git a/backend/internal/prompts/agent-system-policy.md b/backend/internal/prompts/agent-system-policy.md index 0d757c9da..792e0accb 100644 --- a/backend/internal/prompts/agent-system-policy.md +++ b/backend/internal/prompts/agent-system-policy.md @@ -1,6 +1,6 @@ --- id: cloud-agent-system -version: 7 +version: 8 --- # 影策 Cloud Agent 系统行为策略 @@ -47,6 +47,16 @@ version: 7 上一轮收束摘要只说明运行结果、失败原因和已提交任务;它不要求重做历史任务。已提交收费任务不得因续聊或恢复提示自动重发。 +## 布局与整理 + +节点位置属于用户手工编辑的空间,只在两种情况下整理:用户明确要求整理、归类或排版;或你新增的节点会与现有节点重叠(服务端在未给坐标时会自动落位,通常不需要你处理)。 + +- 整理用 `canvas_arrange_nodes`:先读取画布拿 `snapshotHash`,只改坐标,不改内容、不建连线、不改层级,也不把节点收进背板。 +- 归类用 `groups`:你决定"哪些节点属于同一场景/同一镜头组"和组的顺序,位置由服务端算;分组只形成横向位置带。 +- 新增节点时不要自己算坐标:省略 `x`/`y` 让服务端按画布内容落位(靠近依赖的输入在左、输出在右,或追加到同类节点之后)。只有用户明确要求某个位置,或需要微调单个节点时,才用 `update_node` 的 `x`/`y`。 +- 锁定节点、背板等容器节点、隐藏批次子节点和已归属背板的子节点不会被动;整理要跳过它们,不要为了统一外观去动用户的锁定布局。 +- 整理会改变画布快照:整理后要重新读取画布再继续写入,不要复用旧的 `snapshotHash`。 + ## 能力使用 以工具返回的能力清单为准,不猜测节点类型、字段、连接关系或模型选择。节点选择由你结合用户目标、镜头数量、连续性和后续维护需求判断:单个画面、一次性提示词或快速试验通常使用文本/Markdown与媒体节点更轻量;多镜头、逐镜审查、逐镜生成、资产关联或需要交接维护时,分镜脚本通常更合适。不要为了形式创建复杂节点,也不要用普通文本伪装成结构化分镜;如果选择轻量节点处理多镜头草稿,不要声称它具备镜头级维护能力。结构化数据节点应通过其专用字段和分页读取,不把整份数据塞进上下文。`sourceNodeId` 只用于能力清单声明为文本输入来源的节点;媒体资产只通过 `referenceNodeIds` 建立真实引用,文字中的编号不能替代引用。模型选择必须复制模型目录返回的完整 `selection`,不要把任意 ID 猜成 `logicalModelId`。 diff --git a/backend/internal/prompts/agent_policy_test.go b/backend/internal/prompts/agent_policy_test.go index 8bef4bc8f..bdb66a1d6 100644 --- a/backend/internal/prompts/agent_policy_test.go +++ b/backend/internal/prompts/agent_policy_test.go @@ -10,7 +10,7 @@ func TestLoadAgentPoliciesUsesDocumentMetadata(t *testing.T) { if err != nil { t.Fatal(err) } - if system.ID != "cloud-agent-system" || system.Version != 7 || media.ID != "cloud-agent-media" || media.Version != 3 { + if system.ID != "cloud-agent-system" || system.Version != 8 || media.ID != "cloud-agent-media" || media.Version != 3 { t.Fatalf("unexpected policy metadata: system=%+v media=%+v", system, media) } if strings.Contains(system.Text, "id: cloud-agent-system") || !strings.HasPrefix(system.Text, "# 影策 Cloud Agent") { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 6dc74ade1..19e8e86f5 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -154,13 +154,14 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 - 系统行为策略与媒体默认策略是版本化、编译进后端发布物的资源,修改它们仍需发布后端,不是后台热更新;工具、校验、扣费和审批在代码侧强制执行。画布节点能力由服务端 registry 定义,工具 schema、创建、连线与画布摘要读取共同消费;新生成模式还必须接入媒体任务适配器,不能仅凭 md 或注册节点启用收费操作。 - 已安装且属于当前用户的技能在创建时只保存版本、哈希和文本文件路径,不预存全部正文,也不按所有选中技能正文合计 128KB 拒绝创建。`skill_read_file` 按需读取 `SKILL.md` 或必要引用,支持字符偏移 `offset`(默认 0),每页最多 12000 字符,返回 `hasMore` / `nextOffset`。读取时重新验证用户权限、启用状态和版本;技能更新后拒绝混读,需新开一轮。技能是不可信数据,不能创造工具、改变权限或覆盖系统规则。 - Agent 按实际文本模型的 `contextWindowTokens` 与 `maxOutputTokens` 计算输入预算,并在达到该模型预算的压缩阈值时移除较早工具结果中可重新读取的正文,保留工具调用/结果配对、用户原始要求、生成回执及最近完整工具轮次,并允许重新读取。此机制是确定性正文卸载,不是 LLM 语义摘要;运行 checkpoint 的持久化上限与模型上下文窗口独立。技能读取事件只保存回执,不复制正文,正文页只进入模型对话。 -- 平台支持工具全集为 `agent_profile_read`、`plan_update`、`ask_user`、`recall_lessons`、`remember_lesson`、`canvas_list_node_types`、`canvas_get_state`、`canvas_inspect_image`、`skill_read_file`、`task_get`、`model_list`、`canvas_apply_ops`、`generate_media`;单轮实际工具仍按偏好快照、技能、权限、画布上下文和生成预算裁剪。画布写入工具要求本轮授权 `contextScope: ["canvas"]`。禁止 shell、任意网络请求和外部 MCP。`remember_lesson` 在 `read_only` 下不可用。 - 画布 Agent 的看图能力:当且仅当本轮渠道模型的能力合同声明了图片输入(`text.references.maxImages > 0`)时,额外暴露 `canvas_inspect_image(nodeId)`。该工具校验目标节点是已就绪、归属当前用户、媒体类型为 `image/*` 的素材,然后签发**短时签名下载链接**并把图片作为 `image_url` 内容块交给上游(与生成任务参考图同一套机制),因此 Agent 上下文只增长一个 URL,不装 base64;图片在上下文里保留最近 3 个工具轮次,更早的由运行期裁剪移出并保留文本回执与模型自己写下的观察;同一张图一轮内看满 2 次后只回执文字、不再附图,确需重新确认画面时传 `refresh=true`。看到图后模型写进正文的那句话会记到锚点(`visualNote`),跨轮继承后可以直接复用;查看过的素材在锚点里记为 `visualIdentity=inspected`。画面内文字按不可信数据处理。该能力依赖服务器访问地址:本地存储需要配置 `CANVAS_PUBLIC_BASE_URL`,且该地址必须能被模型上游访问到;上游取不到图片时如实失败,服务端不做取图内联兜底。 +- 平台支持工具全集为 `agent_profile_read`、`plan_update`、`ask_user`、`recall_lessons`、`remember_lesson`、`canvas_list_node_types`、`canvas_get_state`、`canvas_inspect_image`、`canvas_read_batch_table`、`canvas_read_storyboard`、`skill_read_file`、`skill_search`、`task_get`、`model_list`、`canvas_create_storyboard`、`canvas_edit_storyboard`、`canvas_edit_batch_table`、`canvas_apply_ops`、`canvas_arrange_nodes`、`generate_media`、`image_layer_split`、`image_text_detect`、`image_annotation_render`;单轮实际工具仍按偏好快照、技能、权限、画布上下文和生成预算裁剪。画布写入工具要求本轮授权 `contextScope: ["canvas"]`。禁止 shell、任意网络请求和外部 MCP。`remember_lesson` 在 `read_only` 下不可用。 - `plan_update` 维护本轮结构化待办清单(整表替换,最多 20 项),跨轮继承未完成项,并在界面以 PlanBar 展示。权限为 `request_approval` 时,首次建立至少 2 项的清单会先进入审批;`auto` 直接写入清单。新一轮的第一次模型请求就会带上继承的清单。模型在没有工具调用且清单仍有未完成项时,服务端会催办并要求继续调用工具;这是软约束,不验证工作是否真的完成,也不会在没有清单时强行催建。 - `ask_user` 给出一个问题与 2–6 个候选项后结束本轮,界面弹出选项面板;用户点选或输入后作为新一轮继续。运行中也可以用插话直接回答。 - `recall_lessons` / `remember_lesson` 是当前用户的个人记忆:已批准条目在系统提示里只出现索引(标题 + 适用场景,最多 20 条,与当前目标可能相关的排在前面),完整做法由模型用 `recall_lessons(topic/keyword/category)` 取;Agent 写入先入待审,用户在 `GET/POST/PATCH/DELETE /agent/memories` 与 `POST /agent/memories/:id/decide` 自己批准、添加、编辑、删除,也可用 `GET /agent/memories/export`、`POST /agent/memories/import` 导出导入,并用 `GET/PATCH /agent/memories/settings`、`POST /agent/memories/compact` 选择文本模型后手动或按日/周/月压缩。管理员只在 `GET/DELETE /admin/agent-lessons` 按 `userId` / `keyword` / `status` 查看或删除,不能代批。写入会机械脱敏(拒绝链接、资源键、节点/任务 ID;@引用允许),只读模式不能写,本轮最多 3 条、每位用户最多 50 条待审、300 条已批准,一次导入最多 200 条。召回接口不返回作者。记忆只作参考,不能覆盖工具契约、权限、计费或审批。 - `canvas_list_node_types` 从服务端节点能力 registry 返回当前可用类型;可更新节点的 `updateFields` 同时包含字段类型、最大字符数和字段语义。模型不能从前端渲染器、技能或偏好文档推断额外能力。 -- `canvas_apply_ops` 必须带 `snapshotHash`,每批最多 20 项;支持新增文本、Markdown、图片、视频、音频、分组与分镜表节点,以及 `connect_nodes` 引用连线。媒体占位节点的 `content` 是提示词,不是媒体 URL;只建节点不提交收费生成。`update_node` 的字段映射完全由能力 registry 决定:文本 / Markdown 可改标题和正文,图片 / 视频 / 音频可改标题和下一次生成使用的提示词草稿;媒体更新参数中的 `content` 写入 `metadata.composerContent`,不会覆盖媒体结果 `metadata.content`、已提交提示词 `metadata.prompt`、任务状态或引用状态。锁定节点、删除、任意元数据写入、自连接、重复边与不存在的端点仍被服务端拒绝。 +- `canvas_apply_ops` 必须带 `snapshotHash`,每批最多 20 项;`add_node` 可省略坐标:服务端按画布内容落位(优先贴合本批连线的对端,否则追加到同泳道之后并避让已有节点),不再落到原点重叠;`update_node` 的 patch 额外支持 `x`/`y`(坐标绝对值上限 1e6)用于微调单个节点。支持新增文本、Markdown、图片、视频、音频、分组与分镜表节点,以及 `connect_nodes` 引用连线。媒体占位节点的 `content` 是提示词,不是媒体 URL;只建节点不提交收费生成。`update_node` 的字段映射完全由能力 registry 决定:文本 / Markdown 可改标题和正文,图片 / 视频 / 音频可改标题和下一次生成使用的提示词草稿;媒体更新参数中的 `content` 写入 `metadata.composerContent`,不会覆盖媒体结果 `metadata.content`、已提交提示词 `metadata.prompt`、任务状态或引用状态。锁定节点、删除、任意元数据写入、自连接、重复边与不存在的端点仍被服务端拒绝。 +- `canvas_arrange_nodes` 只改节点坐标:`snapshotHash` 必需,`mode` 取 `auto`(有引用连线按依赖分层、否则按媒体类型分区)/`flow`/`byType`/`row`/`column`/`grid`,`groups` 把节点按给定顺序排成横向位置带(仅位置分带,不创建容器、不改层级),`align` 做对齐或等距分布,`gap` 调整组间距,`dryRun` 只预演。`nodeIds` 省略时整理全部可整理节点,锁定节点、背板等容器节点、隐藏批次子节点与已归属背板的子节点保持不动并在回执中说明;一次最多 50 个节点。整理与 `add_node` 的自动落位共用服务端布局实现(`backend/internal/canvas/layout`),与前端“自动整理节点”使用同一套常量与算法。 - `model_list` 复用生成面板的当前模型目录。生成前传 `mode` 和实际 `referenceNodeIds`(无参考传空数组),服务端读取当前用户画布内的就绪资源,按真实素材类型、数量及生成操作构建意图并调用统一能力匹配,不让多图生视频落入仅文生视频候选;返回 `intent` 供核对。空参数仍可浏览完整目录;空候选不降级到不匹配模型。每项的 `selection` 是 `logicalModelId` 或 `channelId` + `channelModelKey`,工具调用必须原样选择一种,不猜 ID 或混用;时长、画幅、音频、价格仍需核对返回配置,任务准入再次强校验。 - `generate_media` 支持图片 / 视频 / 音频:必填 `mode`、`prompt`、`nodeId`(可续用草稿,无草稿时才新建)、`title`、有序 `referenceNodeIds`(无参考传空数组),再加一种模型选择;`snapshotHash` 可省略,省略时服务端使用当前画布内容快照。图片 / 视频要求 `size`,视频要求正数 `durationSeconds`,图片 / 音频时长为 0。可传来源节点 `sourceNodeId`、`quality`、视频 `videoGenerateAudio`。`sourceNodeId` 仅接文本 / 镜头提示词节点;参考图片、视频、音频只放 `referenceNodeIds`,无文本来源时留空且不重复传入。引用从当前画布节点的账号资源解析,校验归属、类型、就绪状态和模型能力,不接受任意 URL。图片生成仅接图片参考;视频可按模型能力接图片、视频、音频参考;音频生成当前仅支持文本输入。 - 参数选择与收费授权分开:用户明确说“随便 / 你决定 / 按默认”时,Agent 可从当前目录选择能力及预算匹配的模型,优先用户最近明确使用的可用模型,其次价格清楚的低成本选项;支持时沿用参考图原始比例,否则选目录默认,时长用目录默认。未要求音频且模型不支持时用默片。用户接受已推荐方案后不再要求特定确认口令;仅真正缺失或冲突的阻塞项一次性询问。本轮已有目录结果应复用。以上是 Agent 的参数选择策略,服务端仍强校验具体规格,不提供绕过预算 / 能力校验的默认兜底,也不绕过独立收费审批。 diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 71de8c348..cc1aee498 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -1102,7 +1102,7 @@ function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange } function ApprovalPreviewItemView({ item, theme, onFocusNode }: { item: ReturnType["items"][number]; theme: CanvasTheme; onFocusNode?: (nodeId: string) => void }) { - const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成"; + const operationLabel = item.operation === "add_node" ? "新增" : item.operation === "update_node" ? "修改" : item.operation === "connect_nodes" ? "连线" : item.operation === "arrange_nodes" ? "整理" : item.operation === "create_storyboard" ? "创建分镜" : item.operation === "edit_storyboard" ? "修改分镜" : item.operation === "plan_step" ? "计划" : "生成"; const renderNode = (title: string | undefined, id: string | undefined, typeLabel: string | undefined, role: "source" | "target" | "node") => { if (!title) return null; const content = <>{title}{typeLabel ? {typeLabel} : null}; diff --git a/web/src/components/canvas/canvas-cloud-agent.css b/web/src/components/canvas/canvas-cloud-agent.css index 434344ce6..ed8edf58c 100644 --- a/web/src/components/canvas/canvas-cloud-agent.css +++ b/web/src/components/canvas/canvas-cloud-agent.css @@ -931,6 +931,7 @@ .canvas-agent-approval-operation-add_node { background: #dcfce7; color: #166534; } .canvas-agent-approval-operation-update_node { background: #dbeafe; color: #1d4ed8; } .canvas-agent-approval-operation-connect_nodes { background: #fef3c7; color: #92400e; } +.canvas-agent-approval-operation-arrange_nodes { background: #ccfbf1; color: #115e59; } .canvas-agent-approval-operation-generate_media { background: #f3e8ff; color: #7e22ce; } .canvas-agent-approval-node, diff --git a/web/src/lib/canvas/agent-approval-presentation.ts b/web/src/lib/canvas/agent-approval-presentation.ts index 15969a34d..20566a1db 100644 --- a/web/src/lib/canvas/agent-approval-presentation.ts +++ b/web/src/lib/canvas/agent-approval-presentation.ts @@ -41,7 +41,7 @@ function fieldLabel(key: string, nodeType: string): string { } function operation(value: unknown): AgentApprovalPreviewOperation | null { - return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null; + return value === "add_node" || value === "update_node" || value === "connect_nodes" || value === "arrange_nodes" || value === "generate_media" || value === "create_storyboard" || value === "edit_storyboard" || value === "plan_step" ? value : null; } function normalizeServerPreview(value: unknown): AgentApprovalPresentation | null { diff --git a/web/src/services/api/agent.ts b/web/src/services/api/agent.ts index 4c70326ad..4a945e847 100644 --- a/web/src/services/api/agent.ts +++ b/web/src/services/api/agent.ts @@ -27,7 +27,7 @@ export type AgentProfileView = { layers: AgentProfileLayer[]; }; -export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "plan_step"; +export type AgentApprovalPreviewOperation = "add_node" | "update_node" | "connect_nodes" | "arrange_nodes" | "generate_media" | "create_storyboard" | "edit_storyboard" | "plan_step"; export type AgentApprovalPreviewItem = { operation: AgentApprovalPreviewOperation; diff --git a/web/test/agent-approval-presentation.test.ts b/web/test/agent-approval-presentation.test.ts index f815881b8..1a4c16666 100644 --- a/web/test/agent-approval-presentation.test.ts +++ b/web/test/agent-approval-presentation.test.ts @@ -64,6 +64,20 @@ describe("Agent approval presentation", () => { expect(JSON.stringify(view)).not.toContain("private"); }); + it("keeps an arrange_nodes preview item instead of dropping it as an unknown operation", () => { + // 未知 operation 会被 operation() 判成 null 并丢掉整条预览项,因此新增的 + // arrange_nodes 必须同时进联合类型与判据,否则审批卡只剩"生成"这个兜底标签。 + const view = agentApprovalPresentation({ preview: { + kind: "canvas_mutation", + title: "确认整理画布", + description: "请确认要整理的节点。", + items: [{ operation: "arrange_nodes", nodeId: "text-1", nodeTitle: "开场分镜", nodeTypeLabel: "文本", summary: "整理《开场分镜》" }], + } }); + expect(view.items).toHaveLength(1); + expect(view.items[0].operation).toBe("arrange_nodes"); + expect(view.items[0].nodeTitle).toBe("开场分镜"); + }); + it("states when an unknown approval cannot identify a target", () => { const view = agentApprovalPresentation({ toolName: "unknown_tool", arguments: {} }); expect(view.items).toHaveLength(0);