diff --git a/backend/internal/app/cloud_agent_anchor.go b/backend/internal/app/cloud_agent_anchor.go index d580ab699..702dfcdf1 100644 --- a/backend/internal/app/cloud_agent_anchor.go +++ b/backend/internal/app/cloud_agent_anchor.go @@ -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 { diff --git a/backend/internal/app/cloud_agent_approval_preview_test.go b/backend/internal/app/cloud_agent_approval_preview_test.go index e02f27d03..10c2a398a 100644 --- a/backend/internal/app/cloud_agent_approval_preview_test.go +++ b/backend/internal/app/cloud_agent_approval_preview_test.go @@ -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) } } diff --git a/backend/internal/app/cloud_agent_canvas_events_test.go b/backend/internal/app/cloud_agent_canvas_events_test.go index 909d0f436..2d1b1aa26 100644 --- a/backend/internal/app/cloud_agent_canvas_events_test.go +++ b/backend/internal/app/cloud_agent_canvas_events_test.go @@ -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"] diff --git a/backend/internal/app/cloud_agent_canvas_state.go b/backend/internal/app/cloud_agent_canvas_state.go index 4de772cf2..59e743d2f 100644 --- a/backend/internal/app/cloud_agent_canvas_state.go +++ b/backend/internal/app/cloud_agent_canvas_state.go @@ -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} { diff --git a/backend/internal/app/cloud_agent_media_test.go b/backend/internal/app/cloud_agent_media_test.go index 56b74fb8a..8dc8cc0b0 100644 --- a/backend/internal/app/cloud_agent_media_test.go +++ b/backend/internal/app/cloud_agent_media_test.go @@ -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"] != "参考资产尚未准备完成" { diff --git a/backend/internal/canvas/capability/builtin.go b/backend/internal/canvas/capability/builtin.go index 5aa2dc6a7..d9e679350 100644 --- a/backend/internal/canvas/capability/builtin.go +++ b/backend/internal/canvas/capability/builtin.go @@ -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 } diff --git a/backend/internal/canvas/capability/descriptor.go b/backend/internal/canvas/capability/descriptor.go index 20dc8ab16..0fbc618ca 100644 --- a/backend/internal/canvas/capability/descriptor.go +++ b/backend/internal/canvas/capability/descriptor.go @@ -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] { diff --git a/backend/internal/canvas/capability/registry_test.go b/backend/internal/canvas/capability/registry_test.go index d66d1d0a6..440bd7446 100644 --- a/backend/internal/canvas/capability/registry_test.go +++ b/backend/internal/canvas/capability/registry_test.go @@ -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 { diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 925f0be4c..aaa5d8522 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -11,7 +11,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities"; import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection"; import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; -import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract"; +import { canonicalGenerationMetadata, nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { workflowProviderPluginEnabled } from "@/lib/plugins/builtin/workflows"; @@ -145,7 +145,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC ]; const chipStyle = { background: theme.node.fill, color: theme.node.text }; const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount); - const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim()); + const hasComposerContent = Boolean(nodeGenerationPrompt(node).trim()); const workflowParameterError = firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {}); const capabilityError = workflowParameterError || (workflowProvider === "runninghub" ? (!workflowProviderPluginEnabled(runtimeStatuses, "runninghub") ? "RunningHub 工作流插件未启用" : !globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined) diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index e5c1d6041..36ca5cf49 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -9,11 +9,12 @@ import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/can import { isNeutralColorGrade, resolveCanvasColorGradeReference } from "@/lib/canvas/canvas-color-grade"; import { getNodeGenerationMode, getNodeResourceKind } from "@/lib/canvas/node-registry"; import { mediaConversionSourceFingerprint } from "@/lib/media-conversion/contracts"; +import { audioFileExtension } from "@/lib/character-voice-formats"; import { resolveCanvasDrawingReference } from "@/lib/canvas/canvas-drawing-reference"; import { compileCharacterReferencePrompt, normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference"; import { nodeReferenceImage } from "@/lib/canvas/canvas-project-generation"; import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow"; -import { audioFileExtension } from "@/lib/character-voice-formats"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import type { ModelReferenceLimits } from "@/lib/model-selection"; import type { Asset } from "@/stores/use-asset-store"; @@ -89,7 +90,7 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData const hasConnectedMedia = connectedInputs.some((input) => input.type === "image" || input.type === "video" || input.type === "audio" || input.type === "character"); // 豆包音频按连线决定纯文本、参考音频或参考图片,不能因为提示词里没有逐个 @ 就丢掉已连接素材。 const includeConnectedAudioMedia = Boolean(sourceNode && getNodeGenerationMode(sourceNode) === "audio"); - if ((promptOnly && hasConnectedMedia) || (Boolean(sourceNode?.metadata?.composerContent?.trim()) && (sourceNode?.type === CanvasNodeType.Config || isWorkflowSource)) || hasExplicitResourceMention) { + if ((promptOnly && hasConnectedMedia) || (sourceNode ? Boolean(nodeGenerationPrompt(sourceNode).trim()) && (sourceNode.type === CanvasNodeType.Config || isWorkflowSource) : false) || hasExplicitResourceMention) { const autoIncludeWorkflowMedia = isWorkflowSource || includeConnectedAudioMedia; return buildComposerGenerationContext( mentionInputs, @@ -582,7 +583,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex function readNodeTextInput(node: CanvasNodeData) { if (node.type === CanvasNodeType.Text) return node.metadata?.content || node.metadata?.prompt || ""; if (node.type === CanvasNodeType.Skill) return readSkillInput(node); - return node.metadata?.prompt || ""; + return nodeGenerationPrompt(node); } function readCharacterReference(node: CanvasNodeData): CharacterGenerationReference | null { diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 653010ecf..23e4155e3 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -5,7 +5,7 @@ import { ArrowUp, ChevronDown, FileText, ImagePlus, LayoutList, LoaderCircle, Ma import { ModelPicker } from "@/components/model-picker"; import { modelOptionName, resolveModelChannel, useEffectiveConfig } from "@/stores/use-config-store"; -import { canonicalGenerationMetadata } from "@/lib/canvas/generation-contract"; +import { canonicalGenerationMetadata, nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { PROMPT_EDITOR_VIEWPORT_MARGIN } from "@/lib/canvas/canvas-prompt-editor-size"; import { CreditSymbol, requestCreditCost } from "@/constant/credits"; import { canvasThemes } from "@/lib/canvas-theme"; @@ -91,7 +91,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan node = { ...node, metadata: canonicalGenerationMetadata(node, mode) }; const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim()); const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content); - const savedPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; + const savedPrompt = nodeGenerationPrompt(node); const [prompt, setPrompt] = useState(savedPrompt); const promptRef = useRef(savedPrompt); const [presetOpen, setPresetOpen] = useState(false); diff --git a/web/src/components/canvas/canvas-node-toolbar.tsx b/web/src/components/canvas/canvas-node-toolbar.tsx index 672ccbc69..85e2431fc 100644 --- a/web/src/components/canvas/canvas-node-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-toolbar.tsx @@ -14,6 +14,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils"; import { generationErrorMessage } from "@/lib/generation-error"; import { useCopyText } from "@/hooks/use-copy-text"; import { producedModelLabel } from "@/lib/canvas/produced-model"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import { useEffectiveConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasWorkspaceMode, type ViewportTransform } from "@/types/canvas"; @@ -226,7 +227,7 @@ export function CanvasNodeToolbar({ const isAudio = node.type === CanvasNodeType.Audio; const hasImage = isImage && Boolean(node.metadata?.content); const copyImagePrompt = (target: CanvasNodeData) => { - const prompt = target.metadata?.prompt?.trim(); + const prompt = nodeGenerationPrompt(target).trim(); if (!prompt) { message.warning("暂无可复制的提示词"); return; @@ -588,10 +589,10 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea ) : null} - {node.metadata?.prompt ? ( + {nodeGenerationPrompt(node) ? (
提示词
-
{node.metadata.prompt}
+
{nodeGenerationPrompt(node)}
) : null} diff --git a/web/src/lib/canvas/canvas-context-discovery.ts b/web/src/lib/canvas/canvas-context-discovery.ts index a16dbcb44..b8d2da05b 100644 --- a/web/src/lib/canvas/canvas-context-discovery.ts +++ b/web/src/lib/canvas/canvas-context-discovery.ts @@ -3,6 +3,7 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; import { getNodeDefinition } from "./node-registry"; import { isPluginEffectivelyEnabled } from "@/stores/use-plugin-store"; import { inspectStoryboardReadiness } from "./canvas-storyboard-context"; +import { nodeGenerationPrompt } from "./generation-contract"; export type CanvasResourceInsight = { nodeId: string; @@ -68,7 +69,7 @@ export function findCanvasNodes(snapshot: CanvasSnapshot, input: { query?: strin if (statuses && !statuses.has(String(metadata.status || "idle"))) return false; if (input.resourceOnly && !resourceFromNode(node)) return false; if (!query) return true; - return [node.id, node.title, metadata.content, metadata.prompt, metadata.composerContent, metadata.assetId, Array.isArray(metadata.assetTags) ? metadata.assetTags.join(" ") : "", metadata.workflowKind, metadata.workflowTitle, metadata.characterName] + return [node.id, node.title, metadata.content, nodeGenerationPrompt(node), metadata.assetId, Array.isArray(metadata.assetTags) ? metadata.assetTags.join(" ") : "", metadata.workflowKind, metadata.workflowTitle, metadata.characterName] .some((value) => String(value || "").toLocaleLowerCase().includes(query)); }); return { query: input.query || "", total: nodes.length, truncated: nodes.length > limit, nodes: nodes.slice(0, limit).map(compactNode) }; @@ -248,7 +249,7 @@ function compactNode(node: CanvasNodeData) { parentId: node.parentId, status: String(metadata.status || "idle"), content: preview(metadata.content, 240), - prompt: preview(metadata.prompt || metadata.composerContent, 300), + prompt: preview(nodeGenerationPrompt(node), 300), generation: metadata.generationMode || metadata.workflowKind || metadata.taskId ? { mode: metadata.generationMode, model: metadata.model, workflowKind: metadata.workflowKind, workflowTitle: metadata.workflowTitle, taskId: metadata.taskId, status: metadata.taskStatus || metadata.status, progress: numberValue(metadata.taskProgress ?? metadata.progress), stage: metadata.taskStage, provider: metadata.taskProvider, errorCode: metadata.taskErrorCode || metadata.generationErrorCode } : undefined, error: metadata.status === "error" ? preview(metadata.errorDetails || metadata.generationErrorCode, 360) : undefined, asset: metadata.assetId || metadata.characterAssetId ? { assetId: metadata.assetId || metadata.characterAssetId, versionId: metadata.characterVersionId, category: metadata.assetCategory, tags: metadata.assetTags, characterName: metadata.characterName } : undefined, diff --git a/web/src/lib/canvas/canvas-node-search.ts b/web/src/lib/canvas/canvas-node-search.ts index 3ebb8a653..7544348e5 100644 --- a/web/src/lib/canvas/canvas-node-search.ts +++ b/web/src/lib/canvas/canvas-node-search.ts @@ -3,7 +3,7 @@ import { producedModelLabel, producedModelSearchTerms } from "@/lib/canvas/produ import type { AiConfig } from "@/stores/use-config-store"; import { canvasNodeCreatedAt, canvasNodeUpdatedAt } from "@/lib/canvas/canvas-node-timestamps"; import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas"; - +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; const searchTimeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", @@ -25,7 +25,7 @@ export function canvasNodeSearchContext(node: CanvasNodeData) { const textContent = node.type === CanvasNodeType.Text || node.type === CanvasNodeType.Markdown || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Skill ? node.metadata?.content : undefined; - return location || node.metadata?.prompt || node.metadata?.composerContent || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type); + return location || nodeGenerationPrompt(node) || node.metadata?.workflowDescription || textContent || getNodeListLabel(node.type); } export function canvasNodeMaterialSummary(node: CanvasNodeData, config?: AiConfig) { @@ -56,8 +56,7 @@ function canvasNodeSearchTerms(node: CanvasNodeData, config?: AiConfig) { node.title, node.type, getNodeListLabel(node.type), - node.metadata?.prompt, - node.metadata?.composerContent, + nodeGenerationPrompt(node), node.metadata?.model, ...producedModelSearchTerms(node.metadata, config), node.metadata?.chapterTitle, diff --git a/web/src/lib/canvas/canvas-resource-references.ts b/web/src/lib/canvas/canvas-resource-references.ts index 513d73604..d8f836891 100644 --- a/web/src/lib/canvas/canvas-resource-references.ts +++ b/web/src/lib/canvas/canvas-resource-references.ts @@ -1,6 +1,7 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt"; import { normalizeCharacterImageMentions } from "@/lib/canvas/canvas-character-reference"; import { canvasNodeVideoPreviewUrl, canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { writeCanvasNodePrompt } from "@/lib/canvas/canvas-node-prompt"; import { getNodeResourceKind } from "@/lib/canvas/node-registry"; import { seedanceReferenceLabel } from "@/lib/seedance-video"; @@ -264,7 +265,7 @@ export function applyCanvasConnectionPromptSync(previousNodes: CanvasNodeData[], const nextMap = buildCanvasNodeMentionReferenceMap(nextNodes, nextConnections, nextNodes); let changed = false; const mapped = nextNodes.map((node) => { - const previousPrompt = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; + const previousPrompt = nodeGenerationPrompt(node); const nextPrompt = rewriteCanvasPromptAfterReferenceChange(previousPrompt, previousMap.get(node.id) || [], nextMap.get(node.id) || []); if (nextPrompt === previousPrompt) return node; changed = true; @@ -387,7 +388,7 @@ export function buildCanvasAgentMentionReferences(nodes: CanvasNodeData[]): Canv previewStorageKey: node.type === CanvasNodeType.Video ? node.metadata?.videoPreview?.storageKey : undefined, drawingId: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingId : undefined, drawingRevision: node.type === CanvasNodeType.Drawing ? node.metadata?.drawingRevision : undefined, - text: node.metadata?.content || node.metadata?.composerContent || node.metadata?.prompt || node.title, + text: node.metadata?.content || nodeGenerationPrompt(node) || node.title, active: true, sourceType: node.type, mentionToken: canvasNodeMentionToken(node.id), diff --git a/web/src/lib/canvas/generation-contract.ts b/web/src/lib/canvas/generation-contract.ts index 0be68376e..5877c3582 100644 --- a/web/src/lib/canvas/generation-contract.ts +++ b/web/src/lib/canvas/generation-contract.ts @@ -120,6 +120,20 @@ export function readNodeGenerationSpec(node: Pick): string { + const mode = canvasGenerationMode(node.type); + if (mode) { + try { + const spec = readNodeGenerationSpec(node); + if (spec?.mode === mode) return spec.prompt; + } catch { + // A malformed legacy contract should not make canvas discovery fail. + } + } + const metadata = node.metadata || {}; + return typeof metadata.composerContent === "string" ? metadata.composerContent : typeof metadata.prompt === "string" ? metadata.prompt : ""; +} + export function canonicalGenerationMetadata(node: CanvasNodeData, mode: string): CanvasNodeMetadata { if (!canvasGenerationMode(mode)) return { ...node.metadata }; const spec = readNodeGenerationSpec(node); diff --git a/web/src/pages/canvas/shared.tsx b/web/src/pages/canvas/shared.tsx index 93b7e4118..0d3e9eb62 100644 --- a/web/src/pages/canvas/shared.tsx +++ b/web/src/pages/canvas/shared.tsx @@ -22,7 +22,7 @@ import { getContextResourceNodes } from "@/lib/canvas/canvas-resource-references import { getPublicCanvasShare } from "@/services/api/canvas-share"; import { useCanvasThemeStore, useCanvasThemeScope } from "@/stores/canvas/use-canvas-theme-store"; import { CanvasNodeType, type CanvasNodeData, type Position, type ViewportTransform } from "@/types/canvas"; - +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; type ContextMenu = { x: number; y: number; world: Position; nodeId?: string }; type DragState = { primaryId: string; nodeIds: string[]; startX: number; startY: number; origins: Map; moved: boolean }; @@ -314,7 +314,7 @@ function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)]; return
{node.title}
-
{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}
+
{nodeGenerationPrompt(node) || "未填写提示词"}
; } @@ -325,6 +325,6 @@ function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn return
{node.title}{rows.length} 镜
event.stopPropagation()}>{rows.length ? rows.map((row) =>
#{row.shotNumber}{row.durationSeconds}s{row.plotDescription || "-"}{row.dialogue || "-"}
) :
暂无分镜
}
- {node.metadata?.composerContent ?
{node.metadata.composerContent}
: null} + {nodeGenerationPrompt(node) ?
{nodeGenerationPrompt(node)}
: null}
; } diff --git a/web/src/pages/canvas/use-canvas-generation-batches.ts b/web/src/pages/canvas/use-canvas-generation-batches.ts index a5b2b9984..0fbb9b4d0 100644 --- a/web/src/pages/canvas/use-canvas-generation-batches.ts +++ b/web/src/pages/canvas/use-canvas-generation-batches.ts @@ -5,6 +5,7 @@ import { nanoid } from "nanoid"; import { generationBatchStatus, isGenerationCostUncertainError } from "@/lib/canvas/canvas-generation-batch"; import { buildGenerationConfig, createGenerationRetryContext, generationTaskMetadata, resetGenerationTaskMetadata } from "@/lib/canvas/canvas-project-generation"; import { unchangedModeratedPrompt } from "@/lib/generation-error"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { listGenerationTasks } from "@/services/api/task-center"; import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -207,7 +208,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no updateBatch(batch.sourceNodeId, batch.id, (current) => withUpdatedItem(current, item.id, { status: "failed", errorDetails: "生成模型未配置,请完成配置后重试" })); continue; } - const prompt = (node.metadata?.composerContent || node.metadata?.prompt || "").trim(); + const prompt = nodeGenerationPrompt(node).trim(); if (!prompt) { updateBatch(batch.sourceNodeId, batch.id, (current) => withUpdatedItem(current, item.id, { status: "failed", errorDetails: "生成提示词为空" })); continue; @@ -242,7 +243,7 @@ export function useCanvasGenerationBatches({ projectId, projectLoaded, nodes, no const nodeById = new Map(nodesRef.current.map((node) => [node.id, node])); const blockedItems = failedItems.filter((item) => { const node = nodeById.get(item.nodeId); - return unchangedModeratedPrompt(node?.metadata, node?.metadata?.composerContent || node?.metadata?.prompt || ""); + return unchangedModeratedPrompt(node?.metadata, node ? nodeGenerationPrompt(node) : ""); }); const retryableItems = failedItems.filter((item) => !blockedItems.includes(item)); if (blockedItems.length) message.warning(`${blockedItems.length} 个镜头未通过内容审核,请先修改提示词`); diff --git a/web/src/pages/canvas/use-canvas-generation-retry.ts b/web/src/pages/canvas/use-canvas-generation-retry.ts index 3f8092fcd..ebac9e9a7 100644 --- a/web/src/pages/canvas/use-canvas-generation-retry.ts +++ b/web/src/pages/canvas/use-canvas-generation-retry.ts @@ -2,6 +2,7 @@ import { useCallback, type Dispatch, type SetStateAction } from "react"; import { App } from "antd"; import { buildNodeGenerationContext, hydrateNodeGenerationContext } from "@/components/canvas/canvas-node-generation"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { producedModelCandidateForGeneration } from "@/lib/canvas/produced-model"; import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel"; import { buildEmotionImageArtifacts, emotionGenerationSize, emotionProviderMask, normalizeEmotionPromptForProvider, resolveEmotionEditPlan } from "@/lib/canvas/canvas-emotion"; @@ -132,7 +133,7 @@ export function useCanvasGenerationRetry({ return; } - const retryPromptSource = sourceNode.metadata?.composerContent || sourceNode.metadata?.prompt || node.metadata?.prompt || ""; + const retryPromptSource = nodeGenerationPrompt(sourceNode) || nodeGenerationPrompt(node); const retryContextPrompt = retryMode === "image" && sourceNode.metadata?.portraitTexture ? buildPortraitTexturePrompt(retryPromptSource, sourceNode.metadata.portraitTexture) : retryPromptSource; if (unchangedModeratedPrompt(node.metadata, retryPromptSource)) { message.warning("该提示词未通过内容审核,请先修改提示词再重新生成"); diff --git a/web/src/pages/canvas/use-canvas-render-model.ts b/web/src/pages/canvas/use-canvas-render-model.ts index f6b208394..ebdeeab95 100644 --- a/web/src/pages/canvas/use-canvas-render-model.ts +++ b/web/src/pages/canvas/use-canvas-render-model.ts @@ -8,6 +8,7 @@ import { buildCanvasNodeMentionReferenceMap, buildCanvasResourceReferences, buil import { buildSkillMentionReferences } from "@/lib/canvas/canvas-skill-mentions"; import { buildCanvasSpatialIndex, canvasNodeBounds, type CanvasSpatialIndex, type CanvasSpatialIndexEntry } from "@/lib/canvas/canvas-spatial-index"; import { canvasOverviewMode, resolveCanvasNodeLOD, type CanvasNodeRenderLOD } from "@/lib/canvas/canvas-node-lod"; +import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract"; import { intersectsCanvasBounds, selectCanvasVisibleNodes } from "@/lib/canvas/canvas-node-visibility"; import type { Skill } from "@/services/api/skills"; import type { Asset, ImageAsset } from "@/stores/use-asset-store"; @@ -410,7 +411,7 @@ export function useCanvasRenderModel({ const toolMentionReferencesByNodeId = useMemo(() => { const map = new Map[]>(); for (const node of semanticNodes) { - const text = node.metadata?.composerContent ?? node.metadata?.prompt ?? ""; + const text = nodeGenerationPrompt(node); const tokens = parseToolMentionTokens(text); if (!tokens.length) continue; const seen = new Set();