fix(canvas): enforce configured local edit model

This commit is contained in:
134 2026-08-10 21:46:04 +08:00
parent e7182e7faa
commit 667bba8f53
5 changed files with 175 additions and 18 deletions

View File

@ -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({
<LocalAiSectionShell
description={
<>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 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
)}
>
<SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
@ -1474,7 +1495,7 @@ export function LocalAiImagePipelineSections({
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
<SelectItem key={model.id} value={model.id}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))
@ -1499,7 +1520,9 @@ export function LocalAiImagePipelineSections({
scaleAdjustModelId: value === "__none__" ? "" : value,
}))
}
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
value={resolveCanvasLocalEditSelectValue(
canvasLocalEditConfig.scaleAdjustModelId
)}
>
<SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
@ -1508,7 +1531,7 @@ export function LocalAiImagePipelineSections({
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
<SelectItem key={model.id} value={model.id}>
{formatAiWashModelOptionLabel(model)}
</SelectItem>
))

View File

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

View File

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

View File

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

View File

@ -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(
'<SelectItem key={model.id} value={model.id}>'
);
});
test("accepts target movement even when target size stays unchanged", () => {
const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 };