feat(canvas): 云端 Agent 画布 - 支持分镜连接、脚本节点与资产关系持久化

This commit is contained in:
ddcat666
2026-10-04 02:21:36 +08:00
parent 76ca0fd247
commit e70430e3e2
10 changed files with 505 additions and 37 deletions

View File

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

View File

@@ -42,6 +42,46 @@ func TestCloudAgentCanvasApprovalPreviewDescribesUpdateTargetAndFields(t *testin
}
}
func TestCloudAgentStoryboardConnectionsUseRowHandlesAndPersistBindings(t *testing.T) {
doc, err := creationDocument(`{"nodes":[{"id":"script-1","type":"script","title":"分镜","metadata":{"storyboard":{"rows":[{"id":"row-1","shotNumber":1,"durationSeconds":5,"assetBindings":[],"imageNodeId":""}],"referenceNodeIds":[]}}},{"id":"image-1","type":"image","title":"环境图","metadata":{"content":"ready","assetCategory":"environment"}},{"id":"video-1","type":"video","title":"镜头视频","metadata":{}}],"connections":[]}`)
if err != nil {
t.Fatal(err)
}
items, err := applyCloudAgentCanvasPlan(doc, []agentCanvasOp{
{Type: "update_node", ID: "script-1", Patch: map[string]any{"title": "夜雨分镜", "x": 120.0, "y": 240.0}},
{Type: "connect_nodes", ID: "edge-asset", FromNodeID: "image-1", ToNodeID: "script-1", ToHandleID: "row:row-1"},
{Type: "connect_nodes", ID: "edge-output", FromNodeID: "script-1", ToNodeID: "video-1", FromHandleID: "row:row-1"},
})
if err != nil {
t.Fatal(err)
}
if len(items) != 3 || len(items[1].Details) != 1 || !strings.Contains(items[1].Details[0], "row-1") {
t.Fatalf("row handle was not reflected in approval preview: %+v", items)
}
connections := creationMaps(doc["connections"])
if len(connections) != 2 || stringValue(connections[0]["toHandleId"]) != "row:row-1" || stringValue(connections[1]["fromHandleId"]) != "row:row-1" {
t.Fatalf("row handles were not persisted: %+v", connections)
}
script := cloudAgentNodeByID(creationMaps(doc["nodes"]), "script-1")
if script["title"] != "夜雨分镜" {
t.Fatalf("script title update was not applied: %#v", script)
}
position := script["position"].(map[string]any)
if position["x"] != 120.0 || position["y"] != 240.0 {
t.Fatalf("script position update was not applied: %#v", position)
}
metadata := script["metadata"].(map[string]any)
storyboard := metadata["storyboard"].(map[string]any)
row := creationMaps(storyboard["rows"])[0]
if stringValue(row["imageNodeId"]) != "" {
t.Fatalf("asset connection should not be treated as storyboard output: %#v", row)
}
bindings := creationMaps(row["assetBindings"])
if len(bindings) != 1 || stringValue(bindings[0]["nodeId"]) != "image-1" || stringValue(bindings[0]["role"]) != "environment" {
t.Fatalf("row asset binding was not persisted: %#v", row)
}
}
func TestCloudAgentCanvasApprovalPreviewDescribesConnectionsAndMixedOperations(t *testing.T) {
doc, err := creationDocument(`{"nodes":[{"id":"image-1","type":"image","title":"参考图"},{"id":"video-1","type":"video","title":"视频镜头"}],"connections":[]}`)
if err != nil {
@@ -85,6 +125,28 @@ func TestCloudAgentCanvasApprovalPreviewRejectsUnknownTargetInsteadOfFallingBack
// 漏 patch 必须是**可恢复的参数错误**:运行期会把它当工具结果回给模型重试,
// 而不是把整轮判死;未知操作类型(例如删除)仍按准入失败终止。
func TestCloudAgentCanvasApprovalPreviewTreatsInvalidConnectionEndpointsAsArgumentErrors(t *testing.T) {
for _, tc := range []struct {
name, from, to, field string
}{
{"missing source", "missing", "image-1", "ops[0].fromNodeId"},
{"missing target", "image-1", "missing", "ops[0].toNodeId"},
{"self connection", "image-1", "image-1", "ops[0].toNodeId"},
} {
t.Run(tc.name, func(t *testing.T) {
doc, err := creationDocument(`{"nodes":[{"id":"image-1","type":"image","title":"参考图"}],"connections":[]}`)
if err != nil {
t.Fatal(err)
}
_, err = applyCloudAgentCanvasPlan(doc, []agentCanvasOp{{Type: "connect_nodes", ID: "edge-1", FromNodeID: tc.from, ToNodeID: tc.to}})
var fieldErr *cloudAgentFieldArgumentError
if !errors.As(err, &fieldErr) || fieldErr.Field != tc.field {
t.Fatalf("expected repairable field error %q, got %v", tc.field, err)
}
})
}
}
func TestCloudAgentCanvasApprovalPreviewTreatsMissingPatchAsArgumentError(t *testing.T) {
doc, err := creationDocument(`{"nodes":[{"id":"image-1","type":"image","title":"参考图"}],"connections":[]}`)
if err != nil {

View File

@@ -193,6 +193,33 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) {
if trace["callId"] != call.ID || last.Type != "tool_completed" || last.Payload["callId"] != call.ID {
t.Fatalf("canvas delta and tool result cannot be deduplicated: %+v / %+v", trace, last)
}
result := last.Payload["result"].(map[string]any)
if result["committed"] != true {
t.Fatalf("canvas_apply_ops result did not acknowledge persistence: %+v", result)
}
preview := result["preview"].(map[string]any)
if preview["status"] != "applied" || strings.Contains(stringValue(preview["description"]), "批准后才会写入") {
t.Fatalf("canvas_apply_ops result still looks like an approval preview: %+v", preview)
}
canvas, err := s.repo.CanvasProjectForUser("user", "agent-canvas")
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
var cat map[string]any
for _, node := range creationMaps(doc["nodes"]) {
if stringValue(node["id"]) == "cat" {
cat = node
break
}
}
if cat == nil || stringValue(cat["title"]) != "叮当猫飞行参考" || stringValue(cat["metadata"].(map[string]any)["composerContent"]) != "下一版参考图提示词" {
t.Fatalf("canvas_apply_ops did not persist the requested draft: %+v", cat)
}
actions := creationMaps(trace["actions"])
byActionAndNode := map[string]map[string]any{}
for _, action := range actions {

View File

@@ -208,7 +208,7 @@ func cloudAgentCanvasStateSelected(repo *repository.Repository, userID, canvasID
candidateNodes = append(candidateNodes, node)
}
}
limit := 2000
limit := 320
if len(ids) > 0 || len(focus) > 0 {
limit = 16000
}

View File

@@ -169,6 +169,19 @@ func TestCloudAgentToolsFollowCanvasCapabilityRegistry(t *testing.T) {
}
}
}
script := nodeTypeDefinitions["script"]
if script == nil || script["canSource"] != true || script["canTarget"] != true || script["canReference"] != false || script["inputKind"] != "text" {
t.Fatalf("script capability is incomplete: %#v", script)
}
updateFields, _ := script["updateFields"].(map[string]any)
for _, field := range []string{"title", "x", "y"} {
if _, ok := updateFields[field]; !ok {
t.Fatalf("script update field %s is missing: %#v", field, script)
}
}
if accepted, _ := script["acceptedInputKinds"].([]string); !reflect.DeepEqual(accepted, []string{"audio", "character", "image", "text", "video"}) {
t.Fatalf("script accepted input kinds are incomplete: %#v", script["acceptedInputKinds"])
}
if got := functions["generate_media"]["properties"].(map[string]any)["mode"].(map[string]any)["enum"]; !reflect.DeepEqual(got, cloudAgentGenerationModeNames()) {
t.Fatalf("generation modes differ from implemented adapters: %v", got)
}
@@ -353,6 +366,28 @@ func TestCloudAgentProjectionRejectsMissingAdapterAndOpaqueMetadata(t *testing.T
}
}
func TestCloudAgentCanvasStateBoundsDefaultBodyProjection(t *testing.T) {
body := strings.Repeat("长正文 ", 999) + "长正文"
doc := map[string]any{"nodes": []map[string]any{{"id": "markdown", "type": "markdown", "title": "长文档", "metadata": map[string]any{"content": body}}}, "connections": []map[string]any{}}
view, err := cloudAgentCanvasState(nil, "user", "agent-canvas", doc, 0, nil, 0)
if err != nil {
t.Fatal(err)
}
item := view.(map[string]any)["nodes"].([]any)[0].(map[string]any)
content := stringValue(item["content"])
if len([]rune(content)) > 323 || item["contentTruncated"] != true {
t.Fatalf("default canvas read was not bounded: len=%d item=%v", len([]rune(content)), item)
}
precise, err := cloudAgentCanvasState(nil, "user", "agent-canvas", doc, 0, []string{"markdown"}, 0)
if err != nil {
t.Fatal(err)
}
preciseItem := precise.(map[string]any)["nodes"].([]any)[0].(map[string]any)
if stringValue(preciseItem["content"]) != body || preciseItem["contentTruncated"] != nil {
t.Fatalf("precise canvas read lost full content: got=%d want=%d item=%v", len([]rune(stringValue(preciseItem["content"]))), len([]rune(body)), preciseItem)
}
}
func TestCloudAgentCanvasStateDoesNotForwardUnknownObjectFields(t *testing.T) {
doc := map[string]any{"nodes": []map[string]any{{
"id": "safe", "type": "text", "title": "镜头",

View File

@@ -77,7 +77,7 @@ func cloudAgentStoryboardPatchSchema() map[string]any {
return schema
}
func validateCloudAgentStoryboardRow(row map[string]any, requireDescription bool) error {
func validateCloudAgentStoryboardRow(row map[string]any, requireDescription bool, fieldPath string) error {
if row == nil {
return BadAuthRequest("分镜行不能为空")
}
@@ -102,8 +102,9 @@ func validateCloudAgentStoryboardRow(row map[string]any, requireDescription bool
if requireDescription {
plot := strings.TrimSpace(stringValue(row["plotDescription"]))
motion := strings.TrimSpace(stringValue(row["videoMotionPrompt"]))
if plot == "" && motion == "" {
return BadAuthRequest("新增镜头需要画面描述或视频提示词")
imagePrompt := strings.TrimSpace(stringValue(row["imageGenerationPrompt"]))
if plot == "" && motion == "" && imagePrompt == "" {
return cloudAgentFieldError(fieldPath, "required", "新增镜头需要画面描述、首帧提示词或视频提示词")
}
}
return nil
@@ -115,7 +116,7 @@ func normalizeCloudAgentStoryboardRows(rows []map[string]any, userID, nodeID, se
}
out := make([]any, 0, len(rows))
for index, input := range rows {
if err := validateCloudAgentStoryboardRow(input, true); err != nil {
if err := validateCloudAgentStoryboardRow(input, true, fmt.Sprintf("rows[%d]", index)); err != nil {
return nil, err
}
row := cloudAgentStoryboardRowDefaults()
@@ -294,7 +295,7 @@ func prepareCloudAgentStoryboardEdit(repo *repository.Repository, userID, canvas
if len(rows) >= maxCloudAgentStoryboardRows {
return nil, BadAuthRequest("单个分镜表最多100个镜头")
}
if err := validateCloudAgentStoryboardRow(patch, true); err != nil {
if err := validateCloudAgentStoryboardRow(patch, true, "patch"); err != nil {
return nil, err
}
row := cloudAgentStoryboardRowDefaults()

View File

@@ -71,6 +71,97 @@ func createCloudAgentStoryboardForTest(t *testing.T, s *Service, canvas *model.C
return rows
}
func TestCloudAgentStoryboardAcceptsImagePromptAsDescription(t *testing.T) {
if err := validateCloudAgentStoryboardRow(map[string]any{
"durationSeconds": float64(3),
"imageGenerationPrompt": "首帧:雨夜街口,主角从巷中走出",
}, true, "rows[0]"); err != nil {
t.Fatalf("a standalone imageGenerationPrompt should describe the shot: %v", err)
}
if err := validateCloudAgentStoryboardRow(map[string]any{"durationSeconds": float64(3)}, true, "rows[0]"); err == nil || !strings.Contains(err.Error(), "首帧提示词") {
t.Fatalf("missing shot description should explain accepted fields: %v", err)
} else if fieldErr, ok := err.(*cloudAgentFieldArgumentError); !ok || fieldErr.Field != "rows[0]" || fieldErr.Issue != "required" {
t.Fatalf("missing shot description should be a repairable field error, got %T: %v", err, err)
}
}
func TestCloudAgentStoryboardArgumentFailureContinuesRun(t *testing.T) {
s, canvas := cloudAgentStoryboardFixture(t)
req := agentTestRequest()
req.PermissionMode = "request_approval"
root, err := s.CreateCloudAgentRun("user", req, "")
if err != nil {
t.Fatal(err)
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
call := cloudAgentStoryboardCall(t, "canvas_create_storyboard", "storyboard-invalid-call", map[string]any{
"snapshotHash": cloudAgentCanvasHash(doc),
"nodeId": "storyboard-invalid",
"title": "缺少描述的分镜",
"rows": []map[string]any{{"durationSeconds": 4.0}},
})
run, err := s.repo.CloudAgent("user", root.ID)
if err != nil {
t.Fatal(err)
}
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
state.ActiveTaskID = ""
state.Calls = []cloudAgentCall{call}
state.CallIndex = 0
if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error {
return cloudAgentSave(current, &state)
}); err != nil {
t.Fatal(err)
}
run, err = s.repo.CloudAgent("user", run.ID)
if err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
stored, err := s.repo.CloudAgent("user", run.ID)
if err != nil {
t.Fatal(err)
}
state, err = cloudAgentDecode(stored)
if err != nil {
t.Fatal(err)
}
if stored.Status != "running" {
t.Fatalf("repairable storyboard argument failure terminated run: status=%s message=%s", stored.Status, stored.FailureMessage)
}
var failed *CloudAgentEvent
for i := range state.Events {
if state.Events[i].Type == "tool_failed" {
failed = &state.Events[i]
}
if state.Events[i].Type == "run_failed" {
t.Fatalf("argument error emitted run_failed: %+v", state.Events[i])
}
}
if failed == nil {
t.Fatal("missing tool_failed event")
}
result, _ := failed.Payload["result"].(map[string]any)
if result["field"] != "rows[0]" || result["issue"] != "required" || result["requiredAction"] != "fix_arguments" {
t.Fatalf("missing actionable storyboard field feedback: %+v", result)
}
storedCanvas, err := s.repo.CanvasProjectForUser("user", canvas.ID)
if err != nil {
t.Fatal(err)
}
if strings.Contains(storedCanvas.PayloadJSON, "storyboard-invalid") {
t.Fatal("invalid storyboard arguments wrote to the canvas")
}
}
func TestCloudAgentNodeCapabilityCardsExplainStoryboardTradeoffs(t *testing.T) {
result := cloudAgentNodeTypes()
if result["schemaVersion"] != 2 {

View File

@@ -232,16 +232,18 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
"globalPrompt": str("set_global_prompt 使用;非空时覆盖各任务提示词,空字符串清除全局提示词"),
}, "snapshotHash", "nodeId", "action")
opProperties := map[string]any{
"type": map[string]any{"type": "string", "enum": []string{"add_node", "update_node", "connect_nodes"}, "description": "必填的操作类型;新增节点必须传 add_node,nodeType 不能代替本字段"},
"id": str("节点或连线唯一ID"),
"nodeType": map[string]any{"type": "string", "enum": cloudAgentNodeTypeNames()},
"title": str("标题;更新操作可选"),
"content": str("文本正文或媒体提示词;更新操作可选"),
"patch": cloudAgentPatchSchema(),
"fromNodeId": str("连线来源节点ID"),
"toNodeId": str("连线目标节点ID"),
"x": map[string]any{"type": "number"},
"y": map[string]any{"type": "number"},
"type": map[string]any{"type": "string", "enum": []string{"add_node", "update_node", "connect_nodes"}, "description": "必填的操作类型;新增节点必须传 add_node,nodeType 不能代替本字段"},
"id": str("节点或连线唯一ID"),
"nodeType": map[string]any{"type": "string", "enum": cloudAgentNodeTypeNames()},
"title": str("标题;更新操作可选"),
"content": str("文本正文或媒体提示词;更新操作可选"),
"patch": cloudAgentPatchSchema(),
"fromNodeId": str("连线来源节点ID"),
"toNodeId": str("连线目标节点ID"),
"fromHandleId": str("分镜来源 handle:row:<rowId> 或 storyboard:context"),
"toHandleId": str("分镜目标 handle:row:<rowId> 或 storyboard:context"),
"x": map[string]any{"type": "number"},
"y": map[string]any{"type": "number"},
}
opItem := map[string]any{
"type": "object",
@@ -254,7 +256,7 @@ 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(可给 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_apply_ops", "创建空白节点、修改提示词或建立引用连线,不提交生成任务、不产生生成费用;先读取画布并传 snapshotHash。提交媒体生成使用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。每项都需要 type 和 id:add_node 还需要 nodeType(可给 x/y 指定位置;省略坐标时服务端按画布内容自动落位,不会叠在原点),update_node 还需要按节点能力清单填写 patch(可含 x/y 移动节点),connect_nodes 还需要 fromNodeId 与 toNodeId。分镜脚本的镜头级关联必须把 canvas_get_state 或 canvas_read_storyboard 返回的 rowId 写成 fromHandleId/toHandleId 的 row:<rowId>;整表设定使用 storyboard:context。连线是生成输入关系,不会改变已提交任务的输入;来源须 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;省略=全部可整理")},

View File

@@ -52,10 +52,12 @@ type agentCanvasOp struct {
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"`
X *float64 `json:"x"`
Y *float64 `json:"y"`
FromNodeID string `json:"fromNodeId"`
ToNodeID string `json:"toNodeId"`
FromHandleID string `json:"fromHandleId"`
ToHandleID string `json:"toHandleId"`
}
// Explicit node creation and edges only; no generic metadata, media URL or deletion.
@@ -67,6 +69,7 @@ func applyCloudAgentCanvas(repo *repository.Repository, userID, canvasID string,
if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil {
return nil, err
}
appliedPreview := cloudAgentCanvasAppliedPreview(plan.Preview)
if len(recorder) > 0 && recorder[0] != nil {
if err := recorder[0](repo, cloudAgentMutationInput{
UserID: userID,
@@ -76,15 +79,19 @@ func applyCloudAgentCanvas(repo *repository.Repository, userID, canvasID string,
BeforeSnapshotHash: plan.BeforeSnapshotHash,
AfterSnapshotHash: cloudAgentCanvasHash(plan.Document),
BeforeJSON: plan.BeforeJSON,
Preview: &plan.Preview,
Preview: &appliedPreview,
}); err != nil {
return nil, err
}
}
return map[string]any{"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "summary": fmt.Sprintf("已完成 %d 项节点/连线操作", len(plan.Args.Ops)), "preview": plan.Preview}, nil
return map[string]any{"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "committed": true, "summary": fmt.Sprintf("已完成 %d 项节点/连线操作", len(plan.Args.Ops)), "preview": appliedPreview}, nil
}
func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, existingConnections ...[]map[string]any) error {
return validateCloudAgentConnectionWithHandles(nodes, fromID, toID, "", "", existingConnections...)
}
func validateCloudAgentConnectionWithHandles(nodes []map[string]any, fromID, toID, fromHandleID, toHandleID string, existingConnections ...[]map[string]any) error {
if err := validateCloudAgentID(fromID, "来源节点 ID", 80); err != nil {
return err
}
@@ -106,6 +113,12 @@ func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, e
if from == nil || to == nil {
return BadAuthRequest("连线端点不存在")
}
if err := validateCloudAgentStoryboardHandle(from, fromHandleID, "来源"); err != nil {
return err
}
if err := validateCloudAgentStoryboardHandle(to, toHandleID, "目标"); err != nil {
return err
}
fromCapability, fromKnown := cloudAgentNodeCapabilityForNode(from)
toCapability, toKnown := cloudAgentNodeCapabilityForNode(to)
if !fromKnown || !toKnown {
@@ -123,7 +136,7 @@ func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, e
connections = existingConnections[0]
}
for _, edge := range connections {
if stringValue(edge["toNodeId"]) == toID && stringValue(edge["fromNodeId"]) == fromID {
if stringValue(edge["toNodeId"]) == toID && stringValue(edge["fromNodeId"]) == fromID && stringValue(edge["fromHandleId"]) == fromHandleID && stringValue(edge["toHandleId"]) == toHandleID {
return BadAuthRequest("连线重复")
}
}
@@ -145,6 +158,30 @@ func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, e
return nil
}
func validateCloudAgentStoryboardHandle(node map[string]any, handleID, side string) error {
if handleID == "" {
return nil
}
if stringValue(node["type"]) != "script" {
return BadAuthRequest(fmt.Sprintf("只有分镜脚本节点可以指定%s handle", side))
}
if handleID == "storyboard:context" {
return nil
}
if !strings.HasPrefix(handleID, "row:") || strings.TrimSpace(strings.TrimPrefix(handleID, "row:")) == "" {
return BadAuthRequest(fmt.Sprintf("分镜%s handle 必须是 row:<rowId> 或 storyboard:context", side))
}
metadata, _ := node["metadata"].(map[string]any)
storyboard, _ := metadata["storyboard"].(map[string]any)
rowID := strings.TrimPrefix(handleID, "row:")
for _, row := range creationMaps(storyboard["rows"]) {
if stringValue(row["id"]) == rowID {
return nil
}
}
return BadAuthRequest(fmt.Sprintf("分镜%s handle 引用的 rowId 不存在,请先读取最新分镜", side))
}
func cloudAgentInputKindLabel(kind string) string {
switch kind {
case "image":

View File

@@ -93,8 +93,12 @@ func BuiltinRegistry() *Registry {
GoodFor: []string{"多镜头规划", "镜头连续性", "逐镜审查和微调", "逐镜生成图片或视频", "需要他人接手维护的内容"},
NotIdealFor: []string{"只有一个画面的快速试验", "一次性临时提示词", "仅需要阅读排版的普通文档"},
Tradeoffs: []string{"前期录入成本高于文本节点", "但能保留镜头级结构、资产关系和后续维护能力"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "generate_storyboard"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "generate_storyboard", "connect_assets", "update_node"},
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"audio", "character", "image", "text", "video"}},
CanUpdate: true,
SummaryFields: []string{"storyboard"}, DetailFields: []string{"storyboard"}, ProjectionKind: "storyboard", ProjectionField: "storyboard",
PatchFields: titleAndPositionPatchFields(),
CreateMetadata: func(string) map[string]any {
return map[string]any{"status": "idle", "workflowKind": "script", "storyboard": map[string]any{"rows": []any{}, "visibleColumns": []any{"shotNumber", "durationSeconds", "videoMotionPrompt", "dialogue", "assets"}, "referenceNodeIds": []any{}}}
},
@@ -178,14 +182,11 @@ func generatedMediaSemantics(nodeType string) generatedMediaCapabilitySemantics
}
}
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
func titleAndPositionPatchFields() map[string]PatchField {
fields := map[string]PatchField{
"title": {
Path: "title", Kind: patchKindString, Label: "节点名称", Order: 10, Description: "节点标题", MaxRunes: maxAgentNodeTitleRunes,
},
"content": {
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
},
}
for key, field := range positionPatchFields() {
fields[key] = field
@@ -193,6 +194,14 @@ func editableNodeFields(contentPath, contentLabel, contentDescription string) ma
return fields
}
func editableNodeFields(contentPath, contentLabel, contentDescription string) map[string]PatchField {
fields := titleAndPositionPatchFields()
fields["content"] = PatchField{
Path: contentPath, Kind: patchKindString, Label: contentLabel, Order: 20, Description: contentDescription, MaxRunes: maxAgentNodeContentRunes,
}
return fields
}
// positionPatchFields 是坐标字段:模型可以直接指定节点位置(微调),批量整理走 canvas_arrange_nodes。
// 坐标上限与整理侧的收敛范围一致,避免写进离谱的几何值。
func positionPatchFields() map[string]PatchField {