feat(canvas): 批量创作表 - 支持全局提示词、参考图编排与连线同步

This commit is contained in:
ddCat
2026-09-18 13:03:35 +08:00
parent a8770c89b0
commit c994566ffe
13 changed files with 714 additions and 116 deletions

View File

@@ -29,6 +29,7 @@ type cloudAgentBatchTableEditArgs struct {
Patch map[string]any `json:"patch"`
Operation string `json:"operation"`
Concurrency int `json:"concurrency"`
GlobalPrompt string `json:"globalPrompt"`
}
type cloudAgentBatchTableMutationPlan struct {
@@ -87,6 +88,12 @@ func batchTableNodeFromDocument(doc map[string]any, nodeID string) (map[string]a
if !ok || !cloudAgentBatchConcurrencyAllowed(concurrency) {
return nil, nil, nil, nil, BadAuthRequest("批量创作表包含无效的并发数")
}
if rawPrompt, exists := table["globalPrompt"]; exists {
globalPrompt, ok := rawPrompt.(string)
if !ok || utf8.RuneCountInString(globalPrompt) > maxCloudAgentBatchPromptRunes {
return nil, nil, nil, nil, BadAuthRequest("批量创作表包含无效或过长的全局提示词")
}
}
columns := creationMaps(table["referenceColumns"])
if len(columns) < 1 || len(columns) > maxCloudAgentBatchReferences {
return nil, nil, nil, nil, BadAuthRequest("批量创作表必须包含 1 到 6 个参考图列")
@@ -182,7 +189,7 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas
if err := validateCloudAgentID(args.NodeID, "批量创作表节点ID", 80); err != nil {
return nil, err
}
allowedActions := map[string]bool{"append": true, "update": true, "remove": true, "set_operation": true, "set_concurrency": true, "add_reference_column": true}
allowedActions := map[string]bool{"append": true, "update": true, "remove": true, "set_operation": true, "set_concurrency": true, "add_reference_column": true, "remove_reference_column": true, "set_global_prompt": true}
if !allowedActions[args.Action] {
return nil, BadAuthRequest("批量创作表操作无效")
}
@@ -302,6 +309,38 @@ func prepareCloudAgentBatchTableEdit(repo *repository.Repository, userID, canvas
table["referenceColumns"] = mapsAsAny(columns)
fields = []string{"参考图列"}
summaryVerb = "新增参考图列到"
case "remove_reference_column":
if len(columns) <= 1 {
return nil, BadAuthRequest("批量创作表至少保留1组参考图")
}
if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 {
return nil, BadAuthRequest("减少参考图列不接受其他修改参数")
}
columns = columns[:len(columns)-1]
for index, column := range columns {
column["label"] = fmt.Sprintf("参考图 %d", index+1)
}
table["referenceColumns"] = mapsAsAny(columns)
for _, row := range rows {
row["inputNodeIds"] = cloudAgentBatchInputIDs(row["inputNodeIds"], len(columns))
}
table["rows"] = mapsAsAny(rows)
fields = []string{"参考图列"}
summaryVerb = "减少参考图列自"
case "set_global_prompt":
if utf8.RuneCountInString(args.GlobalPrompt) > maxCloudAgentBatchPromptRunes {
return nil, BadAuthRequest("全局提示词必须是不超过20000字的文本")
}
if args.RowID != "" || len(args.Patch) != 0 || args.Operation != "" || args.Concurrency != 0 {
return nil, BadAuthRequest("设置全局提示词不接受其他修改参数")
}
if strings.TrimSpace(args.GlobalPrompt) == "" {
delete(table, "globalPrompt")
} else {
table["globalPrompt"] = args.GlobalPrompt
}
fields = []string{"全局提示词"}
summaryVerb = "设置全局提示词到"
}
metadata["batchTable"] = table
title := cloudAgentApprovalNodeTitle(node, "批量创作表")

View File

@@ -48,7 +48,7 @@ func TestCloudAgentBatchTableCapabilityMatchesCanvasComponent(t *testing.T) {
if !ok {
t.Fatal("batch-table capability missing")
}
if descriptor.Label != "批量创作表" || descriptor.DefaultWidth != 900 || descriptor.DefaultHeight != 520 || descriptor.InputKind != "text" || descriptor.ProjectionKind != "batch_table" || descriptor.ProjectionField != "batchTable" {
if descriptor.Label != "批量创作表" || descriptor.DefaultWidth != 1280 || descriptor.DefaultHeight != 560 || descriptor.InputKind != "text" || descriptor.ProjectionKind != "batch_table" || descriptor.ProjectionField != "batchTable" {
t.Fatalf("batch-table capability differs from component contract: %+v", descriptor)
}
if err := descriptor.ValidateConnection("image"); err != nil {
@@ -97,6 +97,20 @@ func TestCloudAgentBatchTableToolsAreScopedAndStructured(t *testing.T) {
t.Fatalf("batch row patch schema missing %s", field)
}
}
if properties["globalPrompt"] == nil {
t.Fatal("batch table edit schema missing globalPrompt")
}
actionEnum, _ := properties["action"].(map[string]any)["enum"].([]string)
foundGlobalPrompt := false
for _, action := range actionEnum {
if action == "set_global_prompt" {
foundGlobalPrompt = true
break
}
}
if !foundGlobalPrompt {
t.Fatalf("batch table edit schema missing set_global_prompt: %+v", actionEnum)
}
for _, forbidden := range []string{"outputNodeId", "taskId", "url", "storageKey", "metadata"} {
if patch[forbidden] != nil {
t.Fatalf("batch row patch schema exposes forbidden field %s", forbidden)
@@ -187,6 +201,85 @@ func TestCloudAgentCanReadAndEditBatchTableRows(t *testing.T) {
}
}
func TestCloudAgentCanSetBatchTableGlobalPrompt(t *testing.T) {
s, canvas := cloudAgentBatchTableFixture(t)
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
t.Fatal(err)
}
policy, err := s.RuntimePolicy()
if err != nil {
t.Fatal(err)
}
appendCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "append-empty-prompt", map[string]any{
"snapshotHash": cloudAgentCanvasHash(doc), "nodeId": "batch-1", "action": "append",
"patch": map[string]any{"inputNodeIds": []string{"image-1", "image-2"}, "prompt": " "},
})
appendResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, appendCall, policy)
if err != nil {
t.Fatal(err)
}
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: canvas.ID}}
readCall := cloudAgentBatchTableCall(t, "canvas_read_batch_table", "read-before-global", map[string]any{"nodeId": "batch-1", "offset": 0})
before, err := cloudAgentReadTool(s.repo, "user", state, readCall)
if err != nil {
t.Fatal(err)
}
beforePreview := before.(map[string]any)["batchTable"].(map[string]any)["generationPreview"].(map[string]any)
if beforePreview["readyRows"] != 0 || beforePreview["missingPromptRows"] != 1 {
t.Fatalf("empty row prompt should not be ready before global prompt: %+v", beforePreview)
}
setCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "set-global", map[string]any{
"snapshotHash": appendResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
"globalPrompt": " 全局换装提示词 ",
})
setResult, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, setCall, policy)
if err != nil {
t.Fatal(err)
}
after, err := cloudAgentReadTool(s.repo, "user", state, readCall)
if err != nil {
t.Fatal(err)
}
table := after.(map[string]any)["batchTable"].(map[string]any)
if table["globalPrompt"] != "全局换装提示词" && table["globalPrompt"] != " 全局换装提示词 " {
t.Fatalf("global prompt was not projected: %+v", table)
}
preview := table["generationPreview"].(map[string]any)
if preview["readyRows"] != 1 || preview["missingPromptRows"] != 0 {
t.Fatalf("global prompt should make the row ready: %+v", preview)
}
clearCall := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "clear-global", map[string]any{
"snapshotHash": setResult.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
"globalPrompt": " ",
})
if _, err := applyCloudAgentBatchTableMutation(s.repo, "user", canvas.ID, clearCall, policy); err != nil {
t.Fatal(err)
}
cleared, err := cloudAgentReadTool(s.repo, "user", state, readCall)
if err != nil {
t.Fatal(err)
}
clearedTable := cleared.(map[string]any)["batchTable"].(map[string]any)
if _, exists := clearedTable["globalPrompt"]; exists {
t.Fatalf("empty global prompt should be cleared: %+v", clearedTable)
}
clearedPreview := clearedTable["generationPreview"].(map[string]any)
if clearedPreview["readyRows"] != 0 {
t.Fatalf("cleared global prompt should restore missing prompt: %+v", clearedPreview)
}
reject := cloudAgentBatchTableCall(t, "canvas_edit_batch_table", "reject-global-row", map[string]any{
"snapshotHash": cleared.(map[string]any)["snapshotHash"], "nodeId": "batch-1", "action": "set_global_prompt",
"rowId": "row-should-not-be-accepted", "globalPrompt": "x",
})
if _, err := prepareCloudAgentBatchTableEdit(s.repo, "user", canvas.ID, reject); err == nil || !strings.Contains(err.Error(), "不接受其他修改参数") {
t.Fatalf("set_global_prompt should reject row-level parameters: %v", err)
}
}
func TestCloudAgentBatchTableRejectsUnsafeAndStaleEdits(t *testing.T) {
s, canvas := cloudAgentBatchTableFixture(t)
doc, err := creationDocument(canvas.PayloadJSON)

View File

@@ -192,14 +192,15 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
"rowId": str("update/remove 使用 canvas_read_storyboard 返回的真实 rowId;append 留空"),
"patch": cloudAgentStoryboardPatchSchema(),
}, "snapshotHash", "nodeId", "action")
add("canvas_edit_batch_table", "操作批量创作表组件:追加、修改或删除任务行,切换批量换装/创意生图,设置1/5/10并发,或新增参考图列。必须先用 canvas_read_batch_table 获取最新 snapshotHash 和真实 rowId。行 patch 仅允许 enabled、inputNodeIds、prompt;prompt 可使用读取结果中的 @参考图1、@参考图2 等 mentionToken 指代本行对应位置的图片。append 未传 inputNodeIds 时会继承上一行参考图;图片ID必须来自当前画布。不能写 outputNodeId、任务状态、URL、storageKey 或任意 metadata。本工具只编辑计划,不提交收费生成。", map[string]any{
add("canvas_edit_batch_table", "操作批量创作表组件:追加、修改或删除任务行,切换批量换装/创意生图,设置1/5/10并发,新增或减少参考图列,或设置覆盖各任务的全局提示词。必须先用 canvas_read_batch_table 获取最新 snapshotHash 和真实 rowId。行 patch 仅允许 enabled、inputNodeIds、prompt;prompt 可使用读取结果中的 @参考图1、@参考图2 等 mentionToken 指代本行对应位置的图片。append 未传 inputNodeIds 时会继承上一行参考图;图片ID必须来自当前画布。不能写 outputNodeId、任务状态、URL、storageKey 或任意 metadata。本工具只编辑计划,不提交收费生成。", map[string]any{
"snapshotHash": str("最近一次批量创作表读取返回的 snapshotHash"),
"nodeId": str("真实批量创作表节点ID"),
"action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove", "set_operation", "set_concurrency", "add_reference_column"}},
"action": map[string]any{"type": "string", "enum": []string{"append", "update", "remove", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt"}},
"rowId": str("update/remove 使用 canvas_read_batch_table 返回的真实 rowId;其他操作留空"),
"patch": cloudAgentBatchTablePatchSchema(),
"operation": map[string]any{"type": "string", "enum": []string{"try_on", "creative"}},
"concurrency": map[string]any{"type": "integer", "enum": []int{1, 5, 10}},
"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"}},

View File

@@ -51,12 +51,12 @@ func BuiltinRegistry() *Registry {
},
},
{
Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 900, DefaultHeight: 520,
Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,并追踪生成结果。",
Type: "batch-table", Version: "1", Label: "批量创作表", DefaultWidth: 1280, DefaultHeight: 560,
Purpose: "面向电商批量换装和创意生图的结构化任务表;每行绑定最多六组画布图片、可用 @参考图1 等位置引用编写独立提示词,也可设置全局提示词覆盖各行,并追踪生成结果。",
GoodFor: []string{"商品与模特批量换装", "同一商品多场景创意图", "多组参考图组合生成", "批量结果追踪与失败重试"},
NotIdealFor: []string{"通用数据库或库存管理", "单张图片快速试验", "多镜头叙事连续性"},
Tradeoffs: []string{"参考图必须先作为图片节点进入画布", "批量提交会产生多项生成任务,执行前必须确认模型、数量和费用"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "preview_batch_generation"},
Actions: []string{"read_rows", "append_row", "update_row", "remove_row", "set_operation", "set_concurrency", "add_reference_column", "remove_reference_column", "set_global_prompt", "preview_batch_generation"},
InputKind: "text",
Connection: ConnectionPolicy{CanSource: true, CanTarget: true, AcceptedInputKinds: []string{"image"}},
CanUpdate: true, SummaryFields: []string{"batchTable"}, DetailFields: []string{"batchTable"}, ProjectionKind: "batch_table", ProjectionField: "batchTable",