fix(canvas): 媒体提示词 - 统一 Agent 更新与画布生成读取生成合同
This commit is contained in:
@@ -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"] != "参考资产尚未准备完成" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{node.metadata?.prompt ? (
|
||||
{nodeGenerationPrompt(node) ? (
|
||||
<section className="canvas-node-inspector-section">
|
||||
<div className="canvas-node-inspector-section-heading"><span>提示词</span></div>
|
||||
<div className="canvas-node-inspector-copy canvas-node-inspector-prompt">{node.metadata.prompt}</div>
|
||||
<div className="canvas-node-inspector-copy canvas-node-inspector-prompt">{nodeGenerationPrompt(node)}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -120,6 +120,20 @@ export function readNodeGenerationSpec(node: Pick<CanvasNodeData, "type" | "meta
|
||||
}
|
||||
|
||||
|
||||
export function nodeGenerationPrompt(node: Pick<CanvasNodeData, "type" | "metadata">): 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);
|
||||
|
||||
@@ -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<string, Position>; moved: boolean };
|
||||
|
||||
@@ -314,7 +314,7 @@ function SharedConfigNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn
|
||||
const theme = canvasThemes[useCanvasThemeStore((state) => state.theme)];
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><ImageIcon className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span></div>
|
||||
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{node.metadata?.composerContent || node.metadata?.prompt || "未填写提示词"}</div>
|
||||
<div className="min-h-0 flex-1 whitespace-pre-wrap break-words p-4 text-sm leading-6" style={{ color: theme.node.muted }}>{nodeGenerationPrompt(node) || "未填写提示词"}</div>
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-t px-3" style={{ borderColor: theme.node.stroke }}><Button size="small" icon={<Send className="size-3.5" />} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }}>生成</Button></div>
|
||||
</div>;
|
||||
}
|
||||
@@ -325,6 +325,6 @@ function SharedScriptNode({ node, onUnauthorized }: { node: CanvasNodeData; onUn
|
||||
return <div className="flex h-full w-full flex-col overflow-hidden rounded-[var(--panel-radius)]">
|
||||
<div className="flex h-10 shrink-0 items-center gap-2 border-b px-4" style={{ background: theme.node.panel, borderColor: theme.node.stroke }}><Clapperboard className="size-4" /><span className="min-w-0 flex-1 truncate text-sm font-semibold">{node.title}</span><span className="text-xs" style={{ color: theme.node.muted }}>{rows.length} 镜</span><button type="button" className="grid size-7 place-items-center rounded hover:bg-black/5 dark:hover:bg-white/10" onMouseDown={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onUnauthorized(); }} aria-label="一键创建视频节点"><Video className="size-3.5" /></button></div>
|
||||
<div data-canvas-wheel-scroll className="min-h-0 flex-1 overflow-y-auto" onWheel={(event) => event.stopPropagation()}>{rows.length ? rows.map((row) => <div key={row.id} className="grid grid-cols-[52px_72px_minmax(180px,1fr)_minmax(150px,.8fr)] border-b text-xs leading-5" style={{ minHeight: 48, borderColor: theme.node.stroke }}><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>#{row.shotNumber}</span><span className="grid place-items-center border-r" style={{ borderColor: theme.node.stroke }}>{row.durationSeconds}s</span><span className="border-r px-3 py-2" style={{ borderColor: theme.node.stroke }}>{row.plotDescription || "-"}</span><span className="px-3 py-2" style={{ color: theme.node.muted }}>{row.dialogue || "-"}</span></div>) : <div className="grid h-full place-items-center text-sm" style={{ color: theme.node.muted }}>暂无分镜</div>}</div>
|
||||
{node.metadata?.composerContent ? <div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>{node.metadata.composerContent}</div> : null}
|
||||
{nodeGenerationPrompt(node) ? <div className="max-h-24 shrink-0 overflow-y-auto border-t px-3 py-2 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>{nodeGenerationPrompt(node)}</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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} 个镜头未通过内容审核,请先修改提示词`);
|
||||
|
||||
@@ -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("该提示词未通过内容审核,请先修改提示词再重新生成");
|
||||
|
||||
@@ -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<string, ReturnType<typeof buildToolMentionReference>[]>();
|
||||
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<string>();
|
||||
|
||||
Reference in New Issue
Block a user