diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index 8d295476c..89de319b2 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -41,6 +41,7 @@ import { type EnhanceProKreaModel, type EnhanceProPreset, } from "@/lib/enhance-pro/config"; +import { resolveCanvasLocalEditModelRef } from "@/lib/canvas/local-edit-config"; import { cn } from "@/lib/utils"; import type { EnhanceProKeyRow, @@ -114,8 +115,26 @@ export function LocalAiImagePipelineSections({ const availableImageModels = (modelsData?.models || []).filter( (model) => model.supportsImageGen && model.isEnabled ); - const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) => - `${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`; + const duplicateRuntimeModelIds = new Set( + availableImageModels + .filter( + (model, index, models) => + models.findIndex((candidate) => candidate.modelId === model.modelId) !== + index + ) + .map((model) => model.modelId) + ); + const formatAiWashModelOptionLabel = ( + model: (typeof availableImageModels)[number] + ) => + `${model.displayName || model.modelId}${ + duplicateRuntimeModelIds.has(model.modelId) + ? ` · 供应商 ${model.providerId.slice(0, 8)}` + : "" + }${model.isHidden ? " · 前台隐藏" : ""}`; + const resolveCanvasLocalEditSelectValue = (modelRef: string) => + resolveCanvasLocalEditModelRef(availableImageModels, modelRef)?.id || + "__none__"; const getAiWashControlToneCopy = (controlId: AiWashControlId) => { if (controlId === "creativity") { return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; @@ -1427,7 +1446,7 @@ export function LocalAiImagePipelineSections({ - 配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。 + 配置无限画布图片“局部编辑”和“比例调整”使用的生图模型。指定后由后台强制使用该供应商的精确模型,不再被用户前端当前选择覆盖;为空时才沿用旧默认或用户当前选择。 } expanded={isLocalAiSectionExpanded("canvas-local-edit")} @@ -1465,7 +1484,9 @@ export function LocalAiImagePipelineSections({ modelId: value === "__none__" ? "" : value, })) } - value={canvasLocalEditConfig.modelId || "__none__"} + value={resolveCanvasLocalEditSelectValue( + canvasLocalEditConfig.modelId + )} > @@ -1474,7 +1495,7 @@ export function LocalAiImagePipelineSections({ 不指定,沿用当前模型/系统默认 {availableImageModels.length > 0 ? ( availableImageModels.map((model) => ( - + {formatAiWashModelOptionLabel(model)} )) @@ -1499,7 +1520,9 @@ export function LocalAiImagePipelineSections({ scaleAdjustModelId: value === "__none__" ? "" : value, })) } - value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"} + value={resolveCanvasLocalEditSelectValue( + canvasLocalEditConfig.scaleAdjustModelId + )} > @@ -1508,7 +1531,7 @@ export function LocalAiImagePipelineSections({ 不指定,兼容旧 AI详情默认模型 {availableImageModels.length > 0 ? ( availableImageModels.map((model) => ( - + {formatAiWashModelOptionLabel(model)} )) diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index a03af8d26..0031b5f0e 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -56,6 +56,7 @@ import { import { CANVAS_LOCAL_EDIT_CONFIG_KEY, normalizeCanvasLocalEditConfig, + resolveCanvasLocalEditModelRef, } from "@/lib/canvas/local-edit-config"; import { DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, @@ -69,6 +70,7 @@ import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig, } from "@/lib/style-pro/config"; +import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options"; import { EXTERNAL_IMAGE_API_CONFIG_KEY, normalizeExternalImageApiConfig, @@ -380,7 +382,36 @@ export async function POST(request: Request) { } if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) { - nextValue = normalizeCanvasLocalEditConfig(value); + const normalizedValue = normalizeCanvasLocalEditConfig(value); + const imageModels = ( + await getStyleProImageModelEntries({ includeHidden: true }) + ).map(({ model }) => ({ id: model.id, modelId: model.modelId })); + const localEditModel = resolveCanvasLocalEditModelRef( + imageModels, + normalizedValue.modelId + ); + const scaleAdjustModel = resolveCanvasLocalEditModelRef( + imageModels, + normalizedValue.scaleAdjustModelId + ); + + if (normalizedValue.modelId && !localEditModel) { + return new ChatSDKError( + "bad_request:api", + "普通局部编辑模型不存在、已停用或不支持生图" + ).toResponse(); + } + if (normalizedValue.scaleAdjustModelId && !scaleAdjustModel) { + return new ChatSDKError( + "bad_request:api", + "比例调整模型不存在、已停用或不支持生图" + ).toResponse(); + } + + nextValue = { + modelId: localEditModel?.id || "", + scaleAdjustModelId: scaleAdjustModel?.id || "", + }; } if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) { diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 48ecd8b3c..e90d59404 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -27,10 +27,12 @@ import { import { CANVAS_LOCAL_EDIT_CONFIG_KEY, normalizeCanvasLocalEditConfig, + resolveCanvasLocalEditModelRef, } from "@/lib/canvas/local-edit-config"; import { getImageToolCreditCost } from "@/lib/points/config"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { uploadFile } from "@/lib/storage/service"; +import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options"; export const runtime = "nodejs"; export const maxDuration = 120; @@ -624,10 +626,16 @@ export async function POST(request: NextRequest) { ); } - const [pointsConfig, siteSettingsForModel, localEditConfigSetting] = await Promise.all([ + const [ + pointsConfig, + siteSettingsForModel, + localEditConfigSetting, + availableImageModelEntries, + ] = await Promise.all([ getPointsSystemConfig(), getSiteSettings().catch(() => null), getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null), + getStyleProImageModelEntries({ includeHidden: true }), ]); const localEditConfig = normalizeCanvasLocalEditConfig( localEditConfigSetting?.value @@ -636,9 +644,41 @@ export async function POST(request: NextRequest) { typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string" ? siteSettingsForModel.canvasDetailDefaultModelId.trim() : ""; - const configuredLocalEditModelId = localEditConfig.modelId; - const configuredScaleAdjustModelId = - localEditConfig.scaleAdjustModelId || + const modelIdentities = availableImageModelEntries.map(({ model }) => ({ + id: model.id, + modelId: model.modelId, + })); + const configuredLocalEditModel = resolveCanvasLocalEditModelRef( + modelIdentities, + localEditConfig.modelId + ); + const configuredScaleAdjustModel = resolveCanvasLocalEditModelRef( + modelIdentities, + localEditConfig.scaleAdjustModelId + ); + if (localEditConfig.modelId && !configuredLocalEditModel) { + return NextResponse.json( + { + success: false, + error: + "后台配置的普通局部编辑模型不存在、已停用或不支持生图,请在后台重新选择", + }, + { status: 500 } + ); + } + if (localEditConfig.scaleAdjustModelId && !configuredScaleAdjustModel) { + return NextResponse.json( + { + success: false, + error: + "后台配置的比例调整模型不存在、已停用或不支持生图,请在后台重新选择", + }, + { status: 500 } + ); + } + const configuredLocalEditModelRef = configuredLocalEditModel?.id || ""; + const configuredScaleAdjustModelRef = + configuredScaleAdjustModel?.id || siteDetailDefaultModelId || SCALE_ADJUST_REPAIR_MODEL_REF; const pointsCharge = await prepareFeaturePointsCharge({ @@ -809,7 +849,8 @@ export async function POST(request: NextRequest) { operation, requestedModel: requestedModel || null, resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio, - resolvedModel: configuredScaleAdjustModelId, + configuredModelRef: localEditConfig.scaleAdjustModelId || null, + resolvedModel: configuredScaleAdjustModelRef, scaleAdjustProductLockMaskEditApplied: true, scaleAdjustPersonExclusionApplied: productLockAssets.personExclusionApplied, @@ -836,7 +877,7 @@ export async function POST(request: NextRequest) { displayPrompt, imageCount: 1, imageSize: requestedImageSize, - model: configuredScaleAdjustModelId, + model: configuredScaleAdjustModelRef, negativePrompt: "框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影", prompt, @@ -869,7 +910,7 @@ export async function POST(request: NextRequest) { sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "", target: region.targetRect, })), - relightModel: configuredScaleAdjustModelId, + relightModel: configuredScaleAdjustModelRef, sourceImageUrl, }, } @@ -898,7 +939,7 @@ export async function POST(request: NextRequest) { ...(hasPositionMove ? { scaleAdjustModelRelight: { - model: configuredScaleAdjustModelId, + model: configuredScaleAdjustModelRef, overlayImageUrl: overlayUpload.url, quality: "medium" as const, regions: scaleAdjustIntentRegions.map((region) => ({ @@ -958,6 +999,8 @@ export async function POST(request: NextRequest) { typeof body.model === "string" && body.model.trim() ? body.model.trim() : undefined; + const resolvedLocalEditModelRef = + configuredLocalEditModelRef || requestedModel; const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) .composite([ { @@ -997,7 +1040,8 @@ export async function POST(request: NextRequest) { displayPrompt, operation, requestedModel: requestedModel || null, - resolvedModel: requestedModel || configuredLocalEditModelId || null, + configuredModelRef: localEditConfig.modelId || null, + resolvedModel: resolvedLocalEditModelRef || null, resolvedAspectRatio, manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length, regionReferenceImageCount: referenceImages.length, @@ -1009,7 +1053,7 @@ export async function POST(request: NextRequest) { displayPrompt, imageCount: 1, imageSize: requestedImageSize, - model: requestedModel || configuredLocalEditModelId || undefined, + model: resolvedLocalEditModelRef, negativePrompt: "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", prompt, diff --git a/lib/canvas/local-edit-config.ts b/lib/canvas/local-edit-config.ts index bcdf9cb27..c40d932f5 100644 --- a/lib/canvas/local-edit-config.ts +++ b/lib/canvas/local-edit-config.ts @@ -5,6 +5,11 @@ export type CanvasLocalEditConfig = { scaleAdjustModelId: string; }; +export type CanvasLocalEditModelIdentity = { + id: string; + modelId: string; +}; + export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig { return { modelId: "", @@ -16,6 +21,22 @@ function normalizeModelRef(value: unknown) { return String(value || "").trim().slice(0, 128); } +export function resolveCanvasLocalEditModelRef< + T extends CanvasLocalEditModelIdentity, +>(models: T[], value: unknown): T | null { + const modelRef = normalizeModelRef(value); + if (!modelRef) return null; + + // New settings persist the database UUID so identically named runtime + // models from different providers cannot be confused. Keep modelId lookup + // as a compatibility path for settings saved before UUID persistence. + return ( + models.find((model) => model.id === modelRef) || + models.find((model) => model.modelId === modelRef) || + null + ); +} + export function normalizeCanvasLocalEditConfig( value: unknown ): CanvasLocalEditConfig { diff --git a/tests/routes/canvas-scale-adjust-intent.test.ts b/tests/routes/canvas-scale-adjust-intent.test.ts index a56373de7..20d531b83 100644 --- a/tests/routes/canvas-scale-adjust-intent.test.ts +++ b/tests/routes/canvas-scale-adjust-intent.test.ts @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { hasScaleAdjustTargetChanged } from "../../components/canvas/nodes/image-node/image-node-local-edit"; import { hasScaleAdjustPositionMoved } from "../../lib/canvas/scale-adjust-geometry"; +import { resolveCanvasLocalEditModelRef } from "../../lib/canvas/local-edit-config"; import { buildSemanticScaleAdjustCleanupPrompt, buildSemanticScaleAdjustCompositeRegions, @@ -16,6 +17,43 @@ function readSource(path: string) { } test.describe("canvas scale adjust product lock", () => { + test("resolves local-edit models by exact UUID before legacy runtime IDs", () => { + const models = [ + { id: "provider-a-model", modelId: "banana-pro" }, + { id: "provider-b-model", modelId: "banana-pro" }, + ]; + + expect(resolveCanvasLocalEditModelRef(models, "provider-b-model")?.id).toBe( + "provider-b-model" + ); + expect(resolveCanvasLocalEditModelRef(models, "banana-pro")?.id).toBe( + "provider-a-model" + ); + expect(resolveCanvasLocalEditModelRef(models, "missing-model")).toBeNull(); + }); + + test("forces the configured local-edit provider over the frontend selection", () => { + const route = readSource("app/api/canvas/local-edit/route.ts"); + const settingsRoute = readSource("app/api/admin/settings/route.ts"); + const settingsPanel = readSource( + "app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx" + ); + + expect(route).toContain( + "configuredLocalEditModelRef || requestedModel" + ); + expect(route).not.toContain( + "requestedModel || configuredLocalEditModelId" + ); + expect(route).toContain("configuredModelRef: localEditConfig.modelId"); + expect(settingsRoute).toContain("modelId: localEditModel?.id || \"\""); + expect(settingsPanel).toContain("resolveCanvasLocalEditSelectValue("); + expect(settingsPanel).toContain("canvasLocalEditConfig.modelId"); + expect(settingsPanel).toContain( + '' + ); + }); + test("accepts target movement even when target size stays unchanged", () => { const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 };