fix(canvas): 项目画风执行 - 自动同步模型路由与画风计划

This commit is contained in:
ddCat
2026-08-21 14:52:50 +08:00
parent 81ef033f58
commit 16359271f6
6 changed files with 84 additions and 29 deletions

View File

@@ -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 == "" {

View File

@@ -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 {

View File

@@ -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) };
}

View File

@@ -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<string, unknown>; 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 }] },
});
}

View File

@@ -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;

View File

@@ -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");
}