Files
ttstd-infinite-canvas/backend/internal/app/cloud_agent_approval_preview.go

304 lines
11 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/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) {
var args agentCanvasArgs
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil {
return nil, canvasArgumentError()
}
if len(args.Ops) < 1 || len(args.Ops) > 20 || args.SnapshotHash == "" {
return nil, BadAuthRequest("画布操作数量或快照无效")
}
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, creationConflict("画布已变化,本次未写入;请重新读取并重新申请审批")
}
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 _, 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("不支持的节点类型")
}
node := creationAddedNode(CreationCanvasOp{Type: op.Type, ID: op.ID, NodeType: op.NodeType, Title: title, X: &op.X, Y: &op.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, 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 {
return nil, 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[:])
}