fix(canvas): 媒体提示词 - 统一 Agent 更新与画布生成读取生成合同

This commit is contained in:
ddcat666 committed 2026-10-04 10:43:55 +08:00
1 parent 853c7d2ce7
commit 6d757b7001
20 files changed
+174 -48

No files matched your search

+2 -1
View File
@@ -68,10 +68,11 @@ func cloudAgentCreativeAnchorForCanvas(repo *repository.Repository, userID strin
continue
}
meta, _ := node["metadata"].(map[string]any)
prompt, _ := cloudAgentMediaPrompt(meta)
item := cloudAgentReferenceAnchor{
NodeID: stringValue(node["id"]), Type: descriptor.Type,
Title: truncateRunes(stringValue(node["title"]), 300),
Prompt: truncateRunes(firstNonEmpty(stringValue(meta["prompt"]), stringValue(meta["composerContent"])), 1000),
Prompt: truncateRunes(prompt, 1000),
VisualIdentity: "unknown", RequiresVisualInspection: true,
}
if tags, ok := meta["assetTags"].([]any); ok {
@@ -29,7 +29,7 @@ func TestCloudAgentCanvasApprovalPreviewDescribesUpdateTargetAndFields(t *testin
if item.NodeTitle != "满月庆祝视频草稿" || item.NodeType != "video" || item.NodeTypeLabel != "视频" {
t.Fatalf("preview lost target identity: %+v", item)
}
if item.ResultTitle != newTitle || strings.Join(item.Fields, "、") != "节点名称、下一版提示词" {
if item.ResultTitle != newTitle || strings.Join(item.Fields, "、") != "节点名称、提示词" {
t.Fatalf("preview lost changed fields: %+v", item)
}
raw, _ := json.Marshal(item)
@@ -37,7 +37,8 @@ func TestCloudAgentCanvasApprovalPreviewDescribesUpdateTargetAndFields(t *testin
t.Fatalf("preview exposed private content or internal id: %s", raw)
}
metadata := doc["nodes"].([]map[string]any)[0]["metadata"].(map[string]any)
if metadata["content"] != "已提交结果" || metadata["prompt"] != "private prompt" || metadata["composerContent"] != newPrompt {
generationSpec := metadata["generationSpec"].(map[string]any)
if metadata["content"] != "已提交结果" || metadata["prompt"] != "private prompt" || generationSpec["prompt"] != newPrompt {
t.Fatalf("preview mutated media result incorrectly: %+v", metadata)
}
}
@@ -216,8 +216,13 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) {
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)
if cat == nil || stringValue(cat["title"]) != "叮当猫飞行参考" {
t.Fatalf("canvas_apply_ops did not persist the requested prompt contract: %+v", cat)
}
metadata := cat["metadata"].(map[string]any)
generationSpec := metadata["generationSpec"].(map[string]any)
if stringValue(generationSpec["prompt"]) != "下一版参考图提示词" {
t.Fatalf("canvas_apply_ops did not persist the requested prompt contract: %+v", cat)
}
actions := creationMaps(trace["actions"])
@@ -238,7 +243,7 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) {
fieldValues = append(fieldValues, stringValue(field))
}
}
if len(fieldValues) != 2 || fieldValues[0] != "节点名称" || fieldValues[1] != "下一版提示词" {
if len(fieldValues) != 2 || fieldValues[0] != "节点名称" || fieldValues[1] != "提示词" {
t.Fatalf("canvas operation trace lost updated fields: %+v", updated)
}
created := byActionAndNode["created:trace-video"]
@@ -440,9 +440,15 @@ func cloudAgentProjectNodeFields(node, meta map[string]any, descriptor capabilit
}
continue
}
value, ok := node[key]
if !ok {
value, ok = meta[key]
var value any
var ok bool
if key == "prompt" && descriptor.GenerationMode != "" {
value, ok = cloudAgentMediaPrompt(meta)
} else {
value, ok = node[key]
if !ok {
value, ok = meta[key]
}
}
if !ok || (key == "content" && descriptor.GenerationMode != "") {
continue
@@ -457,6 +463,21 @@ func cloudAgentProjectNodeFields(node, meta map[string]any, descriptor capabilit
return projected, nil
}
func cloudAgentMediaPrompt(meta map[string]any) (string, bool) {
if generationSpec, ok := meta["generationSpec"].(map[string]any); ok {
if prompt, ok := generationSpec["prompt"].(string); ok {
return prompt, true
}
}
if prompt, ok := meta["composerContent"].(string); ok {
return prompt, true
}
if prompt, ok := meta["prompt"].(string); ok {
return prompt, true
}
return "", false
}
func cloudAgentProjectionValue(node, meta map[string]any, path string) (any, bool) {
parts := strings.Split(path, ".")
for _, root := range []map[string]any{node, meta} {
@@ -963,7 +963,8 @@ func TestCloudAgentCanvasUpdatesExistingVideoDraftThroughCapabilityContract(t *t
}
updated := updatedNodes["video-1789310237935-mmh3-baby-fullmoon"]
metadata := updated["metadata"].(map[string]any)
if updated["title"] != "满月庆祝视频草稿(舒缓呼吸感)" || metadata["composerContent"] != "下一版舒缓视频提示词" {
generationSpec := metadata["generationSpec"].(map[string]any)
if updated["title"] != "满月庆祝视频草稿(舒缓呼吸感)" || generationSpec["prompt"] != "下一版舒缓视频提示词" {
t.Fatalf("video draft was not updated: %#v", updated)
}
if metadata["prompt"] != "原始已提交提示词" || metadata["status"] != "error" || metadata["referenceIssue"] != "参考资产尚未准备完成" {
+22 -6
View File
@@ -1,5 +1,7 @@
package capability
import "infinite-canvas/backend/internal/canvas/contract"
const (
maxAgentNodeTitleRunes = 240
maxAgentNodeContentRunes = 16000
@@ -136,10 +138,10 @@ func generatedMediaDescriptor(nodeType, version, label string, width, height flo
Purpose: semantics.Purpose, GoodFor: semantics.GoodFor, NotIdealFor: semantics.NotIdealFor,
Tradeoffs: semantics.Tradeoffs, Actions: semantics.Actions,
InputKind: nodeType, GenerationMode: generationMode, Connection: connection, CanUpdate: true,
SummaryFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
DetailFields: []string{"prompt", "composerContent", "assetTags", "referenceNodeIds"},
PatchFields: editableNodeFields("metadata.composerContent", "下一版提示词", "下次生成使用的提示词草稿;不覆盖已提交提示词或媒体结果"),
CreateMetadata: generatedMetadata,
SummaryFields: []string{"prompt", "assetTags", "referenceNodeIds"},
DetailFields: []string{"prompt", "assetTags", "referenceNodeIds"},
PatchFields: editableNodeFields("metadata.generationSpec.prompt", "提示词", "生成合同中的当前提示词;这是 Agent 唯一读写的媒体提示词"),
CreateMetadata: func(prompt string) map[string]any { return generatedMetadata(generationMode, prompt) },
}
}
@@ -211,6 +213,20 @@ func positionPatchFields() map[string]PatchField {
}
}
func generatedMetadata(prompt string) map[string]any {
return map[string]any{"content": "", "prompt": prompt, "composerContent": prompt, "status": "idle"}
func generatedMetadata(mode, prompt string) map[string]any {
spec := contract.GenerationSpec{
Version: contract.GenerationVersion,
Mode: mode,
Prompt: prompt,
Options: contract.Options{},
ReferenceBindings: []contract.ReferenceBinding{},
TextInputMode: "append-sources",
}
metadata, err := spec.NodeMetadata()
if err != nil {
panic(err)
}
metadata["content"] = ""
metadata["status"] = "idle"
return metadata
}
@@ -6,6 +6,8 @@ import (
"sort"
"strings"
"unicode"
"infinite-canvas/backend/internal/canvas/contract"
)
const (
@@ -146,6 +148,23 @@ func (d Descriptor) ApplyPatch(node map[string]any, patch map[string]any) error
}
for key, value := range patch {
field := d.PatchFields[key]
if field.Path == "metadata.generationSpec.prompt" {
metadata, ok := node["metadata"].(map[string]any)
if !ok {
metadata = map[string]any{}
node["metadata"] = metadata
}
if _, ok := metadata["generationSpec"].(map[string]any); !ok {
metadata["generationSpec"] = map[string]any{
"version": float64(contract.GenerationVersion),
"mode": d.GenerationMode,
"prompt": "",
"options": map[string]any{},
"referenceBindings": []any{},
"textInputMode": "append-sources",
}
}
}
parts := strings.Split(field.Path, ".")
target := node
for _, part := range parts[:len(parts)-1] {
@@ -241,8 +241,8 @@ func TestBuiltinMediaCapabilitiesUpdateDraftWithoutOverwritingGeneratedAssets(t
if !ok || !descriptor.CanUpdate {
t.Fatalf("media capability is not updateable: %#v", descriptor)
}
if descriptor.PatchFields["content"].Path != "metadata.composerContent" || descriptor.PatchFields["title"].Path != "title" {
t.Fatalf("unsafe media update paths: %#v", descriptor.PatchFields)
if descriptor.PatchFields["content"].Path != "metadata.generationSpec.prompt" || descriptor.PatchFields["title"].Path != "title" {
t.Fatalf("media update must target the generation contract: %#v", descriptor.PatchFields)
}
node := map[string]any{
@@ -251,28 +251,70 @@ func TestBuiltinMediaCapabilitiesUpdateDraftWithoutOverwritingGeneratedAssets(t
"content": "resource:generated-result",
"prompt": "已提交提示词",
"composerContent": "旧草稿",
"status": "success",
"generationSpec": map[string]any{
"version": float64(1),
"mode": nodeType,
"prompt": "旧草稿",
"options": map[string]any{},
"referenceBindings": []any{},
"textInputMode": "append-sources",
},
"status": "success",
},
}
if err := descriptor.ApplyPatch(node, map[string]any{"title": "新标题", "content": "下一版提示词"}); err != nil {
t.Fatal(err)
}
metadata := node["metadata"].(map[string]any)
if node["title"] != "新标题" || metadata["composerContent"] != "下一版提示词" {
t.Fatalf("media draft was not updated: %#v", node)
generationSpec := metadata["generationSpec"].(map[string]any)
if node["title"] != "新标题" || generationSpec["prompt"] != "下一版提示词" || metadata["composerContent"] != "旧草稿" {
t.Fatalf("media prompt contract was not updated: %#v", node)
}
if metadata["content"] != "resource:generated-result" || metadata["prompt"] != "已提交提示词" || metadata["status"] != "success" {
if metadata["content"] != "resource:generated-result" || metadata["prompt"] != "已提交提示词" || metadata["status"] != "success" || generationSpec["prompt"] != "下一版提示词" {
t.Fatalf("media result or submission snapshot was overwritten: %#v", metadata)
}
created := descriptor.Metadata("初始草稿")
if created["content"] != "" || created["prompt"] != "初始草稿" || created["composerContent"] != "初始草稿" || created["status"] != "idle" {
createdSpec := created["generationSpec"].(map[string]any)
if created["content"] != "" || created["prompt"] != "初始草稿" || created["composerContent"] != "初始草稿" || createdSpec["prompt"] != "初始草稿" || created["status"] != "idle" {
t.Fatalf("media draft metadata is incomplete: %#v", created)
}
})
}
}
func TestBuiltinMediaCapabilitiesUpdateDraftInitializesEmptyGenerationContract(t *testing.T) {
registry := BuiltinRegistry()
for _, nodeType := range []string{"image", "video", "audio"} {
t.Run(nodeType, func(t *testing.T) {
descriptor, ok := registry.Resolve(nodeType)
if !ok {
t.Fatal("missing media descriptor")
}
node := map[string]any{
"title": "旧媒体节点",
"metadata": map[string]any{
"content": "resource:already-generated",
"prompt": "已提交快照",
"composerContent": "旧兼容草稿",
"status": "success",
},
}
if err := descriptor.ApplyPatch(node, map[string]any{"content": "空合同后的新提示词"}); err != nil {
t.Fatal(err)
}
metadata := node["metadata"].(map[string]any)
spec := metadata["generationSpec"].(map[string]any)
if spec["mode"] != nodeType || spec["prompt"] != "空合同后的新提示词" {
t.Fatalf("empty generation contract was not initialized: %#v", metadata)
}
if metadata["content"] != "resource:already-generated" || metadata["prompt"] != "已提交快照" || metadata["composerContent"] != "旧兼容草稿" || metadata["status"] != "success" {
t.Fatalf("legacy media state was overwritten: %#v", metadata)
}
})
}
}
func TestRegistryResolveUnknownTypeFailsClosed(t *testing.T) {
registry, err := NewRegistry([]Descriptor{testDescriptor()})
if err != nil {