From 16359271f62520e38eb3821ee39f36ee2460f958 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 21 Aug 2026 14:52:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(canvas):=20=E9=A1=B9=E7=9B=AE=E7=94=BB?= =?UTF-8?q?=E9=A3=8E=E6=89=A7=E8=A1=8C=20-=20=E8=87=AA=E5=8A=A8=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=A8=A1=E5=9E=8B=E8=B7=AF=E7=94=B1=E4=B8=8E=E7=94=BB?= =?UTF-8?q?=E9=A3=8E=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/provider.go | 48 ++++++++++++------- backend/internal/service/provider_test.go | 37 ++++++++++++++ web/src/lib/canvas/canvas-style-execution.ts | 5 +- .../detail/project-character-media.ts | 9 ++-- web/src/services/api/generation-task.ts | 9 ++-- web/src/stores/use-config-store.ts | 5 ++ 6 files changed, 84 insertions(+), 29 deletions(-) diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 67737c82e..d261ce68f 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -423,35 +423,49 @@ func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID strin if strings.TrimSpace(storedProfileJSON) == "" { // 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。 if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) { - return errors.New("任务画风预设与项目当前设置不一致,请刷新画布后重试") + return errors.New("项目画风已发生变化,请返回项目列表后重新打开当前项目再生成") } } else { matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON) - if compareErr != nil || !matches { - return errors.New("任务画风快照与项目当前设置不一致,请刷新画布后重试") + if compareErr != nil { + return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") + } + if !matches { + // 项目画风可能在另一个页面更新;保存路径以服务端快照为准,生成时自动采用最新版本。 + validatedStoredProfileJSON, validateErr := validateStyleProfileJSON(storedProfileJSON) + if validateErr != nil || json.Unmarshal([]byte(validatedStoredProfileJSON), &profile) != nil { + return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") + } } } } - plan, err := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"]) - if err != nil { - return err - } + // 执行计划是前端为即时预览生成的派生数据。平台模型入队后可能改选真实供应线路, + // 因此后端必须以最终模型重新编译,不能要求用户手动“刷新配置”来同步内部路由。 + plan, _ := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"]) stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat)) - if plan.SchemaVersion != 1 || plan.ProfilePresetID != profile.PresetID || plan.ProfileRevision != profile.Revision || plan.Mode != input.Mode || !strings.EqualFold(plan.Model, input.Config.Model) || plan.InterfaceType != firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat) { - return errors.New("项目画风执行计划与当前模型或快照不一致,请刷新配置后重试") - } - if plan.Status != expectedStatus || strings.TrimSpace(plan.Prompt) != stylePrompt { - return errors.New("项目画风执行计划已失效,请重新生成执行计划") - } + input.Prompt = reconcileGenerationStylePrompt(input.Prompt, plan.Prompt, stylePrompt) if expectedStatus == "blocked" { - return fmt.Errorf("项目画风与当前生成模型不兼容:%s", strings.Join(warnings, ";")) - } - if stylePrompt != "" && !strings.Contains(input.Prompt, stylePrompt) { - input.Prompt = strings.TrimSpace(input.Prompt) + "\n\n【项目画风执行规范】\n" + stylePrompt + return fmt.Errorf("当前图片模型无法完整执行项目画风:%s。请切换图片模型,或在项目设置中停用对应画风资产", strings.Join(warnings, ";")) } return nil } +func reconcileGenerationStylePrompt(prompt string, previousStylePrompt string, currentStylePrompt string) string { + content := strings.TrimSpace(prompt) + previous := strings.TrimSpace(previousStylePrompt) + if previous != "" { + previousBlock := "【项目画风执行规范】\n" + previous + if strings.HasSuffix(content, previousBlock) { + content = strings.TrimSpace(strings.TrimSuffix(content, previousBlock)) + } + } + current := strings.TrimSpace(currentStylePrompt) + if current == "" || strings.HasSuffix(content, "【项目画风执行规范】\n"+current) { + return content + } + return strings.TrimSpace(content + "\n\n【项目画风执行规范】\n" + current) +} + func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) { id := strings.TrimSpace(canvasOrProjectID) if id == "" { diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index e57f143e0..4a3df4080 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -1794,6 +1794,43 @@ func TestResolveGenerationStyleExecutionSkipsPromptAssetForOtherModel(t *testing } } +func TestApplyGenerationStyleProfileRebuildsStaleClientPlanForResolvedModel(t *testing.T) { + enabled := true + profile := styleProfileDocument{ + SchemaVersion: 1, + PresetID: "style-1", + Title: "项目画风", + Prompt: "base style", + ExecutionPolicy: "compatible-fallback", + Source: "user", + Revision: 1, + Assets: []styleProfileAsset{{ + ID: "template-1", Kind: "template", Title: "旧模型模板", Provider: "workflow", Enabled: &enabled, Status: "validated", + BaseModels: []string{"client-model"}, PromptFragment: "client-only fragment", + }}, + } + clientPrompt, clientStatus, _ := resolveGenerationStyleExecution(profile, "client-model", "openai-image") + input := canvasGenerationInput{ + Mode: "image", + Prompt: "portrait\n\n【项目画风执行规范】\n" + clientPrompt, + Config: providerConfig{Model: "resolved-model", InterfaceType: "openai-image"}, + Metadata: map[string]interface{}{ + "styleProfileJson": mustStyleProfileJSON(profile), + "styleExecutionPlan": styleExecutionPlanDocument{ + SchemaVersion: 1, ProfilePresetID: profile.PresetID, ProfileRevision: profile.Revision, Mode: "image", + Model: "client-model", InterfaceType: "openai-image", Status: clientStatus, Prompt: clientPrompt, + }, + }, + } + + if err := (&Service{}).applyGenerationStyleProfile("user-1", "", &input); err != nil { + t.Fatalf("applyGenerationStyleProfile() error = %v", err) + } + if input.Prompt != "portrait\n\n【项目画风执行规范】\nbase style" { + t.Fatalf("applyGenerationStyleProfile() prompt = %q", input.Prompt) + } +} + func TestEquivalentStyleProfileJSONIgnoresObjectKeyOrder(t *testing.T) { equal, err := equivalentStyleProfileJSON(`{"schemaVersion":1,"presetId":"style-1","assets":[]}`, `{"assets":[],"presetId":"style-1","schemaVersion":1}`) if err != nil || !equal { diff --git a/web/src/lib/canvas/canvas-style-execution.ts b/web/src/lib/canvas/canvas-style-execution.ts index 1aec8f86d..e422eb57f 100644 --- a/web/src/lib/canvas/canvas-style-execution.ts +++ b/web/src/lib/canvas/canvas-style-execution.ts @@ -1,5 +1,5 @@ import { applyStyleExecutionPlan, createStyleProfileSnapshot, parseStyleProfile, resolveStyleExecutionPlan, serializeStyleProfile, type StyleExecutionPlan, type StyleProfileSnapshot } from "@/lib/canvas/style-profile"; -import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; +import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import type { CanvasNodeData } from "@/types/canvas"; export type CanvasStyleExecutionRuntime = { @@ -16,7 +16,8 @@ export function resolveCanvasStyleExecution(nodes: CanvasNodeData[], sourceNode: if (!profile) return null; const requestConfig = resolveModelRequestConfig(config, config.model); const plan = resolveStyleExecutionPlan(profile, { mode, model: requestConfig.model, interfaceType: requestConfig.interfaceType || requestConfig.apiFormat }); - if (plan.status === "blocked") throw new Error(`项目画风与当前模型不兼容:${plan.warnings.join(";")}`); + // 平台模型的真实供应模型由后端入队时路由,前端只能预览,最终兼容性由后端重算。 + if (plan.status === "blocked" && !logicalModelIDForConfig(config)) throw new Error(`当前模型无法完整执行项目画风:${plan.warnings.join(";")}。请切换模型,或在项目设置中停用对应画风资产`); return { profile, profileJson: serializeStyleProfile(profile), plan, prompt: applyStyleExecutionPlan(prompt, plan) }; } diff --git a/web/src/pages/projects/detail/project-character-media.ts b/web/src/pages/projects/detail/project-character-media.ts index aea1f9e4d..cd1638153 100644 --- a/web/src/pages/projects/detail/project-character-media.ts +++ b/web/src/pages/projects/detail/project-character-media.ts @@ -1,20 +1,21 @@ import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation"; import { resolveStyleExecutionPlan, serializeStyleProfile, type StyleProfileSnapshot } from "@/lib/canvas/style-profile"; -import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; +import { logicalModelIDForConfig, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; type ProjectStylePrompt = { id: string; title: string; prompt: string; profile?: StyleProfileSnapshot }; export async function generateCharacterTurnaround(input: { projectId: string; assetId: string; versionId: string; name: string; definition: Record; projectStyle?: ProjectStylePrompt; config: AiConfig }) { const promptTemplateVariables = characterTurnaroundVariables(input.name, input.definition, input.projectStyle); - const requestConfig = resolveModelRequestConfig(input.config, input.config.imageModel || input.config.model); + const generationConfig = { ...input.config, model: input.config.imageModel || input.config.model, count: "1" }; + const requestConfig = resolveModelRequestConfig(generationConfig, generationConfig.model); const styleExecutionPlan = input.projectStyle?.profile ? resolveStyleExecutionPlan(input.projectStyle.profile, { mode: "image", model: requestConfig.model, interfaceType: requestConfig.interfaceType || requestConfig.apiFormat }) : undefined; - if (styleExecutionPlan?.status === "blocked") throw new Error(`项目画风与当前图片模型不兼容:${styleExecutionPlan.warnings.join(";")}`); + if (styleExecutionPlan?.status === "blocked" && !logicalModelIDForConfig(generationConfig)) throw new Error(`当前图片模型无法完整执行项目画风:${styleExecutionPlan.warnings.join(";")}。请切换图片模型,或在项目设置中停用对应画风资产`); await runBackendCanvasGenerationTask({ projectId: input.projectId, nodeId: `character-turnaround:${input.assetId}`, mode: "image", prompt: "使用当前启用的角色三视图模板。", - config: { ...input.config, model: input.config.imageModel || input.config.model, count: "1" }, + config: generationConfig, metadata: { operation: "character_turnaround", promptTemplateOperation: "character_turnaround", promptTemplateVariables, characterAssetId: input.assetId, stylePresetId: input.projectStyle?.id, styleProfileJson: input.projectStyle?.profile ? serializeStyleProfile(input.projectStyle.profile) : undefined, styleExecutionPlan, resolvedCharacterVersions: [{ assetId: input.assetId, versionId: input.versionId }] }, }); } diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index ee301818c..a7f4b5269 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -7,12 +7,14 @@ import { isLocalDreaminaBackgroundTask, localDreaminaTaskId, projectLocalDreamin import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; import { grokImagePromptLimitError } from "@/lib/grok-image-prompt-limit"; import { resolveVideoOperation } from "@/lib/model-selection"; -import { modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; +import { logicalModelIDForConfig, modelOptionName, resolveModelChannel, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store"; import { useLocalDreaminaModelStore } from "@/stores/use-local-dreamina-model-store"; import type { ReferenceImage } from "@/types/image"; import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; import { buildBackendToolRequests, type ResponseFunctionTool, type ResponseInputMessage, type ToolChoice, type ToolResponseResult } from "@/services/api/image"; +export { logicalModelIDForConfig }; + export type BackendGenerationMode = "text" | "image" | "video" | "audio"; export type BackendGenerationResult = { @@ -500,11 +502,6 @@ export function backendProviderConfig(config: AiConfig) { }; } -export function logicalModelIDForConfig(config: AiConfig) { - const channel = resolveModelChannel(config, config.model); - return channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalModelId || ""; -} - function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode) { const channel = resolveModelChannel(config, config.model); const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec; diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 9b098df56..ba5e5254f 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -494,6 +494,11 @@ export function resolveModelChannel(config: AiConfig, value: string) { return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) }); } +export function logicalModelIDForConfig(config: AiConfig) { + const channel = resolveModelChannel(config, config.model); + return channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalModelId || ""; +} + export function channelConnectionSignature(channel: ModelChannel) { return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", channel.allowLocalChannel === true ? "local:1" : "local:0", JSON.stringify(channel.headers || [])].join("\n"); }