Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_approval_preview.go
AetherNo2332 56598108d4 feat(canvas): 云端 Agent 画布布局 - 新增 canvas_arrange_nodes 与新增节点自动落位
云端 Agent 此前只能新建节点与连线,既不会排版也不能移动已有节点:模型只能用几十个
update_node 手算坐标,或者让用户自己拖。

- 新增 backend/internal/canvas/layout:把前端既有的画布布局算法(web/src/lib/canvas/
  canvas-layout.ts,本 PR 不改该文件)按同一套常量移植到服务端,提供 Kahn 拓扑分层 flow、
  媒体泳道 byType、row/column/grid、对齐与等距、分组分带、AABB 重叠检测与空位搜索。
- 新增写工具 canvas_arrange_nodes:snapshotHash 必需,mode∈{auto,flow,byType,row,column,
  grid},可传 nodeIds、groups(只做横向位置分带,不建容器、不改层级)、align、gap、dryRun。
  只改坐标,不改内容、不建连线、不增删节点;一次最多 50 个节点 / 12 组。锁定节点、背板等
  容器、隐藏批次子节点与已归属背板的子节点保持不动并在回执里说明;整批复用现有 mutation
  记录与事件管线,审批与撤销走同一条路径。
- add_node 省略坐标时不再落到 (0,0):优先贴合本批连线的对端(输入在左、输出在右),否则
  追加到同泳道同类节点之后,冲突则扫描空位;只给一个轴时保留该轴。agentCanvasOp.X/Y 改为
  指针以区分"没给坐标"与"坐标 0"。
- update_node 的 patch 新增 x/y(PatchField.Limit 校验 ±1e6),供模型微调单个节点。
- 系统行为策略新增「布局与整理」一节并升版到 8;docs 补工具说明与工具全集;前端同步审批
  预览联合类型、面板文案与徽标。
- 工具 schema 预算按实测从 24000 上调到 25000(21 个工具 24788 字节),并改正注释里
  "上游约 23.4 KB"的说法(上游 20 个工具实测 22777 字节)。
2026-09-22 01:32:20 +08:00

