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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": "镜头",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;省略=全部可整理")},
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user