fix(agent): 分镜绑定 - 明确连线方向并提供字段纠正提示
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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": "<canvas_get_state.snapshotHash>", "ops": []any{map[string]any{"type": "add_node", "id": "<new-node-id>", "nodeType": "text", "content": "<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": "<new-edge-id>", "fromNodeId": "<asset-node-id>", "toNodeId": "<script-node-id>", "toHandleId": "row:<rowId-from-canvas_read_storyboard>"}
|
||||
if sourceHandle {
|
||||
op = map[string]any{"type": "connect_nodes", "id": "<new-edge-id>", "fromNodeId": "<script-node-id>", "toNodeId": "<output-image-or-video-node-id>", "fromHandleId": "row:<rowId-from-canvas_read_storyboard>"}
|
||||
}
|
||||
detail["guidance"] = message + ";本次连线未执行,示例仅是参数模板,节点 ID 和 rowId 必须来自最新读取结果"
|
||||
detail["exampleArguments"] = map[string]any{"snapshotHash": "<canvas_get_state.snapshotHash>", "ops": []any{op}}
|
||||
} else {
|
||||
detail["exampleArguments"] = map[string]any{"snapshotHash": "<canvas_get_state.snapshotHash>", "ops": []any{map[string]any{"type": "add_node", "id": "<new-node-id>", "nodeType": "text", "content": "<content>"}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。
|
||||
|
||||
@@ -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:<rowId>"`, "输出示例", `"fromHandleId":"row:<rowId>"`, "不是镜头编号"} {
|
||||
if !strings.Contains(description, phrase) {
|
||||
t.Fatalf("missing tool guidance %s", phrase)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("canvas tool not exposed")
|
||||
}
|
||||
@@ -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:<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_apply_ops", "创建空白节点、编辑或连线,不生成、不收费;先读画布传 snapshotHash。生成用 generate_media。每次最多20项,禁止删除、任意 metadata 和媒体 URL。操作必填字段见 schema;add_node 可用 x/y 定位,省略时自动排位;update_node 按节点能力填写 patch,可用 x/y 移动或设置视频首尾帧。镜头绑定示例:{\"fromNodeId\":\"<资产ID>\",\"toNodeId\":\"<分镜ID>\",\"toHandleId\":\"row:<rowId>\"};输出示例:{\"fromNodeId\":\"<分镜ID>\",\"toNodeId\":\"<图片/视频ID>\",\"fromHandleId\":\"row:<rowId>\"}。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;省略=全部可整理")},
|
||||
|
||||
@@ -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:<rowId> 或 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 {
|
||||
|
||||
@@ -5,6 +5,12 @@ description: 信封、错误码与 OpenAPI
|
||||
|
||||
# HTTP API 合同
|
||||
|
||||
## Agent 分镜连线参数反馈
|
||||
|
||||
素材绑定使用“素材→分镜”和 `toHandleId: row:<rowId>`;镜头输出使用“分镜→图片/视频”和 `fromHandleId: row:<rowId>`。`rowId` 必须是读取工具返回的真实行 ID,不能用镜头编号代替。普通节点连线与镜头行连线按 handle 区分,已有整表连线不代表该行已经绑定。
|
||||
|
||||
handle 放错端点、格式错误或行 ID 不存在时,工具错误回执提供具体 `field`(如 `ops[0].fromHandleId`)、`issue`、方向说明及连线参数模板,不再使用无关的新增节点示例。模板不会被自动执行,不自动交换端点或修改原调用。权限、快照和重复连线校验保持不变;工具契约版本为 v4,重启后端后新会话使用更新后的说明。
|
||||
|
||||
## 注册邮箱等价检查
|
||||
|
||||
公开注册与邮箱绑定的唯一性检查将 `gmail.com` 和 `googlemail.com` 视为同一域,移除本地部分的点及 `+` 后缀。例如 `a.b+x@googlemail.com` 与 `ab@gmail.com` 不允许创建两个账号。其他域保留点和 `+`;现有域名白名单规则保持不变。
|
||||
|
||||
@@ -5,6 +5,13 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## Agent 分镜绑定参数引导
|
||||
|
||||
- 已优化:工具说明区分素材输入和镜头输出的两个 JSON 示例;handle 错误明确字段、正确方向和真实 rowId 要求,并在回执附上对应连线模板。不放宽校验、不自动改线。
|
||||
- [x] 专项测试覆盖来源/目标 handle 放错节点、镜头编号误用、缺少 row 前缀、错误回执模板方向、普通连线与行绑定并存,以及重复行绑定仍拒绝;工具 schema 体积检查通过。
|
||||
- [x] 用户已在本地验证镜头绑定可用;自动化测试不调用外部模型,不修改实际创作画布。
|
||||
- [ ] 上游合并后验证新会话沿用正确绑定方向,错误调用不会被误报为成功。
|
||||
|
||||
## 复制生图节点后节点消失
|
||||
|
||||
- 已修复:副本不再继承原节点的 `generationEffectKeys`,避免普通保存把它误判为未确认的生成结果。图片内容、提示词和参考字段仍保留,原节点不受影响。
|
||||
|
||||
Reference in New Issue
Block a user