Merge remote-tracking branch 'origin/pr-589' into codex/pr-557-589-security-integration

# Conflicts:
#	docs/content/docs/backend/http-api.mdx
This commit is contained in:
ddCat
2026-09-22 11:20:54 +08:00
20 changed files with 2036 additions and 34 deletions

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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":

View File

@@ -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))
}
}

View File

@@ -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.