feat(canvas): 云端 Agent - 完成策略分层、节点能力注册与可解释审批改造
This commit is contained in:
1 parent
4b0e9d8965
commit
01486872c4
65 files changed
+5347
-918
No files matched your search
@@ -0,0 +1,303 @@
|
||||
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, BadAuthRequest("画布工具参数必须是只含支持字段的单个JSON对象")
|
||||
}
|
||||
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[:])
|
||||
}
|
||||
Reference in new issue
Block a user