From ea26a604f03031832a6a1628399565caed5fc86e Mon Sep 17 00:00:00 2001 From: tanzui <305375675@.com> Date: Fri, 9 Oct 2026 12:19:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=88=86=E9=95=9C=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=20-=20=E6=98=8E=E7=A1=AE=E8=BF=9E=E7=BA=BF=E6=96=B9?= =?UTF-8?q?=E5=90=91=E5=B9=B6=E6=8F=90=E4=BE=9B=E5=AD=97=E6=AE=B5=E7=BA=A0?= =?UTF-8?q?=E6=AD=A3=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/cloud_agent_approval_preview.go | 6 ++ .../internal/app/cloud_agent_runtime_tools.go | 17 ++- ...d_agent_storyboard_handle_guidance_test.go | 102 ++++++++++++++++++ backend/internal/app/cloud_agent_tools.go | 4 +- .../internal/app/cloud_agent_tools_canvas.go | 16 ++- docs/content/docs/backend/http-api.mdx | 6 ++ docs/content/docs/progress/pending-test.mdx | 7 ++ 7 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 backend/internal/app/cloud_agent_storyboard_handle_guidance_test.go diff --git a/backend/internal/app/cloud_agent_approval_preview.go b/backend/internal/app/cloud_agent_approval_preview.go index e239cbca8..fc6813778 100644 --- a/backend/internal/app/cloud_agent_approval_preview.go +++ b/backend/internal/app/cloud_agent_approval_preview.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "sort" "strings" @@ -166,6 +167,11 @@ func applyCloudAgentCanvasPlan(doc map[string]any, ops []agentCanvasOp) ([]cloud return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].toNodeId", opIndex), "invalid_value", "连线不能指向自身;请选择另一个目标节点") } if err := validateCloudAgentConnectionWithHandles(nodes, op.FromNodeID, op.ToNodeID, op.FromHandleID, op.ToHandleID, edges); err != nil { + // 保留端点校验指出的具体字段,让模型只修正错误 handle,而不是放弃镜头绑定。 + var fieldErr *cloudAgentFieldArgumentError + if errors.As(err, &fieldErr) { + return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d].%s", opIndex, fieldErr.Field), fieldErr.Issue, cloudAgentSafeToolError(err)) + } return nil, cloudAgentFieldError(fmt.Sprintf("ops[%d]", opIndex), "invalid_connection", cloudAgentSafeToolError(err)) } for _, edge := range edges { diff --git a/backend/internal/app/cloud_agent_runtime_tools.go b/backend/internal/app/cloud_agent_runtime_tools.go index c5c86f1f2..c65b10dcc 100644 --- a/backend/internal/app/cloud_agent_runtime_tools.go +++ b/backend/internal/app/cloud_agent_runtime_tools.go @@ -86,7 +86,22 @@ func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgen detail["exampleArguments"] = map[string]any{} } if call.Function.Name == "canvas_apply_ops" { - detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{map[string]any{"type": "add_node", "id": "", "nodeType": "text", "content": ""}}} + if fieldErr != nil && (strings.HasSuffix(fieldErr.Field, ".fromHandleId") || strings.HasSuffix(fieldErr.Field, ".toHandleId")) { + // 这里只提供纠正模板,不修改原调用。handle 放错节点时应换到分镜一端; + // 行 ID 或格式错误则保持原方向,要求重新读取真实 rowId。 + sourceHandle := strings.HasSuffix(fieldErr.Field, ".fromHandleId") + if fieldErr.Issue == "invalid_node_handle" { + sourceHandle = !sourceHandle + } + op := map[string]any{"type": "connect_nodes", "id": "", "fromNodeId": "", "toNodeId": "", "toHandleId": "row:"} + if sourceHandle { + op = map[string]any{"type": "connect_nodes", "id": "", "fromNodeId": "", "toNodeId": "", "fromHandleId": "row:"} + } + detail["guidance"] = message + ";本次连线未执行,示例仅是参数模板,节点 ID 和 rowId 必须来自最新读取结果" + detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{op}} + } else { + detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{map[string]any{"type": "add_node", "id": "", "nodeType": "text", "content": ""}}} + } } } // 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。 diff --git a/backend/internal/app/cloud_agent_storyboard_handle_guidance_test.go b/backend/internal/app/cloud_agent_storyboard_handle_guidance_test.go new file mode 100644 index 000000000..e05ac8a34 --- /dev/null +++ b/backend/internal/app/cloud_agent_storyboard_handle_guidance_test.go @@ -0,0 +1,102 @@ +package app + +import ( + "errors" + "strings" + "testing" + + "yingce/backend/internal/model" +) + +// storyboardHandleGuidanceDocument 提供独立分镜夹具,区分素材输入、镜头输出和整表引用。 +func storyboardHandleGuidanceDocument(t *testing.T) map[string]any { + t.Helper() + doc, err := creationDocument(`{"nodes":[{"id":"script","type":"script","metadata":{"storyboard":{"rows":[{"id":"r1","assetBindings":[{"nodeId":"asset","role":"environment"}],"imageNodeId":"output"},{"id":"r2"}]}}},{"id":"asset","type":"image","metadata":{"assetCategory":"environment"}},{"id":"output","type":"image","metadata":{}}],"connections":[{"id":"input","fromNodeId":"asset","toNodeId":"script","toHandleId":"row:r1"},{"id":"output-edge","fromNodeId":"script","toNodeId":"output","fromHandleId":"row:r1"},{"id":"context","fromNodeId":"asset","toNodeId":"script","toHandleId":"storyboard:context"},{"id":"unrelated","fromNodeId":"asset","toNodeId":"script","toHandleId":"row:r2"}]}`) + if err != nil { + t.Fatal(err) + } + return doc +} + +func TestAgentStoryboardHandleErrorsIdentifyTheWrongField(t *testing.T) { + for _, tc := range []struct{ name, from, to, fromHandle, toHandle, field, issue, guidance string }{ + {"asset source handle", "asset", "script", "row:r1", "", "fromHandleId", "invalid_node_handle", "省略 fromHandleId"}, + {"output target handle", "script", "output", "", "row:r1", "toHandleId", "invalid_node_handle", "分镜来源的 fromHandleId"}, + {"shot number is not row id", "asset", "script", "", "row:9", "toHandleId", "not_found", "不要用镜头编号代替"}, + {"missing row prefix", "asset", "script", "", "r1", "toHandleId", "invalid_handle", "row:<真实rowId>"}, + } { + t.Run(tc.name, func(t *testing.T) { + doc := storyboardHandleGuidanceDocument(t) + ops := []agentCanvasOp{{Type: "connect_nodes", ID: "new-edge", FromNodeID: tc.from, ToNodeID: tc.to, FromHandleID: tc.fromHandle, ToHandleID: tc.toHandle}} + _, err := applyCloudAgentCanvasPlan(doc, ops) + var fieldErr *cloudAgentFieldArgumentError + if !errors.As(err, &fieldErr) || fieldErr.Field != "ops[0]."+tc.field || fieldErr.Issue != tc.issue || !strings.Contains(err.Error(), tc.guidance) { + t.Fatalf("wrong handle feedback: %v", err) + } + if len(creationMaps(doc["connections"])) != 4 { + t.Fatal("rejected handle created or exchanged a connection") + } + // 校验模型实际收到的错误回执,不能附上与连线无关的新增节点模板。 + call := cloudAgentCall{ID: "bad-handle"} + call.Function.Name = "canvas_apply_ops" + state := cloudAgentRuntime{Request: CloudAgentRequest{PermissionMode: "auto", ContextScope: []string{"canvas"}}, Calls: []cloudAgentCall{call}} + cloudAgentRecordToolResult(&model.CloudAgentExecution{ID: "run"}, &state, call, nil, err) + result := state.Events[0].Payload["result"].(map[string]any) + if result["field"] != "ops[0]."+tc.field || !strings.Contains(stringValue(result["guidance"]), "本次连线未执行") { + t.Fatal("tool receipt lost actionable feedback") + } + example := result["exampleArguments"].(map[string]any)["ops"].([]any)[0].(map[string]any) + if example["type"] != "connect_nodes" { + t.Fatal("wrong correction template") + } + wantHandle := tc.field + if tc.issue == "invalid_node_handle" { + if wantHandle == "fromHandleId" { + wantHandle = "toHandleId" + } else { + wantHandle = "fromHandleId" + } + } + if example[wantHandle] == nil { + t.Fatal("correction template uses the wrong endpoint") + } + }) + } +} + +func TestAgentStoryboardRowBindingDoesNotConflictWithWholeNodeReference(t *testing.T) { + doc := storyboardHandleGuidanceDocument(t) + doc["connections"] = []any{map[string]any{"id": "generic", "fromNodeId": "asset", "toNodeId": "script"}} + storyboard := cloudAgentNodeByID(creationMaps(doc["nodes"]), "script")["metadata"].(map[string]any)["storyboard"].(map[string]any) + row := creationMaps(storyboard["rows"])[0] + row["assetBindings"], row["imageNodeId"] = []any{}, "" + ops := []agentCanvasOp{{Type: "connect_nodes", ID: "row-binding", FromNodeID: "asset", ToNodeID: "script", ToHandleID: "row:r1"}} + if _, err := applyCloudAgentCanvasPlan(doc, ops); err != nil { + t.Fatal(err) + } + bindings := creationMaps(row["assetBindings"]) + if len(bindings) != 1 || bindings[0]["nodeId"] != "asset" || row["imageNodeId"] != "" || len(creationMaps(doc["connections"])) != 2 { + t.Fatal("row binding was confused with output or generic reference") + } + if _, err := applyCloudAgentCanvasPlan(doc, []agentCanvasOp{{Type: "connect_nodes", ID: "duplicate-row", FromNodeID: "asset", ToNodeID: "script", ToHandleID: "row:r1"}}); err == nil { + t.Fatal("duplicate row binding accepted") + } +} + +func TestAgentToolDescriptionIncludesBothStoryboardDirections(t *testing.T) { + tools, _ := platformToolSchema(t) + for _, tool := range tools { + fn := tool["function"].(map[string]any) + if fn["name"] != "canvas_apply_ops" { + continue + } + description := stringValue(fn["description"]) + for _, phrase := range []string{"镜头绑定示例", `"toHandleId":"row:"`, "输出示例", `"fromHandleId":"row:"`, "不是镜头编号"} { + if !strings.Contains(description, phrase) { + t.Fatalf("missing tool guidance %s", phrase) + } + } + return + } + t.Fatal("canvas tool not exposed") +} diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index c0f8cd204..2cabdbd6a 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -26,7 +26,7 @@ func cloudAgentCanonicalFor(system string, history []providerTextMessage, prompt const ( cloudAgentPromptCacheSchemaVersion = "cloud-agent-prompt-cache/v2" - cloudAgentToolSchemaVersion = "cloud-agent-tools/v3" + cloudAgentToolSchemaVersion = "cloud-agent-tools/v4" ) // cloudAgentPromptCacheIdentity deliberately excludes the canvas payload and its @@ -261,7 +261,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。操作必填字段见 schema;add_node 可用 x/y 定位,省略时自动排位;update_node 按节点能力填写 patch,可用 x/y 移动或设置视频首尾帧。分镜脚本的镜头级关联必须把 canvas_get_state 或 canvas_read_storyboard 返回的 rowId 写成 fromHandleId/toHandleId 的 row:;整表设定使用 storyboard:context。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。批量排位用 canvas_arrange_nodes。", 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。操作必填字段见 schema;add_node 可用 x/y 定位,省略时自动排位;update_node 按节点能力填写 patch,可用 x/y 移动或设置视频首尾帧。镜头绑定示例:{\"fromNodeId\":\"<资产ID>\",\"toNodeId\":\"<分镜ID>\",\"toHandleId\":\"row:\"};输出示例:{\"fromNodeId\":\"<分镜ID>\",\"toNodeId\":\"<图片/视频ID>\",\"fromHandleId\":\"row:\"}。rowId 必须取自读取结果,不是镜头编号;整表引用用 storyboard:context。连线是生成输入关系,不会改变已提交任务的输入;来源须 canSource,目标须 canTarget 且接受来源 inputKind,能力以注册表为准。批量排位用 canvas_arrange_nodes。", 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;省略=全部可整理")}, diff --git a/backend/internal/app/cloud_agent_tools_canvas.go b/backend/internal/app/cloud_agent_tools_canvas.go index 4e845b41b..f720884f8 100644 --- a/backend/internal/app/cloud_agent_tools_canvas.go +++ b/backend/internal/app/cloud_agent_tools_canvas.go @@ -189,18 +189,28 @@ func validateCloudAgentConnectionWithHandles(nodes []map[string]any, fromID, toI return nil } +// validateCloudAgentStoryboardHandle 校验 handle 所属端点和真实镜头行,返回可修正的具体字段。 +// 资产引用与镜头输出方向不同,只给出纠正说明,不自动交换端点或迁移 handle。 func validateCloudAgentStoryboardHandle(node map[string]any, handleID, side string) error { if handleID == "" { return nil } + field := "toHandleId" + if side == "来源" { + field = "fromHandleId" + } if stringValue(node["type"]) != "script" { - return BadAuthRequest(fmt.Sprintf("只有分镜脚本节点可以指定%s handle", side)) + message := "toHandleId 只能填写在分镜目标节点;若要指定镜头输出,请使用分镜→图片/视频,并将 row:<真实rowId> 填入分镜来源的 fromHandleId" + if side == "来源" { + message = "fromHandleId 只能填写在分镜来源节点;若要把素材绑定到镜头行,请使用素材→分镜,省略 fromHandleId,将 row:<真实rowId> 填入分镜目标的 toHandleId" + } + return cloudAgentFieldError(field, "invalid_node_handle", message) } if handleID == "storyboard:context" { return nil } if !strings.HasPrefix(handleID, "row:") || strings.TrimSpace(strings.TrimPrefix(handleID, "row:")) == "" { - return BadAuthRequest(fmt.Sprintf("分镜%s handle 必须是 row: 或 storyboard:context", side)) + return cloudAgentFieldError(field, "invalid_handle", field+" 必须是 row:<真实rowId> 或 storyboard:context;镜头编号不是 rowId,请先读取分镜") } metadata, _ := node["metadata"].(map[string]any) storyboard, _ := metadata["storyboard"].(map[string]any) @@ -210,7 +220,7 @@ func validateCloudAgentStoryboardHandle(node map[string]any, handleID, side stri return nil } } - return BadAuthRequest(fmt.Sprintf("分镜%s handle 引用的 rowId 不存在,请先读取最新分镜", side)) + return cloudAgentFieldError(field, "not_found", field+" 引用的 rowId 不存在,请先用 canvas_read_storyboard 读取最新真实行 ID,不要用镜头编号代替") } func cloudAgentInputKindLabel(kind string) string { diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index 7b29608e2..9a516ab37 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -5,6 +5,12 @@ description: 信封、错误码与 OpenAPI # HTTP API 合同 +## Agent 分镜连线参数反馈 + +素材绑定使用“素材→分镜”和 `toHandleId: row:`;镜头输出使用“分镜→图片/视频”和 `fromHandleId: row:`。`rowId` 必须是读取工具返回的真实行 ID,不能用镜头编号代替。普通节点连线与镜头行连线按 handle 区分,已有整表连线不代表该行已经绑定。 + +handle 放错端点、格式错误或行 ID 不存在时,工具错误回执提供具体 `field`(如 `ops[0].fromHandleId`)、`issue`、方向说明及连线参数模板,不再使用无关的新增节点示例。模板不会被自动执行,不自动交换端点或修改原调用。权限、快照和重复连线校验保持不变;工具契约版本为 v4,重启后端后新会话使用更新后的说明。 + ## 注册邮箱等价检查 公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。 diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index d2e08f1b6..bb1f30ed8 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## Agent 分镜绑定参数引导 + +- 已优化:工具说明区分素材输入和镜头输出的两个 JSON 示例;handle 错误明确字段、正确方向和真实 rowId 要求,并在回执附上对应连线模板。不放宽校验、不自动改线。 +- [x] 专项测试覆盖来源/目标 handle 放错节点、镜头编号误用、缺少 row 前缀、错误回执模板方向、普通连线与行绑定并存,以及重复行绑定仍拒绝;工具 schema 体积检查通过。 +- [x] 用户已在本地验证镜头绑定可用;自动化测试不调用外部模型,不修改实际创作画布。 +- [ ] 上游合并后验证新会话沿用正确绑定方向,错误调用不会被误报为成功。 + ## 复制生图节点后节点消失 - 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。