331 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"unicode/utf8"
"infinite-canvas/backend/internal/canvas/capability"
"infinite-canvas/backend/internal/canvas/layout"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
)
// cloudAgentApprovalPreview is server-authored explanatory data. It never
// grants capabilities: execution still validates the original call, snapshot,
// ownership, node registry, locks and budget after approval.
type cloudAgentApprovalPreview struct {
Kind string `json:"kind"`
Title string `json:"title"`
Description string `json:"description"`
Items []cloudAgentApprovalPreviewItem `json:"items"`
}
type cloudAgentApprovalPreviewItem struct {
Operation string `json:"operation"`
NodeID string `json:"nodeId,omitempty"`
NodeTitle string `json:"nodeTitle,omitempty"`
ResultTitle string `json:"resultTitle,omitempty"`
NodeType string `json:"nodeType,omitempty"`
NodeTypeLabel string `json:"nodeTypeLabel,omitempty"`
TargetNodeID string `json:"targetNodeId,omitempty"`
TargetNodeTitle string `json:"targetNodeTitle,omitempty"`
TargetNodeType string `json:"targetNodeType,omitempty"`
Fields []string `json:"fields,omitempty"`
Details []string `json:"details,omitempty"`
Summary string `json:"summary"`
}
type cloudAgentCanvasMutationPlan struct {
Args agentCanvasArgs
Canvas *model.CanvasProject
Document map[string]any
BeforeJSON string
BeforeSnapshotHash string
Preview cloudAgentApprovalPreview
}
// prepareCloudAgentCanvasMutation is the single dry-run and execution planner.
// Approval previews and the eventual write therefore share the exact same
// parser, snapshot check and capability validation instead of drifting apart.
func prepareCloudAgentCanvasMutation(repo *repository.Repository, userID, canvasID string, call cloudAgentCall) (*cloudAgentCanvasMutationPlan, error) {
args, err := decodeCloudAgentCanvasArgs(call.Function.Arguments)
if err != nil {
return nil, err
}
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 != args.SnapshotHash {
return nil, &cloudAgentFieldArgumentError{error: &cloudAgentArgumentError{creationConflict("画布已变化,本次未写入;请重新读取并重新申请审批")}, Field: "snapshotHash", Issue: "stale_snapshot"}
}
items, err := applyCloudAgentCanvasPlan(doc, args.Ops)
if err != nil {
return nil, err
}
return &cloudAgentCanvasMutationPlan{
Args: args,
Canvas: canvas,
Document: doc,
BeforeJSON: canvas.PayloadJSON,
BeforeSnapshotHash: beforeHash,
Preview: cloudAgentCanvasApprovalPreview(items),
}, nil
}
func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloudAgentApprovalPreviewItem, error) {
nodes := creationMaps(doc["nodes"])
edges := creationMaps(doc["connections"])
items := make([]cloudAgentApprovalPreviewItem, 0, len(ops))
for opIndex, op := range ops {
title, content := "", ""
if op.Title != nil {
title = *op.Title
}
if op.Content != nil {
content = *op.Content
}
if err := validateCloudAgentID(op.ID, "节点或连线 ID", 80); err != nil {
return nil, err
}
if op.Type == "add_node" && (utf8.RuneCountInString(content) > 16000 || utf8.RuneCountInString(title) > 240) {
return nil, BadAuthRequest("节点标题或正文超出限制")
}
index := cloudAgentNodeIndex(nodes, op.ID)
switch op.Type {
case "add_node":
if index >= 0 {
return nil, BadAuthRequest("新增节点ID重复")
}
capability, ok := cloudAgentNodeCapabilityForType(op.NodeType)
if strings.TrimSpace(op.NodeType) == "" {
return nil, BadAuthRequest("新增节点缺少 nodeType")
}
if !ok {
return nil, BadAuthRequest("不支持的节点类型")
}
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{
Operation: "add_node", NodeID: op.ID, NodeTitle: nodeTitle,
NodeType: capability.Type, NodeTypeLabel: capability.Label,
Summary: fmt.Sprintf("新增%s《%s》", capability.Label, nodeTitle),
})
case "connect_nodes":
if err := validateCloudAgentID(op.FromNodeID, "来源节点 ID", 80); err != nil {
return nil, err
}
if err := validateCloudAgentID(op.ToNodeID, "目标节点 ID", 80); err != nil {
return nil, err
}
fromIndex, toIndex := cloudAgentNodeIndex(nodes, op.FromNodeID), cloudAgentNodeIndex(nodes, op.ToNodeID)
if fromIndex < 0 || toIndex < 0 || op.FromNodeID == op.ToNodeID {
return nil, BadAuthRequest("连线端点不存在或指向自身")
}
if err := validateCloudAgentConnection(nodes, op.FromNodeID, op.ToNodeID, edges); err != nil {
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d]", opIndex), "invalid_connection", cloudAgentSafeToolError(err))
}
for _, edge := range edges {
if stringValue(edge["id"]) == op.ID || (stringValue(edge["fromNodeId"]) == op.FromNodeID && stringValue(edge["toNodeId"]) == op.ToNodeID) {
return nil, BadAuthRequest("连线重复")
}
}
fromCapability, _ := cloudAgentNodeCapabilityForType(stringValue(nodes[fromIndex]["type"]))
toCapability, _ := cloudAgentNodeCapabilityForType(stringValue(nodes[toIndex]["type"]))
fromTitle := cloudAgentApprovalNodeTitle(nodes[fromIndex], fromCapability.Label)
toTitle := cloudAgentApprovalNodeTitle(nodes[toIndex], toCapability.Label)
edges = append(edges, map[string]any{"id": op.ID, "fromNodeId": op.FromNodeID, "toNodeId": op.ToNodeID})
items = append(items, cloudAgentApprovalPreviewItem{
Operation: "connect_nodes", NodeID: op.FromNodeID, NodeTitle: fromTitle,
NodeType: fromCapability.Type, NodeTypeLabel: fromCapability.Label,
TargetNodeID: op.ToNodeID, TargetNodeTitle: toTitle, TargetNodeType: toCapability.Type,
Summary: fmt.Sprintf("建立《%s》→《%s》的引用连线", fromTitle, toTitle),
})
case "update_node":
if len(op.Patch) == 0 {
// 漏字段是模型照 schema 就能自己修好的参数错误:当成工具结果回给它重试,
// 而不是判整轮失败(用户只在失败提示里看到一句"必须提供 patch")。
// 未知操作类型仍按准入失败终止(cloud_agent_test.go 有用例断言这一行为)。
return nil, &cloudAgentArgumentError{BadAuthRequest("更新节点必须提供 patch")}
}
if index < 0 {
return nil, BadAuthRequest("只能更新现有且受 Agent 支持的节点")
}
capability, ok := cloudAgentNodeCapabilityForType(stringValue(nodes[index]["type"]))
if !ok || !capability.CanUpdate {
return nil, BadAuthRequest("该节点类型不支持 Agent 更新")
}
metadata, _ := nodes[index]["metadata"].(map[string]any)
if metadata["locked"] == true {
return nil, BadAuthRequest("不能修改锁定节点")
}
beforeTitle := cloudAgentApprovalNodeTitle(nodes[index], capability.Label)
fields := cloudAgentApprovalPatchLabels(capability.PatchFields, op.Patch)
if err := capability.ApplyPatch(nodes[index], op.Patch); err != nil {
return nil, BadAuthRequest(err.Error())
}
afterTitle := cloudAgentApprovalNodeTitle(nodes[index], capability.Label)
resultTitle := ""
if afterTitle != beforeTitle {
resultTitle = afterTitle
}
items = append(items, cloudAgentApprovalPreviewItem{
Operation: "update_node", NodeID: op.ID, NodeTitle: beforeTitle, ResultTitle: resultTitle,
NodeType: capability.Type, NodeTypeLabel: capability.Label, Fields: fields,
Summary: fmt.Sprintf("修改%s《%s》的%s", capability.Label, beforeTitle, strings.Join(fields, "、")),
})
default:
return nil, BadAuthRequest("不支持的画布写操作")
}
}
doc["nodes"] = nodes
doc["connections"] = edges
return items, nil
}
func cloudAgentCanvasApprovalPreview(items []cloudAgentApprovalPreviewItem) cloudAgentApprovalPreview {
counts := map[string]int{}
for _, item := range items {
counts[item.Operation]++
}
parts := make([]string, 0, 3)
if counts["add_node"] > 0 {
parts = append(parts, fmt.Sprintf("新增 %d 个节点", counts["add_node"]))
}
if counts["update_node"] > 0 {
parts = append(parts, fmt.Sprintf("修改 %d 个节点", counts["update_node"]))
}
if counts["connect_nodes"] > 0 {
parts = append(parts, fmt.Sprintf("建立 %d 条引用连线", counts["connect_nodes"]))
}
return cloudAgentApprovalPreview{
Kind: "canvas_mutation", Title: "确认画布修改",
Description: fmt.Sprintf("Agent 准备%s。请确认目标节点和修改字段;批准后才会写入画布。", strings.Join(parts, ",")),
Items: items,
}
}
func cloudAgentMediaApprovalPreview(plan *cloudAgentMediaPlan, modelName string) cloudAgentApprovalPreview {
args := plan.Args
descriptor, _ := cloudAgentNodeCapabilityForGenerationMode(args.Mode)
nodeTitle := truncateRunes(strings.TrimSpace(args.Title), 120)
if nodeTitle == "" {
nodeTitle = "未命名" + descriptor.Label
}
details := make([]string, 0, 6)
if modelName != "" {
details = append(details, "模型:"+truncateRunes(modelName, 120))
}
if len(args.ReferenceNodeIDs) > 0 {
details = append(details, fmt.Sprintf("引用 %d 个画布资产并建立连线", len(args.ReferenceNodeIDs)))
} else {
details = append(details, "不引用画布媒体资产")
}
if args.Duration > 0 {
details = append(details, fmt.Sprintf("时长:%d 秒", args.Duration))
}
if args.Size != "" {
details = append(details, "画幅:"+truncateRunes(args.Size, 40))
}
if args.Quality != "" {
details = append(details, "质量:"+truncateRunes(args.Quality, 40))
}
if args.VideoGenerateAudio != nil {
value := "关闭"
if *args.VideoGenerateAudio {
value = "开启"
}
details = append(details, "音频:"+value)
}
return cloudAgentApprovalPreview{
Kind: "media_generation", Title: "确认生成" + descriptor.Label,
Description: descriptor.Label + "草稿节点和引用连线已创建,尚未提交生成。确认规格后批准才会提交收费任务;拒绝则保留草稿,结果自动回写画布。",
Items: []cloudAgentApprovalPreviewItem{{
Operation: "generate_media", NodeID: args.NodeID, NodeTitle: nodeTitle,
NodeType: descriptor.Type, NodeTypeLabel: descriptor.Label, Details: details,
Summary: "生成" + descriptor.Label + "《" + nodeTitle + "》",
}},
}
}
func cloudAgentApprovalPatchLabels(fields map[string]capability.PatchField, patch map[string]any) []string {
keys := make([]string, 0, len(patch))
for key := range patch {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
left, right := fields[keys[i]], fields[keys[j]]
if left.Order != right.Order {
return left.Order < right.Order
}
return keys[i] < keys[j]
})
labels := make([]string, 0, len(keys))
for _, key := range keys {
if field, ok := fields[key]; ok {
labels = append(labels, field.Label)
}
}
return labels
}
func cloudAgentNodeIndex(nodes []map[string]any, id string) int {
for index, node := range nodes {
if stringValue(node["id"]) == id {
return index
}
}
return -1
}
func cloudAgentApprovalNodeTitle(node map[string]any, typeLabel string) string {
title := truncateRunes(strings.TrimSpace(stringValue(node["title"])), 120)
if title != "" {
return title
}
return "未命名" + typeLabel
}
func cloudAgentApprovalCallHash(call cloudAgentCall) string {
raw, _ := json.Marshal(call)
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}