fix(canvas): 项目画风执行 - 自动同步模型路由与画风计划
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }] },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user