feat(canvas): 云端 Agent 画布 - 支持分镜连接、脚本节点与资产关系持久化
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
// ownership, node registry, locks and budget after approval.
|
||||
type cloudAgentApprovalPreview struct {
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Items []cloudAgentApprovalPreviewItem `json:"items"`
|
||||
@@ -150,32 +151,52 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud
|
||||
})
|
||||
case "connect_nodes":
|
||||
if err := validateCloudAgentID(op.FromNodeID, "来源节点 ID", 80); err != nil {
|
||||
return nil, err
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].fromNodeId", opIndex), "invalid_value", cloudAgentSafeToolError(err))
|
||||
}
|
||||
if err := validateCloudAgentID(op.ToNodeID, "目标节点 ID", 80); err != nil {
|
||||
return nil, err
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].toNodeId", opIndex), "invalid_value", cloudAgentSafeToolError(err))
|
||||
}
|
||||
fromIndex, toIndex := cloudAgentNodeIndex(nodes, op.FromNodeID), cloudAgentNodeIndex(nodes, op.ToNodeID)
|
||||
if fromIndex < 0 || toIndex < 0 || op.FromNodeID == op.ToNodeID {
|
||||
return nil, BadAuthRequest("连线端点不存在或指向自身")
|
||||
switch {
|
||||
case fromIndex < 0:
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].fromNodeId", opIndex), "not_found", "连线来源节点不在当前画布;请重新读取画布并使用真实节点 ID")
|
||||
case toIndex < 0:
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].toNodeId", opIndex), "not_found", "连线目标节点不在当前画布;请重新读取画布并使用真实节点 ID")
|
||||
case op.FromNodeID == op.ToNodeID:
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].toNodeId", opIndex), "invalid_value", "连线不能指向自身;请选择另一个目标节点")
|
||||
}
|
||||
if err := validateCloudAgentConnection(nodes, op.FromNodeID, op.ToNodeID, edges); err != nil {
|
||||
if err := validateCloudAgentConnectionWithHandles(nodes, op.FromNodeID, op.ToNodeID, op.FromHandleID, op.ToHandleID, 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("连线重复")
|
||||
if stringValue(edge["id"]) == op.ID {
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].id", opIndex), "duplicate", "连线 ID 已存在;请为新连线指定未使用的 ID")
|
||||
}
|
||||
if stringValue(edge["fromNodeId"]) == op.FromNodeID && stringValue(edge["toNodeId"]) == op.ToNodeID && stringValue(edge["fromHandleId"]) == op.FromHandleID && stringValue(edge["toHandleId"]) == op.ToHandleID {
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d]", opIndex), "duplicate", "这两个节点与 handle 之间已有连线;请移除重复操作")
|
||||
}
|
||||
}
|
||||
fromCapability, _ := cloudAgentNodeCapabilityForNode(nodes[fromIndex])
|
||||
toCapability, _ := cloudAgentNodeCapabilityForNode(nodes[toIndex])
|
||||
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})
|
||||
edge := map[string]any{"id": op.ID, "fromNodeId": op.FromNodeID, "toNodeId": op.ToNodeID}
|
||||
if op.FromHandleID != "" {
|
||||
edge["fromHandleId"] = op.FromHandleID
|
||||
}
|
||||
if op.ToHandleID != "" {
|
||||
edge["toHandleId"] = op.ToHandleID
|
||||
}
|
||||
edges = append(edges, edge)
|
||||
if err := applyCloudAgentStoryboardConnection(nodes, op.FromNodeID, op.ToNodeID, op.FromHandleID, op.ToHandleID); err != nil {
|
||||
return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d]", opIndex), "invalid_connection", cloudAgentSafeToolError(err))
|
||||
}
|
||||
details := cloudAgentStoryboardConnectionDetails(op.FromHandleID, op.ToHandleID)
|
||||
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,
|
||||
Details: details,
|
||||
Summary: fmt.Sprintf("建立《%s》→《%s》的引用连线", fromTitle, toTitle),
|
||||
})
|
||||
case "update_node":
|
||||
@@ -220,6 +241,189 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func applyCloudAgentStoryboardConnection(nodes []map[string]any, fromNodeID, toNodeID, fromHandleID, toHandleID string) error {
|
||||
if fromHandleID == "" && toHandleID == "" {
|
||||
return nil
|
||||
}
|
||||
from := cloudAgentNodeByID(nodes, fromNodeID)
|
||||
to := cloudAgentNodeByID(nodes, toNodeID)
|
||||
if from == nil || to == nil {
|
||||
return BadAuthRequest("分镜连线端点不存在")
|
||||
}
|
||||
scriptNode, linkedNode, handleID := from, to, fromHandleID
|
||||
scriptIsSource := true
|
||||
if handleID == "" {
|
||||
scriptNode, linkedNode, handleID = to, from, toHandleID
|
||||
scriptIsSource = false
|
||||
}
|
||||
if stringValue(scriptNode["type"]) != "script" {
|
||||
return BadAuthRequest("分镜 handle 必须挂在分镜脚本节点上")
|
||||
}
|
||||
metadata, ok := scriptNode["metadata"].(map[string]any)
|
||||
if !ok {
|
||||
return BadAuthRequest("分镜节点数据格式无效")
|
||||
}
|
||||
storyboard, ok := metadata["storyboard"].(map[string]any)
|
||||
if !ok {
|
||||
return BadAuthRequest("分镜节点缺少结构化表格")
|
||||
}
|
||||
if handleID == "storyboard:context" {
|
||||
ids := stringIDs(storyboard["referenceNodeIds"])
|
||||
linkedID := stringValue(linkedNode["id"])
|
||||
if linkedID != "" && !containsStoryboardString(ids, linkedID) {
|
||||
storyboard["referenceNodeIds"] = append(idsAsAny(ids), linkedID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rowID := strings.TrimPrefix(handleID, "row:")
|
||||
var row map[string]any
|
||||
for _, candidate := range creationMaps(storyboard["rows"]) {
|
||||
if stringValue(candidate["id"]) == rowID {
|
||||
row = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
return BadAuthRequest("分镜行不存在,请先读取最新分镜行 ID")
|
||||
}
|
||||
if scriptIsSource {
|
||||
switch stringValue(linkedNode["type"]) {
|
||||
case "image":
|
||||
row["imageNodeId"] = linkedNode["id"]
|
||||
case "video":
|
||||
row["videoNodeId"] = linkedNode["id"]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
binding, ok := cloudAgentStoryboardBinding(linkedNode)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bindings := creationMaps(row["assetBindings"])
|
||||
for _, existing := range bindings {
|
||||
if stringValue(existing["nodeId"]) == stringValue(linkedNode["id"]) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
row["assetBindings"] = append(bindingsAsAny(bindings), binding)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloudAgentStoryboardConnectionDetails(fromHandleID, toHandleID string) []string {
|
||||
handleID := fromHandleID
|
||||
if handleID == "" {
|
||||
handleID = toHandleID
|
||||
}
|
||||
if handleID == "storyboard:context" {
|
||||
return []string{"挂载到分镜全局设定"}
|
||||
}
|
||||
if strings.HasPrefix(handleID, "row:") {
|
||||
return []string{fmt.Sprintf("挂载到镜头行 %s", strings.TrimPrefix(handleID, "row:"))}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloudAgentStoryboardBinding(node map[string]any) (map[string]any, bool) {
|
||||
metadata, _ := node["metadata"].(map[string]any)
|
||||
role := ""
|
||||
switch {
|
||||
case stringValue(metadata["workflowKind"]) == "character" || stringValue(metadata["assetCategory"]) == "character":
|
||||
role = "character"
|
||||
case stringValue(node["type"]) == "audio":
|
||||
role = "audio"
|
||||
case stringValue(node["type"]) == "video":
|
||||
role = "motion"
|
||||
case stringValue(metadata["assetCategory"]) == "environment":
|
||||
role = "environment"
|
||||
case stringValue(metadata["assetCategory"]) == "prop":
|
||||
role = "prop"
|
||||
case stringValue(node["type"]) == "image":
|
||||
role = "style"
|
||||
}
|
||||
if role == "" || stringValue(node["id"]) == "" {
|
||||
return nil, false
|
||||
}
|
||||
priority := 60
|
||||
switch role {
|
||||
case "character":
|
||||
priority = 100
|
||||
case "environment":
|
||||
priority = 90
|
||||
case "prop", "wardrobe", "weapon":
|
||||
priority = 80
|
||||
case "motion", "audio":
|
||||
priority = 70
|
||||
}
|
||||
return map[string]any{"nodeId": node["id"], "role": role, "priority": float64(priority)}, true
|
||||
}
|
||||
|
||||
func cloudAgentNodeByID(nodes []map[string]any, id string) map[string]any {
|
||||
for _, node := range nodes {
|
||||
if stringValue(node["id"]) == id {
|
||||
return node
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringIDs(value any) []string {
|
||||
values, _ := value.([]any)
|
||||
ids := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
if id := stringValue(item); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func idsAsAny(values []string) []any {
|
||||
out := make([]any, len(values))
|
||||
for index, value := range values {
|
||||
out[index] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bindingsAsAny(values []map[string]any) []any {
|
||||
out := make([]any, len(values))
|
||||
for index, value := range values {
|
||||
out[index] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsStoryboardString(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloudAgentCanvasAppliedPreview(preview cloudAgentApprovalPreview) cloudAgentApprovalPreview {
|
||||
applied := preview
|
||||
counts := map[string]int{}
|
||||
for _, item := range preview.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"]))
|
||||
}
|
||||
applied.Status = "applied"
|
||||
applied.Title = "画布修改已写入"
|
||||
applied.Description = fmt.Sprintf("画布修改已写入:%s。", strings.Join(parts, ","))
|
||||
return applied
|
||||
}
|
||||
|
||||
func cloudAgentCanvasApprovalPreview(items []cloudAgentApprovalPreviewItem) cloudAgentApprovalPreview {
|
||||
counts := map[string]int{}
|
||||
for _, item := range items {
|
||||
|
||||
Reference in New Issue
Block a user