Compare commits

...

9 Commits

27 changed files with 1142 additions and 193 deletions

View File

@ -41,6 +41,7 @@ import {
type EnhanceProKreaModel, type EnhanceProKreaModel,
type EnhanceProPreset, type EnhanceProPreset,
} from "@/lib/enhance-pro/config"; } from "@/lib/enhance-pro/config";
import { resolveCanvasLocalEditModelRef } from "@/lib/canvas/local-edit-config";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { import type {
EnhanceProKeyRow, EnhanceProKeyRow,
@ -54,6 +55,7 @@ import {
export function LocalAiImagePipelineSections({ export function LocalAiImagePipelineSections({
modelsData, modelsData,
providersData,
rmbgConfig, rmbgConfig,
setRmbgConfig, setRmbgConfig,
enhanceConfig, enhanceConfig,
@ -114,8 +116,21 @@ export function LocalAiImagePipelineSections({
const availableImageModels = (modelsData?.models || []).filter( const availableImageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen && model.isEnabled (model) => model.supportsImageGen && model.isEnabled
); );
const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) => const providerNamesById = new Map(
`${model.displayName || model.modelId}${model.isHidden ? " · 前台隐藏" : ""}`; (providersData?.providers || []).map((provider) => [
provider.id,
provider.name,
])
);
const formatAiWashModelOptionLabel = (
model: (typeof availableImageModels)[number]
) =>
`${model.displayName || model.modelId} · ${
providerNamesById.get(model.providerId) || model.modelId
}${model.isHidden ? " · 前台隐藏" : ""}`;
const resolveCanvasLocalEditSelectValue = (modelRef: string) =>
resolveCanvasLocalEditModelRef(availableImageModels, modelRef)?.id ||
"__none__";
const getAiWashControlToneCopy = (controlId: AiWashControlId) => { const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") { if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。"; return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
@ -1427,7 +1442,7 @@ export function LocalAiImagePipelineSections({
<LocalAiSectionShell <LocalAiSectionShell
description={ description={
<> <>
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。 配置无限画布图片“局部编辑”和“比例调整”使用的生图模型。指定后由后台强制使用该供应商的精确模型,不再被用户前端当前选择覆盖;为空时才沿用旧默认或用户当前选择。
</> </>
} }
expanded={isLocalAiSectionExpanded("canvas-local-edit")} expanded={isLocalAiSectionExpanded("canvas-local-edit")}
@ -1465,7 +1480,9 @@ export function LocalAiImagePipelineSections({
modelId: value === "__none__" ? "" : value, modelId: value === "__none__" ? "" : value,
})) }))
} }
value={canvasLocalEditConfig.modelId || "__none__"} value={resolveCanvasLocalEditSelectValue(
canvasLocalEditConfig.modelId
)}
> >
<SelectTrigger id="canvasLocalEditModel"> <SelectTrigger id="canvasLocalEditModel">
<SelectValue placeholder="不指定时使用当前模型或系统默认" /> <SelectValue placeholder="不指定时使用当前模型或系统默认" />
@ -1474,7 +1491,7 @@ export function LocalAiImagePipelineSections({
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem> <SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
{availableImageModels.length > 0 ? ( {availableImageModels.length > 0 ? (
availableImageModels.map((model) => ( availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}> <SelectItem key={model.id} value={model.id}>
{formatAiWashModelOptionLabel(model)} {formatAiWashModelOptionLabel(model)}
</SelectItem> </SelectItem>
)) ))
@ -1499,7 +1516,9 @@ export function LocalAiImagePipelineSections({
scaleAdjustModelId: value === "__none__" ? "" : value, scaleAdjustModelId: value === "__none__" ? "" : value,
})) }))
} }
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"} value={resolveCanvasLocalEditSelectValue(
canvasLocalEditConfig.scaleAdjustModelId
)}
> >
<SelectTrigger id="canvasScaleAdjustModel"> <SelectTrigger id="canvasScaleAdjustModel">
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" /> <SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
@ -1508,7 +1527,7 @@ export function LocalAiImagePipelineSections({
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem> <SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
{availableImageModels.length > 0 ? ( {availableImageModels.length > 0 ? (
availableImageModels.map((model) => ( availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}> <SelectItem key={model.id} value={model.id}>
{formatAiWashModelOptionLabel(model)} {formatAiWashModelOptionLabel(model)}
</SelectItem> </SelectItem>
)) ))

View File

@ -307,6 +307,7 @@ export type LayerSplitConfig = {
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & { export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
modelsData?: AdminModelsData; modelsData?: AdminModelsData;
providersData?: AdminProvidersData;
rmbgConfig: RmbgConfig; rmbgConfig: RmbgConfig;
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>; setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
enhanceConfig: EnhanceConfig; enhanceConfig: EnhanceConfig;

View File

@ -18449,6 +18449,7 @@ export function AdminSettingsClient() {
layerSplitConfig={layerSplitConfig} layerSplitConfig={layerSplitConfig}
modelsData={modelsData} modelsData={modelsData}
pointsSystemConfig={pointsSystemConfig} pointsSystemConfig={pointsSystemConfig}
providersData={providersData}
renderEnhanceProPresetEditor={renderEnhanceProPresetEditor} renderEnhanceProPresetEditor={renderEnhanceProPresetEditor}
rmbgConfig={rmbgConfig} rmbgConfig={rmbgConfig}
savedEnhanceProKeyRows={savedEnhanceProKeyRows} savedEnhanceProKeyRows={savedEnhanceProKeyRows}

View File

@ -9,6 +9,7 @@ import {
getUserById, getUserById,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability"; import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
import { canAccessHiddenAiModels } from "@/lib/ai/model-visibility";
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability"; import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
@ -146,8 +147,11 @@ export async function GET(request: Request) {
} }
const currentUser = await getUserById({ id: session.user.id }); const currentUser = await getUserById({ id: session.user.id });
const allowHiddenModels = const allowHiddenModels = canAccessHiddenAiModels({
session.user.type === "admin" || Boolean(currentUser?.isHighVip); sessionUserType: session.user.type,
databaseUserRole: currentUser?.role,
isHighVip: currentUser?.isHighVip,
});
const includeUnavailable = const includeUnavailable =
new URL(request.url).searchParams.get("includeUnavailable") === "1"; new URL(request.url).searchParams.get("includeUnavailable") === "1";

View File

@ -56,6 +56,7 @@ import {
import { import {
CANVAS_LOCAL_EDIT_CONFIG_KEY, CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig, normalizeCanvasLocalEditConfig,
resolveCanvasLocalEditModelRef,
} from "@/lib/canvas/local-edit-config"; } from "@/lib/canvas/local-edit-config";
import { import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY, DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
@ -69,6 +70,7 @@ import {
STYLE_PRO_CONFIG_KEY, STYLE_PRO_CONFIG_KEY,
normalizeStyleProConfig, normalizeStyleProConfig,
} from "@/lib/style-pro/config"; } from "@/lib/style-pro/config";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import { import {
EXTERNAL_IMAGE_API_CONFIG_KEY, EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig, normalizeExternalImageApiConfig,
@ -380,7 +382,36 @@ export async function POST(request: Request) {
} }
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) { 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) { if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {

View File

@ -14,6 +14,11 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
"worldlabs.ai", "worldlabs.ai",
"s3.1080p.hk", "s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"yybb.top",
"qiniucs.com",
"qiniudn.com",
"clouddn.com",
"qbox.me",
"blob.vercel-storage.com", "blob.vercel-storage.com",
"aliyuncs.com", "aliyuncs.com",
"amazonaws.com", "amazonaws.com",

View File

@ -28,6 +28,11 @@ type RemoteAssetArchiveCacheItem = {
archivedUrl: string; archivedUrl: string;
}; };
type RemoteAssetArchiveResult = {
url: string | null;
pending: boolean;
};
function isLikelyArchivedStorageUrl(parsed: URL) { function isLikelyArchivedStorageUrl(parsed: URL) {
const hostname = parsed.hostname.toLowerCase(); const hostname = parsed.hostname.toLowerCase();
return hostname.includes("blob.vercel-storage.com"); return hostname.includes("blob.vercel-storage.com");
@ -120,23 +125,22 @@ async function archiveTripoModelIfNeeded(params: {
sourceUrl?: string | null; sourceUrl?: string | null;
filenamePrefix: string; filenamePrefix: string;
fallbackExtension: string; fallbackExtension: string;
}) { }): Promise<RemoteAssetArchiveResult> {
const sourceUrl = String(params.sourceUrl || "").trim(); const sourceUrl = String(params.sourceUrl || "").trim();
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) { if (!shouldArchiveRemoteAssetUrl(sourceUrl)) {
return sourceUrl || null; return { url: sourceUrl || null, pending: false };
} }
const cache = getTripoArchiveCache(); const cache = getTripoArchiveCache();
const cached = cache.get(params.cacheKey); const cached = cache.get(params.cacheKey);
if (cached && cached.sourceUrl === sourceUrl) { if (cached && cached.sourceUrl === sourceUrl) {
return cached.archivedUrl; return { url: cached.archivedUrl, pending: false };
} }
const inflight = getTripoArchiveInflight(); const inflight = getTripoArchiveInflight();
const existingTask = inflight.get(params.cacheKey); const existingTask = inflight.get(params.cacheKey);
if (existingTask) { if (existingTask) {
const result = await existingTask; return { url: sourceUrl, pending: true };
return result || sourceUrl;
} }
const archiveTask = (async () => { const archiveTask = (async () => {
@ -190,15 +194,19 @@ async function archiveTripoModelIfNeeded(params: {
sourceUrl, sourceUrl,
error, error,
}); });
return null; cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: sourceUrl,
});
return sourceUrl;
} finally { } finally {
inflight.delete(params.cacheKey); inflight.delete(params.cacheKey);
} }
})(); })();
inflight.set(params.cacheKey, archiveTask); inflight.set(params.cacheKey, archiveTask);
const archivedUrl = await archiveTask; void archiveTask;
return archivedUrl || sourceUrl; return { url: sourceUrl, pending: true };
} }
function replaceArchivedUrls(value: unknown, replacements: Map<string, string>): unknown { function replaceArchivedUrls(value: unknown, replacements: Map<string, string>): unknown {
@ -346,20 +354,23 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0; : 0;
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>); const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>); const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
const stableModelUrl = await archiveTripoModelIfNeeded({ const modelArchive = await archiveTripoModelIfNeeded({
taskId: normalizedTaskId, taskId: normalizedTaskId,
cacheKey: `tripo:${normalizedTaskId}:model`, cacheKey: `tripo:${normalizedTaskId}:model`,
sourceUrl: modelUrl, sourceUrl: modelUrl,
filenamePrefix: `tripo-model-${normalizedTaskId}`, filenamePrefix: `tripo-model-${normalizedTaskId}`,
fallbackExtension: "glb", fallbackExtension: "glb",
}); });
const stablePreviewImageUrl = await archiveTripoModelIfNeeded({ const previewArchive = await archiveTripoModelIfNeeded({
taskId: normalizedTaskId, taskId: normalizedTaskId,
cacheKey: `tripo:${normalizedTaskId}:preview`, cacheKey: `tripo:${normalizedTaskId}:preview`,
sourceUrl: previewImageUrl, sourceUrl: previewImageUrl,
filenamePrefix: `tripo-preview-${normalizedTaskId}`, filenamePrefix: `tripo-preview-${normalizedTaskId}`,
fallbackExtension: "jpg", fallbackExtension: "jpg",
}); });
const stableModelUrl = modelArchive.url;
const stablePreviewImageUrl = previewArchive.url;
const assetsPending = modelArchive.pending || previewArchive.pending;
const replacements = new Map<string, string>(); const replacements = new Map<string, string>();
if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) { if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) {
replacements.set(modelUrl, stableModelUrl); replacements.set(modelUrl, stableModelUrl);
@ -376,15 +387,23 @@ export async function GET(request: Request, { params }: RouteParams) {
? replaceArchivedUrls(output, replacements) ? replaceArchivedUrls(output, replacements)
: output; : output;
const storageArchivedAt = const storageArchivedAt =
replacements.size > 0 ? new Date().toISOString() : null; replacements.size > 0 && !assetsPending ? new Date().toISOString() : null;
const responseStatus =
String(taskStatus).trim().toLowerCase() === "success" && assetsPending
? "running"
: result.data.status;
const responseProgress =
String(taskStatus).trim().toLowerCase() === "success" && assetsPending
? 99
: result.data.progress ?? null;
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
taskId: result.data.task_id, taskId: result.data.task_id,
type: result.data.type, type: result.data.type,
status: result.data.status, status: responseStatus,
progress: result.data.progress ?? null, progress: responseProgress,
output: archivedOutput, output: archivedOutput,
modelUrl: stableModelUrl, modelUrl: stableModelUrl,
previewImageUrl: stablePreviewImageUrl, previewImageUrl: stablePreviewImageUrl,
@ -392,6 +411,7 @@ export async function GET(request: Request, { params }: RouteParams) {
providerId: config.providerId || null, providerId: config.providerId || null,
providerName: config.providerName || null, providerName: config.providerName || null,
storageArchivedAt, storageArchivedAt,
assetsPending,
sourceModelUrl: sourceModelUrl:
modelUrl && stableModelUrl && modelUrl !== stableModelUrl modelUrl && stableModelUrl && modelUrl !== stableModelUrl
? modelUrl ? modelUrl

View File

@ -27,10 +27,12 @@ import {
import { import {
CANVAS_LOCAL_EDIT_CONFIG_KEY, CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig, normalizeCanvasLocalEditConfig,
resolveCanvasLocalEditModelRef,
} from "@/lib/canvas/local-edit-config"; } from "@/lib/canvas/local-edit-config";
import { getImageToolCreditCost } from "@/lib/points/config"; import { getImageToolCreditCost } from "@/lib/points/config";
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
import { uploadFile } from "@/lib/storage/service"; import { uploadFile } from "@/lib/storage/service";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const maxDuration = 120; 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(), getPointsSystemConfig(),
getSiteSettings().catch(() => null), getSiteSettings().catch(() => null),
getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null), getSystemSetting({ key: CANVAS_LOCAL_EDIT_CONFIG_KEY }).catch(() => null),
getStyleProImageModelEntries({ includeHidden: true }),
]); ]);
const localEditConfig = normalizeCanvasLocalEditConfig( const localEditConfig = normalizeCanvasLocalEditConfig(
localEditConfigSetting?.value localEditConfigSetting?.value
@ -636,9 +644,41 @@ export async function POST(request: NextRequest) {
typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string" typeof siteSettingsForModel?.canvasDetailDefaultModelId === "string"
? siteSettingsForModel.canvasDetailDefaultModelId.trim() ? siteSettingsForModel.canvasDetailDefaultModelId.trim()
: ""; : "";
const configuredLocalEditModelId = localEditConfig.modelId; const modelIdentities = availableImageModelEntries.map(({ model }) => ({
const configuredScaleAdjustModelId = id: model.id,
localEditConfig.scaleAdjustModelId || 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 || siteDetailDefaultModelId ||
SCALE_ADJUST_REPAIR_MODEL_REF; SCALE_ADJUST_REPAIR_MODEL_REF;
const pointsCharge = await prepareFeaturePointsCharge({ const pointsCharge = await prepareFeaturePointsCharge({
@ -809,7 +849,8 @@ export async function POST(request: NextRequest) {
operation, operation,
requestedModel: requestedModel || null, requestedModel: requestedModel || null,
resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio, resolvedAspectRatio: hasPositionMove ? "auto" : resolvedAspectRatio,
resolvedModel: configuredScaleAdjustModelId, configuredModelRef: localEditConfig.scaleAdjustModelId || null,
resolvedModel: configuredScaleAdjustModelRef,
scaleAdjustProductLockMaskEditApplied: true, scaleAdjustProductLockMaskEditApplied: true,
scaleAdjustPersonExclusionApplied: scaleAdjustPersonExclusionApplied:
productLockAssets.personExclusionApplied, productLockAssets.personExclusionApplied,
@ -836,7 +877,7 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
imageCount: 1, imageCount: 1,
imageSize: requestedImageSize, imageSize: requestedImageSize,
model: configuredScaleAdjustModelId, model: configuredScaleAdjustModelRef,
negativePrompt: negativePrompt:
"框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影", "框线、箭头、坐标、标注、文字、UI、缩放整张图、裁剪整张图、拉伸主体、复制主体、替换主体、旧主体残影",
prompt, prompt,
@ -869,7 +910,7 @@ export async function POST(request: NextRequest) {
sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "", sourceCropImageUrl: semanticSourceCropUploads[index]?.url || "",
target: region.targetRect, target: region.targetRect,
})), })),
relightModel: configuredScaleAdjustModelId, relightModel: configuredScaleAdjustModelRef,
sourceImageUrl, sourceImageUrl,
}, },
} }
@ -898,7 +939,7 @@ export async function POST(request: NextRequest) {
...(hasPositionMove ...(hasPositionMove
? { ? {
scaleAdjustModelRelight: { scaleAdjustModelRelight: {
model: configuredScaleAdjustModelId, model: configuredScaleAdjustModelRef,
overlayImageUrl: overlayUpload.url, overlayImageUrl: overlayUpload.url,
quality: "medium" as const, quality: "medium" as const,
regions: scaleAdjustIntentRegions.map((region) => ({ regions: scaleAdjustIntentRegions.map((region) => ({
@ -958,6 +999,8 @@ export async function POST(request: NextRequest) {
typeof body.model === "string" && body.model.trim() typeof body.model === "string" && body.model.trim()
? body.model.trim() ? body.model.trim()
: undefined; : undefined;
const resolvedLocalEditModelRef =
configuredLocalEditModelRef || requestedModel;
const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" }) const annotatedBuffer = await sharp(sourceBuffer, { failOn: "none" })
.composite([ .composite([
{ {
@ -997,7 +1040,8 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
operation, operation,
requestedModel: requestedModel || null, requestedModel: requestedModel || null,
resolvedModel: requestedModel || configuredLocalEditModelId || null, configuredModelRef: localEditConfig.modelId || null,
resolvedModel: resolvedLocalEditModelRef || null,
resolvedAspectRatio, resolvedAspectRatio,
manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length, manualRegionReferenceImageCount: manualRegionReferenceImageUrls.length,
regionReferenceImageCount: referenceImages.length, regionReferenceImageCount: referenceImages.length,
@ -1009,7 +1053,7 @@ export async function POST(request: NextRequest) {
displayPrompt, displayPrompt,
imageCount: 1, imageCount: 1,
imageSize: requestedImageSize, imageSize: requestedImageSize,
model: requestedModel || configuredLocalEditModelId || undefined, model: resolvedLocalEditModelRef,
negativePrompt: negativePrompt:
"彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位", "彩色框、蓝色边框、绿色边框、红色边框、橙色边框、矩形框、编号圆点、提示词气泡、标注文字、UI 控件、半透明色块、选区边框、白边、画幅压扁、画面拉伸、主体截断、贴边裁切、矩形贴片、硬边、拼接线、补丁感、色差边缘、背景错位、地面纹理断裂、椅脚错位",
prompt, prompt,

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing"; import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
import { canAccessHiddenAiModels } from "@/lib/ai/model-visibility";
import { getSystemSetting, getUserById } from "@/lib/db/queries"; import { getSystemSetting, getUserById } from "@/lib/db/queries";
import { import {
findStyleProImageModelByRef, findStyleProImageModelByRef,
@ -27,8 +28,11 @@ export async function GET() {
} }
const currentUser = await getUserById({ id: session.user.id }); const currentUser = await getUserById({ id: session.user.id });
const allowHiddenModels = const allowHiddenModels = canAccessHiddenAiModels({
session.user.type === "admin" || Boolean(currentUser?.isHighVip); sessionUserType: session.user.type,
databaseUserRole: currentUser?.role,
isHighVip: currentUser?.isHighVip,
});
const [displayEntries, entries, realisticModelEntries, setting] = const [displayEntries, entries, realisticModelEntries, setting] =
await Promise.all([ await Promise.all([
@ -67,14 +71,21 @@ export async function GET() {
}) })
: 0; : 0;
return NextResponse.json({ return NextResponse.json(
success: true, {
defaultModelId: defaultEntry?.model.id || null, success: true,
realisticUnitCost, defaultModelId: defaultEntry?.model.id || null,
models: sortStyleProImageModelEntries(displayEntries).map( realisticUnitCost,
toPublicStyleProModelOption models: sortStyleProImageModelEntries(displayEntries).map(
), toPublicStyleProModelOption
}); ),
},
{
headers: {
"Cache-Control": "private, no-store, max-age=0",
},
}
);
} catch (error) { } catch (error) {
console.error("[Style Pro Model Options] Failed:", error); console.error("[Style Pro Model Options] Failed:", error);
return NextResponse.json( return NextResponse.json(

View File

@ -285,6 +285,23 @@ const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
"cancelled", "cancelled",
"unknown", "unknown",
]); ]);
const RECOVERABLE_TASK_QUERY_ERROR_REGEX =
/(?:查询\s*(?:3D\s*)?任务失败|任务查询失败|任务同步超时)/i;
function restoreRecoverableTaskForPolling(task: TaskRecord) {
if (
task.provider === "tripo-3d" &&
task.status === "failed" &&
RECOVERABLE_TASK_QUERY_ERROR_REGEX.test(String(task.error || ""))
) {
return {
...task,
status: "running" as const,
error: null,
};
}
return task;
}
const TASK_MODE_SET = new Set<TaskMode>([ const TASK_MODE_SET = new Set<TaskMode>([
"text", "text",
"image", "image",
@ -850,6 +867,7 @@ export default function CanvasModel3DPage() {
); );
const pollAttemptsRef = useRef(0); const pollAttemptsRef = useRef(0);
const pollFailureStreakRef = useRef(0);
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]); const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]); const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
@ -1186,6 +1204,7 @@ export default function CanvasModel3DPage() {
let normalized = list let normalized = list
.map((item) => mapApiEntityToTaskRecord(item)) .map((item) => mapApiEntityToTaskRecord(item))
.filter((item): item is TaskRecord => Boolean(item)) .filter((item): item is TaskRecord => Boolean(item))
.map(restoreRecoverableTaskForPolling)
.slice(0, TASK_HISTORY_LIMIT); .slice(0, TASK_HISTORY_LIMIT);
if (normalized.length === 0) { if (normalized.length === 0) {
@ -1418,34 +1437,39 @@ export default function CanvasModel3DPage() {
pollAttemptsRef.current = 0; pollAttemptsRef.current = 0;
let cancelled = false; let cancelled = false;
let pollInFlight = false;
const runPoll = async () => { const runPoll = async () => {
if (cancelled) return; if (cancelled || pollInFlight) return;
pollInFlight = true;
pollAttemptsRef.current += 1; pollAttemptsRef.current += 1;
try {
const isWorldTask = currentTask.provider === "worldlabs-3d";
const result =
isWorldTask
? await worldLabsGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
})
: await model3DGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
});
if (!result.success || !result.data) {
pollFailureStreakRef.current += 1;
if (pollFailureStreakRef.current < 5) return;
const failMessage = result.error || "任务查询失败";
const failedRecord: TaskRecord = {
...currentTask,
status: "failed",
error: failMessage,
};
setCurrentTask(failedRecord);
upsertTaskHistory(failedRecord);
toast.error(failMessage);
return;
}
pollFailureStreakRef.current = 0;
const isWorldTask = currentTask.provider === "worldlabs-3d"; const status = normalizeTaskStatus(result.data.status);
const result =
isWorldTask
? await worldLabsGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
})
: await model3DGenerationService.getTask(currentTask.taskId, {
providerId: currentTask.assetPayload?.providerId,
});
if (!result.success || !result.data) {
const failMessage = result.error || "任务查询失败";
const failedRecord: TaskRecord = {
...currentTask,
status: "failed",
error: failMessage,
};
setCurrentTask(failedRecord);
upsertTaskHistory(failedRecord);
toast.error(failMessage);
return;
}
const status = normalizeTaskStatus(result.data.status);
const worldResultData = isWorldTask const worldResultData = isWorldTask
? (result.data as NonNullable< ? (result.data as NonNullable<
Awaited<ReturnType<typeof worldLabsGenerationService.getTask>>["data"] Awaited<ReturnType<typeof worldLabsGenerationService.getTask>>["data"]
@ -1501,7 +1525,7 @@ export default function CanvasModel3DPage() {
} }
} }
if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) { if (pollAttemptsRef.current >= 360 && !FINAL_TASK_STATUS.has(status)) {
const timeoutRecord: TaskRecord = { const timeoutRecord: TaskRecord = {
...nextRecord, ...nextRecord,
status: "failed", status: "failed",
@ -1510,6 +1534,9 @@ export default function CanvasModel3DPage() {
setCurrentTask(timeoutRecord); setCurrentTask(timeoutRecord);
upsertTaskHistory(timeoutRecord); upsertTaskHistory(timeoutRecord);
toast.error("任务轮询超时,请稍后重试"); toast.error("任务轮询超时,请稍后重试");
}
} finally {
pollInFlight = false;
} }
}; };

View File

@ -300,6 +300,7 @@ function configureMaterialTextures(
function cloneSceneWithIndependentMaterials(scene: Object3D) { function cloneSceneWithIndependentMaterials(scene: Object3D) {
const clonedScene = scene.clone(true); const clonedScene = scene.clone(true);
const clonedTextures = new Map<THREE.Texture, THREE.Texture>();
clonedScene.traverse((node) => { clonedScene.traverse((node) => {
if (!isMesh(node)) return; if (!isMesh(node)) return;
@ -309,6 +310,24 @@ function cloneSceneWithIndependentMaterials(scene: Object3D) {
: [node.material]; : [node.material];
const clonedMaterials = sourceMaterials.map((material) => { const clonedMaterials = sourceMaterials.map((material) => {
const clonedMaterial = material.clone(); const clonedMaterial = material.clone();
const sourceMaterialRecord = material as any;
const clonedMaterialRecord = clonedMaterial as any;
for (const key of MATERIAL_TEXTURE_KEYS) {
const sourceTexture = sourceMaterialRecord[key];
if (!sourceTexture?.isTexture) continue;
const typedSourceTexture = sourceTexture as THREE.Texture;
let clonedTexture = clonedTextures.get(typedSourceTexture);
if (!clonedTexture) {
const newTexture = typedSourceTexture.clone();
newTexture.needsUpdate = true;
clonedTextures.set(typedSourceTexture, newTexture);
clonedTexture = newTexture;
}
clonedMaterialRecord[key] = clonedTexture;
}
configureMaterialTextures(clonedMaterial, node.geometry); configureMaterialTextures(clonedMaterial, node.geometry);
return clonedMaterial; return clonedMaterial;
}); });
@ -407,12 +426,20 @@ function ModelScene({
useEffect(() => { useEffect(() => {
return () => { return () => {
const disposedTextures = new Set<THREE.Texture>();
scene.traverse((node) => { scene.traverse((node) => {
if (!isMesh(node)) return; if (!isMesh(node)) return;
const materials = Array.isArray(node.material) const materials = Array.isArray(node.material)
? node.material ? node.material
: [node.material]; : [node.material];
for (const material of materials) { for (const material of materials) {
const materialRecord = material as any;
for (const key of MATERIAL_TEXTURE_KEYS) {
const texture = materialRecord[key];
if (!texture?.isTexture || disposedTextures.has(texture)) continue;
disposedTextures.add(texture);
texture.dispose();
}
material.dispose(); material.dispose();
} }
}); });
@ -437,7 +464,7 @@ function ModelScene({
return ( return (
<group rotation={modelRotation || [0, 0, 0]}> <group rotation={modelRotation || [0, 0, 0]}>
<primitive object={scene} scale={1} /> <primitive dispose={null} object={scene} scale={1} />
</group> </group>
); );
} }

View File

@ -645,6 +645,7 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024; const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
const CANVAS_3D_POLL_INTERVAL_MS = 2000; const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360; const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES = 5;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500; const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80; const AI_WASH_TASK_POLL_LIMIT = 80;
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000; const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
@ -7107,6 +7108,7 @@ export function ImageNode({
sourceImageUrl: string; sourceImageUrl: string;
previewImageUrl?: string | null; previewImageUrl?: string | null;
}) => { }) => {
let consecutiveQueryFailures = 0;
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) { for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
await sleep(CANVAS_3D_POLL_INTERVAL_MS); await sleep(CANVAS_3D_POLL_INTERVAL_MS);
const result = await model3DGenerationService.getTask(params.taskId, { const result = await model3DGenerationService.getTask(params.taskId, {
@ -7116,6 +7118,12 @@ export function ImageNode({
const progress = result.data?.progress ?? null; const progress = result.data?.progress ?? null;
if (!result.success || !result.data) { if (!result.success || !result.data) {
consecutiveQueryFailures += 1;
if (
consecutiveQueryFailures < CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES
) {
continue;
}
const error = result.error || "3D 模型任务查询失败"; const error = result.error || "3D 模型任务查询失败";
updateNode(params.nodeId, { updateNode(params.nodeId, {
data: { data: {
@ -7155,6 +7163,7 @@ export function ImageNode({
toast.error(error); toast.error(error);
return; return;
} }
consecutiveQueryFailures = 0;
if ( if (
status === "failed" || status === "failed" ||

View File

@ -1001,7 +1001,12 @@ export function StyleProWorkspace({
const { data: configData, isLoading: configLoading } = const { data: configData, isLoading: configLoading } =
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher); useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
const { data: modelsData, isLoading: modelsLoading } = const { data: modelsData, isLoading: modelsLoading } =
useSWR<StyleProModelsResponse>("/api/style-pro/model-options", fetcher); useSWR<StyleProModelsResponse>("/api/style-pro/model-options", fetcher, {
dedupingInterval: 5_000,
refreshInterval: 30_000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
});
const config = configData?.config; const config = configData?.config;
const modelOptions = useMemo( const modelOptions = useMemo(
() => (modelsData?.models || []).filter((model) => Boolean(model.id)), () => (modelsData?.models || []).filter((model) => Boolean(model.id)),

View File

@ -178,6 +178,18 @@ export function isImageGenerationModelDescriptor(params: {
return STATIC_IMAGE_PROVIDER_TYPES.has(providerType); return STATIC_IMAGE_PROVIDER_TYPES.has(providerType);
} }
export function isStillImageGenerationModelDescriptor(params: {
providerType?: string | null;
modelId?: string | null;
capabilities?: unknown;
supportsImageGen?: boolean | null;
}): boolean {
return (
isImageGenerationModelDescriptor(params) &&
!isVideoModelDescriptor(params)
);
}
export function modelSupportsTextToImageDescriptor(params: { export function modelSupportsTextToImageDescriptor(params: {
providerType?: string | null; providerType?: string | null;
modelId?: string | null; modelId?: string | null;

View File

@ -0,0 +1,19 @@
export type HiddenModelAccessInput = {
sessionUserType?: string | null;
databaseUserRole?: string | null;
isHighVip?: boolean | null;
};
const HIDDEN_MODEL_ADMIN_ROLES = new Set(["admin", "agent_admin"]);
export function canAccessHiddenAiModels({
sessionUserType,
databaseUserRole,
isHighVip,
}: HiddenModelAccessInput) {
return (
isHighVip === true ||
HIDDEN_MODEL_ADMIN_ROLES.has(String(databaseUserRole || "").trim()) ||
HIDDEN_MODEL_ADMIN_ROLES.has(String(sessionUserType || "").trim())
);
}

View File

@ -14,6 +14,7 @@ const BFL_REFERENCE_IMAGE_MODEL_REGEX =
/^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i; /^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i;
const REFERENCE_IMAGE_MODE_REGEX = const REFERENCE_IMAGE_MODE_REGEX =
/^(?:image-edit|image-to-image|img2img|reference-images?|image-reference|reference-image)$/i; /^(?:image-edit|image-to-image|img2img|reference-images?|image-reference|reference-image)$/i;
const GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX = /^(?:nano-banana-pro|nano-banana-2)$/i;
function readStringField(source: unknown, key: string) { function readStringField(source: unknown, key: string) {
if (!source || typeof source !== "object" || !(key in source)) return ""; if (!source || typeof source !== "object" || !(key in source)) return "";
@ -86,6 +87,14 @@ export function modelLacksReferenceImageInputSupport(
provider?: ProviderDescriptor | null provider?: ProviderDescriptor | null
) { ) {
if (isKnownReferenceImageModel(model, provider)) return false; if (isKnownReferenceImageModel(model, provider)) return false;
const providerType = String(provider?.type || "").trim().toLowerCase();
const modelId = String(model?.modelId || "").trim();
if (
providerType === "gemini" &&
GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX.test(modelId)
) {
return true;
}
return model?.supportsVision === false; return model?.supportsVision === false;
} }

View File

@ -5,6 +5,11 @@ export type CanvasLocalEditConfig = {
scaleAdjustModelId: string; scaleAdjustModelId: string;
}; };
export type CanvasLocalEditModelIdentity = {
id: string;
modelId: string;
};
export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig { export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig {
return { return {
modelId: "", modelId: "",
@ -16,6 +21,22 @@ function normalizeModelRef(value: unknown) {
return String(value || "").trim().slice(0, 128); 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( export function normalizeCanvasLocalEditConfig(
value: unknown value: unknown
): CanvasLocalEditConfig { ): CanvasLocalEditConfig {

View File

@ -7,6 +7,11 @@ const PROXY_HOST_KEYWORDS = [
"marble", "marble",
"s3.1080p.hk", "s3.1080p.hk",
"img.yydd.ai", "img.yydd.ai",
"yybb.top",
"qiniucs.com",
"qiniudn.com",
"clouddn.com",
"qbox.me",
"blob.vercel-storage.com", "blob.vercel-storage.com",
"aliyuncs.com", "aliyuncs.com",
"amazonaws.com", "amazonaws.com",

View File

@ -0,0 +1,51 @@
import type { AiModel, AiProvider } from "@/lib/db/schema";
import {
getAiModelFrontendAvailability,
type ModelFrontendAvailability,
} from "@/lib/ai/model-frontend-availability";
import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability";
type StyleProAvailabilityEntry = {
model: Pick<
AiModel,
| "displayName"
| "modelId"
| "supportsVision"
| "capabilities"
| "defaultParams"
| "isEnabled"
| "healthStatus"
| "healthAutoDisabledAt"
| "healthObservationStartedAt"
>;
provider: Pick<AiProvider, "type">;
};
export function getStyleProModelAvailability(
entry: StyleProAvailabilityEntry
): ModelFrontendAvailability {
if (!modelSupportsReferenceImageInput(entry.model, entry.provider)) {
return {
availabilityStatus: "maintenance",
availabilityLabel: "仅文生图",
selectable: false,
disabledReason: "风格化需要参考图,该模型仅支持文生图",
};
}
const availability = getAiModelFrontendAvailability(entry.model);
if (availability.availabilityStatus === "testing") {
return {
availabilityStatus: "testing",
availabilityLabel: "暂不可用",
selectable: false,
disabledReason: "模型近期生成异常,恢复健康后会自动开放",
};
}
return availability;
}
export function isStyleProModelRunnable(entry: StyleProAvailabilityEntry) {
return getStyleProModelAvailability(entry).selectable;
}

View File

@ -6,13 +6,17 @@ import {
getImageModelCreditDiscountLabel, getImageModelCreditDiscountLabel,
} from "@/lib/ai/image-model-pricing"; } from "@/lib/ai/image-model-pricing";
import { import {
getAiModelFrontendAvailability,
type ModelFrontendAvailabilityStatus, type ModelFrontendAvailabilityStatus,
} from "@/lib/ai/model-frontend-availability"; } from "@/lib/ai/model-frontend-availability";
import { isStillImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
import { import {
getEnabledAiModelsWithProvider, getEnabledAiModelsWithProvider,
getFrontendDisplayAiModelsWithProvider, getFrontendDisplayAiModelsWithProvider,
} from "@/lib/db/queries"; } from "@/lib/db/queries";
import {
getStyleProModelAvailability,
isStyleProModelRunnable,
} from "./model-availability";
export type StyleProModelEntry = Awaited< export type StyleProModelEntry = Awaited<
ReturnType<typeof getEnabledAiModelsWithProvider> ReturnType<typeof getEnabledAiModelsWithProvider>
@ -37,127 +41,15 @@ export type PublicStyleProModelOption = {
disabledReason: string | null; disabledReason: string | null;
}; };
const VIDEO_PROVIDER_TYPES = new Set([
"grsai-video",
"qianwen-video",
"doubao-video",
"ruxa-video",
"volcano-video",
"fal-video",
"runway",
]);
const VIDEO_MODEL_KEYWORDS = [
"/generate/video/",
"video",
"veo",
"sora",
"kling",
"hailuo",
"seedance",
"runway",
"gen-3",
"gen-4",
"gen 3",
"gen 4",
"wan-2.",
"wan 2.",
"t2v",
"i2v",
];
const EXPLICIT_IMAGE_MODEL_KEYWORDS = [
"/generate/image/",
"gpt-image",
"sora_image",
"qwen-image",
"seedream",
"nano-banana",
"banana",
"flux",
"z-image",
"image-edit",
"gen4_image",
"gen-4 image",
];
function hasVideoGenerationConfig(value: unknown) {
if (!value || typeof value !== "object") return false;
const config = value as Record<string, unknown>;
return (
"creditCostPerVideo" in config ||
"creditCostPerVideoByDuration" in config ||
"creditCostPerVideoByResolution" in config ||
"videoDurationOptions" in config ||
"videoResolutionOptions" in config
);
}
function hasVideoCapabilities(value: unknown) {
if (!value || typeof value !== "object") return false;
const serialized = JSON.stringify(value).toLowerCase();
return (
serialized.includes("text-to-video") ||
serialized.includes("image-to-video")
);
}
function isExplicitImageGenerationModel(
entry: StyleProModelEntry | StyleProDisplayModelEntry
) {
const searchable = [
entry.model.modelId,
entry.model.displayName,
entry.model.description,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
if (
searchable.includes("image-to-video") ||
searchable.includes("text-to-video") ||
searchable.includes("i2v") ||
searchable.includes("t2v")
) {
return false;
}
return EXPLICIT_IMAGE_MODEL_KEYWORDS.some((keyword) =>
searchable.includes(keyword)
);
}
function isVideoLikeModel(
entry: StyleProModelEntry | StyleProDisplayModelEntry
) {
if (VIDEO_PROVIDER_TYPES.has(entry.provider.type)) return true;
if (
hasVideoGenerationConfig(entry.model.imageGenConfig) ||
hasVideoCapabilities(entry.model.capabilities) ||
hasVideoCapabilities(entry.model.defaultParams)
) {
return true;
}
const searchable = [
entry.provider.type,
entry.provider.name,
entry.model.modelId,
entry.model.displayName,
entry.model.description,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return VIDEO_MODEL_KEYWORDS.some((keyword) => searchable.includes(keyword));
}
export function isStyleProImageModel( export function isStyleProImageModel(
entry: StyleProModelEntry | StyleProDisplayModelEntry entry: StyleProModelEntry | StyleProDisplayModelEntry
) { ) {
if (!entry.model.supportsImageGen) return false; return isStillImageGenerationModelDescriptor({
if (isExplicitImageGenerationModel(entry)) return true; providerType: entry.provider.type,
return !isVideoLikeModel(entry); modelId: entry.model.modelId,
capabilities: entry.model.capabilities,
supportsImageGen: entry.model.supportsImageGen,
});
} }
export async function getStyleProImageModelEntries(options?: { export async function getStyleProImageModelEntries(options?: {
@ -167,7 +59,9 @@ export async function getStyleProImageModelEntries(options?: {
false, false,
options?.includeHidden === true options?.includeHidden === true
); );
return models.filter(isStyleProImageModel); return models
.filter(isStyleProImageModel)
.filter(isStyleProModelRunnable);
} }
export async function getStyleProImageModelDisplayEntries(options?: { export async function getStyleProImageModelDisplayEntries(options?: {
@ -216,7 +110,7 @@ export function toPublicStyleProModelOption(
): PublicStyleProModelOption { ): PublicStyleProModelOption {
const imageGenConfig = (entry.model.imageGenConfig || const imageGenConfig = (entry.model.imageGenConfig ||
null) as DiscountableImageGenConfigLike | null; null) as DiscountableImageGenConfigLike | null;
const availability = getAiModelFrontendAvailability(entry.model); const availability = getStyleProModelAvailability(entry);
return { return {
id: entry.model.id, id: entry.model.id,
modelId: entry.model.modelId, modelId: entry.model.modelId,

View File

@ -11,6 +11,10 @@ import type {
StyleProCustomInputMap, StyleProCustomInputMap,
StyleProSelectionMap, StyleProSelectionMap,
} from "./config"; } from "./config";
import {
findStyleProImageModelByRef,
getStyleProImageModelEntries,
} from "./model-options";
export type StyleProTaskSelectedOption = { export type StyleProTaskSelectedOption = {
categoryId: string; categoryId: string;
@ -266,6 +270,18 @@ export async function executeStyleProTask(params: {
userId: string; userId: string;
}) { }) {
const { request, userId } = params; const { request, userId } = params;
const runnableModels = await getStyleProImageModelEntries({
includeHidden: request.generationMode === "realistic",
});
const unavailableModel = request.selectedModels.find(
(selectedModel) =>
!findStyleProImageModelByRef(runnableModels, selectedModel.id)
);
if (unavailableModel) {
throw new Error(
`风格化模型“${unavailableModel.displayName || unavailableModel.id}”当前不支持参考图,请重新选择`
);
}
const aspectRatio = request.prompts.aspectRatio || "auto"; const aspectRatio = request.prompts.aspectRatio || "auto";
const imageSize = request.prompts.imageSize || "2K"; const imageSize = request.prompts.imageSize || "2K";
const negativePrompt = request.prompts.negativePrompt || undefined; const negativePrompt = request.prompts.negativePrompt || undefined;

View File

@ -13,6 +13,7 @@ export default defineConfig({
/routes\/canvas-scale-adjust-intent\.test\.ts/, /routes\/canvas-scale-adjust-intent\.test\.ts/,
/routes\/canvas-runtime-internal-endpoint\.test\.ts/, /routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/model-classification\.test\.ts/, /routes\/model-classification\.test\.ts/,
/routes\/model-visibility\.test\.ts/,
/routes\/model3d-style-pro-regressions\.test\.ts/, /routes\/model3d-style-pro-regressions\.test\.ts/,
], ],
fullyParallel: true, fullyParallel: true,

View File

@ -0,0 +1,471 @@
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { createHash } from "node:crypto";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
} from "node:fs";
import { dirname } from "node:path";
import postgres from "postgres";
import { replaceImageUrlInDatabase } from "@/lib/storage/image-url-replacement";
type ManifestStatus =
| "uploaded"
| "upload_failed"
| "completed"
| "replace_failed";
type ManifestEvent = {
sourceUrl: string;
status: ManifestStatus;
targetUrl?: string;
pathname?: string;
bytes?: number;
sha256?: string;
changed?: number;
error?: string;
recordedAt: string;
};
type StorageConfigRow = {
id: string;
name: string;
type: string;
endpoint: string;
region: string | null;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicUrl: string;
isEnabled: boolean;
};
const MAX_IMAGE_BYTES = 100 * 1024 * 1024;
function readArg(name: string, fallback = "") {
const prefix = `--${name}=`;
return (
process.argv.slice(2).find((item) => item.startsWith(prefix))?.slice(prefix.length) ||
fallback
).trim();
}
function hasFlag(name: string) {
return process.argv.slice(2).includes(`--${name}`);
}
function parseDate(value: string, name: string) {
const parsed = new Date(`${value}T00:00:00.000Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid --${name} date: ${value}`);
}
return parsed;
}
function safeError(error: unknown) {
return String(error instanceof Error ? error.message : error || "Unknown error").slice(
0,
2000
);
}
function loadManifest(path: string) {
const latest = new Map<string, ManifestEvent>();
if (!existsSync(path)) return latest;
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as ManifestEvent;
if (event.sourceUrl && event.status) latest.set(event.sourceUrl, event);
} catch {
throw new Error(`Invalid JSONL manifest line in ${path}`);
}
}
return latest;
}
function appendManifest(path: string, event: ManifestEvent) {
mkdirSync(dirname(path), { recursive: true });
appendFileSync(path, `${JSON.stringify(event)}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
function getExtension(contentType: string, sourceUrl: string) {
const normalizedType = contentType.split(";")[0]?.trim().toLowerCase();
const byType: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/avif": "avif",
"image/svg+xml": "svg",
};
if (byType[normalizedType]) return byType[normalizedType];
try {
const extension = new URL(sourceUrl).pathname
.split(".")
.pop()
?.toLowerCase()
.replace(/[^a-z0-9]/g, "");
if (extension && ["jpg", "jpeg", "png", "webp", "gif", "avif", "svg"].includes(extension)) {
return extension === "jpeg" ? "jpg" : extension;
}
} catch {
// Use the safe fallback below.
}
return "img";
}
async function downloadImage(sourceUrl: string) {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const response = await fetch(sourceUrl, {
headers: {
Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
"User-Agent": "AI-Chatbot-Historical-Qiniu-Migrator/1.0",
},
signal: AbortSignal.timeout(60_000),
});
if (!response.ok) {
throw new Error(`download HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get("content-length") || 0);
if (contentLength > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
const contentType = String(response.headers.get("content-type") || "");
if (contentType && !contentType.toLowerCase().startsWith("image/")) {
throw new Error(`unexpected content-type: ${contentType}`);
}
const bytes = Buffer.from(await response.arrayBuffer());
if (bytes.length === 0) throw new Error("downloaded image is empty");
if (bytes.length > MAX_IMAGE_BYTES) {
throw new Error(`image exceeds ${MAX_IMAGE_BYTES} bytes`);
}
return {
bytes,
contentType: contentType.split(";")[0]?.trim() || "application/octet-stream",
};
} catch (error) {
lastError = error;
if (attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
}
}
throw lastError;
}
async function verifyPublicUrl(url: string) {
let lastStatus = 0;
for (let attempt = 1; attempt <= 4; attempt += 1) {
const head = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(20_000),
headers: { "User-Agent": "AI-Chatbot-Historical-Qiniu-Verifier/1.0" },
}).catch(() => null);
lastStatus = head?.status || 0;
if (head?.ok) return;
const ranged = await fetch(url, {
signal: AbortSignal.timeout(20_000),
headers: {
Range: "bytes=0-0",
"User-Agent": "AI-Chatbot-Historical-Qiniu-Verifier/1.0",
},
}).catch(() => null);
lastStatus = ranged?.status || lastStatus;
if (ranged?.ok) return;
await new Promise((resolve) => setTimeout(resolve, attempt * 1000));
}
throw new Error(`uploaded object is not publicly readable (HTTP ${lastStatus || "unknown"})`);
}
function buildTargetUrl(publicUrl: string, key: string) {
return `${publicUrl.replace(/\/+$/, "")}/${key.replace(/^\/+/, "")}`;
}
async function runPool<T>(
values: T[],
concurrency: number,
worker: (value: T, index: number) => Promise<void>
) {
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(concurrency, values.length) }, async () => {
while (cursor < values.length) {
const index = cursor;
cursor += 1;
await worker(values[index], index);
}
})
);
}
async function main() {
const databaseUrl = process.env.POSTGRES_URL;
if (!databaseUrl) throw new Error("POSTGRES_URL is required");
const fromText = readArg("from", "2026-06-01");
const toText = readArg("to", "2026-09-01");
const from = parseDate(fromText, "from");
const to = parseDate(toText, "to");
if (to <= from) throw new Error("--to must be later than --from");
const targetStorageId = readArg("target-storage-id");
const manifestPath = readArg("manifest");
const concurrency = Math.max(
1,
Math.min(48, Number(readArg("concurrency", "4")) || 4)
);
const limit = Math.max(0, Number(readArg("limit", "0")) || 0);
const execute = hasFlag("execute");
if (execute && (!targetStorageId || !manifestPath)) {
throw new Error("--execute requires --target-storage-id and --manifest");
}
const sqlClient = postgres(databaseUrl, {
connect_timeout: 15,
idle_timeout: 20,
max: Math.max(2, concurrency),
prepare: false,
});
try {
const candidateRows = await sqlClient<{ url: string }[]>`
WITH candidate_urls AS (
SELECT "originalUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
UNION
SELECT "thumbnailUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
)
SELECT url
FROM candidate_urls
WHERE url ~ '^https?://'
ORDER BY url
`;
const hostCounts = new Map<string, number>();
for (const { url } of candidateRows) {
const host = new URL(url).hostname.toLowerCase();
hostCounts.set(host, (hostCounts.get(host) || 0) + 1);
}
console.log(
JSON.stringify({
mode: execute ? "execute" : "plan",
from: fromText,
to: toText,
distinctUrls: candidateRows.length,
hosts: Object.fromEntries(
[...hostCounts.entries()].sort((left, right) => right[1] - left[1])
),
})
);
if (!execute) return;
const storageRows = await sqlClient<StorageConfigRow[]>`
SELECT
id, name, type, endpoint, region, bucket,
"accessKeyId", "secretAccessKey", "publicUrl", "isEnabled"
FROM "StorageConfig"
WHERE id = ${targetStorageId}
LIMIT 1
`;
const storage = storageRows[0];
if (!storage || !storage.isEnabled || storage.type !== "qiniu-kodo") {
throw new Error("Enabled Qiniu target storage config not found");
}
if (!storage.endpoint || !storage.bucket || !storage.accessKeyId || !storage.secretAccessKey || !storage.publicUrl) {
throw new Error("Qiniu target storage config is incomplete");
}
const targetHost = new URL(storage.publicUrl).hostname.toLowerCase();
const manifest = loadManifest(manifestPath);
const pendingCandidates = candidateRows
.map((row) => row.url)
.filter((url) => new URL(url).hostname.toLowerCase() !== targetHost)
.filter((url) => {
const status = manifest.get(url)?.status;
return status !== "uploaded" && status !== "completed";
});
const uploadCandidates = limit > 0 ? pendingCandidates.slice(0, limit) : pendingCandidates;
const s3 = new S3Client({
endpoint: storage.endpoint,
region: storage.region || "cn-east-1",
credentials: {
accessKeyId: storage.accessKeyId,
secretAccessKey: storage.secretAccessKey,
},
forcePathStyle: true,
});
let uploaded = 0;
let uploadFailed = 0;
await runPool(uploadCandidates, concurrency, async (sourceUrl) => {
try {
const downloaded = await downloadImage(sourceUrl);
const contentSha = createHash("sha256").update(downloaded.bytes).digest("hex");
const sourceSha = createHash("sha256").update(sourceUrl).digest("hex");
const extension = getExtension(downloaded.contentType, sourceUrl);
const key = `historical-images/${fromText}_${toText}/${sourceSha.slice(0, 24)}-${contentSha.slice(0, 12)}.${extension}`;
await s3.send(
new PutObjectCommand({
Bucket: storage.bucket,
Key: key,
Body: downloaded.bytes,
ContentType: downloaded.contentType,
CacheControl: "public, max-age=31536000, immutable",
})
);
const targetUrl = buildTargetUrl(storage.publicUrl, key);
await verifyPublicUrl(targetUrl);
const event: ManifestEvent = {
sourceUrl,
targetUrl,
pathname: key,
status: "uploaded",
bytes: downloaded.bytes.length,
sha256: contentSha,
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, event);
manifest.set(sourceUrl, event);
uploaded += 1;
} catch (error) {
const event: ManifestEvent = {
sourceUrl,
status: "upload_failed",
error: safeError(error),
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, event);
manifest.set(sourceUrl, event);
uploadFailed += 1;
}
const processed = uploaded + uploadFailed;
if (processed % 25 === 0 || processed === uploadCandidates.length) {
console.log(
JSON.stringify({ phase: "upload", processed, total: uploadCandidates.length, uploaded, failed: uploadFailed })
);
}
});
const currentCandidateSet = new Set(candidateRows.map((row) => row.url));
const replaceCandidates = [...manifest.values()].filter(
(event) =>
Boolean(event.targetUrl) &&
(event.status === "uploaded" ||
event.status === "replace_failed" ||
(event.status === "completed" && currentCandidateSet.has(event.sourceUrl)))
);
let replaced = 0;
let replaceFailed = 0;
for (const event of replaceCandidates) {
try {
let changed = 0;
let drained = false;
for (let pass = 0; pass < 20; pass += 1) {
const result = await replaceImageUrlInDatabase(
event.sourceUrl,
event.targetUrl as string
);
changed += result.changed;
if (result.changed === 0) {
drained = true;
break;
}
}
if (!drained) throw new Error("replacement did not drain within 20 passes");
const completed: ManifestEvent = {
...event,
status: "completed",
changed,
error: undefined,
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, completed);
manifest.set(event.sourceUrl, completed);
replaced += 1;
} catch (error) {
const failed: ManifestEvent = {
...event,
status: "replace_failed",
error: safeError(error),
recordedAt: new Date().toISOString(),
};
appendManifest(manifestPath, failed);
manifest.set(event.sourceUrl, failed);
replaceFailed += 1;
}
const processed = replaced + replaceFailed;
if (processed % 25 === 0 || processed === replaceCandidates.length) {
console.log(
JSON.stringify({ phase: "replace", processed, total: replaceCandidates.length, replaced, failed: replaceFailed })
);
}
}
const remainingRows = await sqlClient<{ count: number }[]>`
WITH candidate_urls AS (
SELECT "originalUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
UNION
SELECT "thumbnailUrl" AS url
FROM "GeneratedImage"
WHERE "createdAt" >= ${from} AND "createdAt" < ${to}
)
SELECT count(*)::int AS count
FROM candidate_urls
WHERE url ~ '^https?://' AND url NOT LIKE ${`${storage.publicUrl.replace(/\/+$/, "")}/%`}
`;
const finalStates = [...manifest.values()];
console.log(
JSON.stringify({
phase: "complete",
uploaded,
uploadFailed,
replaced,
replaceFailed,
remainingNonQiniuUrls: remainingRows[0]?.count || 0,
manifest: {
completed: finalStates.filter((event) => event.status === "completed").length,
uploadFailed: finalStates.filter((event) => event.status === "upload_failed").length,
replaceFailed: finalStates.filter((event) => event.status === "replace_failed").length,
},
})
);
} finally {
await sqlClient.end({ timeout: 5 });
}
}
main()
.then(() => process.exit(0))
.catch((error) => {
console.error(safeError(error));
process.exit(1);
});

View File

@ -4,6 +4,7 @@ import { resolve } from "node:path";
import { hasScaleAdjustTargetChanged } from "../../components/canvas/nodes/image-node/image-node-local-edit"; import { hasScaleAdjustTargetChanged } from "../../components/canvas/nodes/image-node/image-node-local-edit";
import { hasScaleAdjustPositionMoved } from "../../lib/canvas/scale-adjust-geometry"; import { hasScaleAdjustPositionMoved } from "../../lib/canvas/scale-adjust-geometry";
import { resolveCanvasLocalEditModelRef } from "../../lib/canvas/local-edit-config";
import { import {
buildSemanticScaleAdjustCleanupPrompt, buildSemanticScaleAdjustCleanupPrompt,
buildSemanticScaleAdjustCompositeRegions, buildSemanticScaleAdjustCompositeRegions,
@ -16,6 +17,44 @@ function readSource(path: string) {
} }
test.describe("canvas scale adjust product lock", () => { 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("providerNamesById.get(model.providerId)");
expect(settingsPanel).toContain(
'<SelectItem key={model.id} value={model.id}>'
);
});
test("accepts target movement even when target size stays unchanged", () => { test("accepts target movement even when target size stays unchanged", () => {
const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 }; const source = { height: 0.4, width: 0.3, x: 0.2, y: 0.25 };

View File

@ -1,8 +1,38 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { import {
isImageGenerationModelDescriptor, isImageGenerationModelDescriptor,
isStillImageGenerationModelDescriptor,
isVideoModelDescriptor, isVideoModelDescriptor,
} from "@/lib/ai/model-classification"; } from "@/lib/ai/model-classification";
import { getStyleProModelAvailability } from "@/lib/style-pro/model-availability";
import type { AiProvider } from "@/lib/db/schema";
function styleProEntry(overrides?: {
providerType?: AiProvider["type"];
modelId?: string;
supportsVision?: boolean;
healthStatus?: "healthy" | "degraded" | "auto_disabled";
healthAutoDisabledAt?: Date | null;
healthObservationStartedAt?: Date | null;
}) {
return {
model: {
displayName: "Test Image Model",
modelId: overrides?.modelId || "test-image-model",
supportsVision: overrides?.supportsVision ?? true,
capabilities: null,
defaultParams: null,
isEnabled: true,
healthStatus: overrides?.healthStatus || "healthy",
healthAutoDisabledAt: overrides?.healthAutoDisabledAt || null,
healthObservationStartedAt:
overrides?.healthObservationStartedAt || null,
},
provider: {
type: overrides?.providerType || "openai-image",
},
};
}
test.describe("model classification", () => { test.describe("model classification", () => {
test("keeps multi-reference BananaImage models classified as image models", () => { test("keeps multi-reference BananaImage models classified as image models", () => {
@ -32,4 +62,55 @@ test.describe("model classification", () => {
}) })
).toBe(true); ).toBe(true);
}); });
test("uses one image-only rule for canvas and Style Pro model pools", () => {
expect(
isStillImageGenerationModelDescriptor({
providerType: "ruxa-image",
modelId: "google/nano-banana-2",
supportsImageGen: true,
})
).toBe(true);
expect(
isStillImageGenerationModelDescriptor({
providerType: "ruxa-video",
modelId: "kling-video",
supportsImageGen: true,
})
).toBe(false);
});
test("prevents reference-incompatible models from being selected in Style Pro", () => {
const availability = getStyleProModelAvailability(
styleProEntry({ supportsVision: false })
);
expect(availability.selectable).toBe(false);
expect(availability.availabilityLabel).toBe("仅文生图");
});
test("temporarily disables degraded models in Style Pro", () => {
const availability = getStyleProModelAvailability(
styleProEntry({
healthStatus: "degraded",
healthObservationStartedAt: new Date(),
})
);
expect(availability.selectable).toBe(false);
expect(availability.availabilityLabel).toBe("暂不可用");
});
test("keeps known reference-capable BFL models selectable", () => {
const availability = getStyleProModelAvailability(
styleProEntry({
providerType: "bfl-image",
modelId: "flux-2-klein-4b",
supportsVision: false,
})
);
expect(availability.selectable).toBe(true);
});
}); });

View File

@ -0,0 +1,31 @@
import { expect, test } from "@playwright/test";
import { canAccessHiddenAiModels } from "@/lib/ai/model-visibility";
test.describe("hidden AI model visibility", () => {
test("uses the current database role when a JWT still contains an old role", () => {
expect(
canAccessHiddenAiModels({
sessionUserType: "regular",
databaseUserRole: "admin",
isHighVip: false,
})
).toBe(true);
});
test("allows Style Pro administrators and high VIP users", () => {
expect(
canAccessHiddenAiModels({ databaseUserRole: "agent_admin" })
).toBe(true);
expect(canAccessHiddenAiModels({ isHighVip: true })).toBe(true);
});
test("keeps hidden provider variants out of regular user selectors", () => {
expect(
canAccessHiddenAiModels({
sessionUserType: "regular",
databaseUserRole: "regular",
isHighVip: false,
})
).toBe(false);
});
});

View File

@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability";
function readSource(path: string) { function readSource(path: string) {
return readFileSync(resolve(process.cwd(), path), "utf8"); return readFileSync(resolve(process.cwd(), path), "utf8");
@ -28,18 +29,112 @@ test.describe("3D workspace and Style Pro regressions", () => {
expect(viewer).toContain("cloneSceneWithIndependentMaterials"); expect(viewer).toContain("cloneSceneWithIndependentMaterials");
expect(viewer).toContain("const clonedMaterial = material.clone()"); expect(viewer).toContain("const clonedMaterial = material.clone()");
expect(viewer).toContain("typedSourceTexture.clone()");
expect(viewer).toContain("clonedMaterialRecord[key] = clonedTexture");
expect(viewer).toContain("disposedTextures.add(texture)");
expect(viewer).toContain("texture.dispose()");
expect(viewer).toContain("<primitive dispose={null}");
expect(viewer).toContain( expect(viewer).toContain(
"configureMaterialTextures(clonedMaterial, node.geometry)" "configureMaterialTextures(clonedMaterial, node.geometry)"
); );
}); });
test("loads Qiniu-hosted GLB assets through the authenticated same-origin proxy", () => {
const assetUrl = readSource("lib/canvas/three-asset-url.ts");
const proxy = readSource("app/api/canvas/3d/model-proxy/route.ts");
for (const host of [
"yybb.top",
"qiniucs.com",
"qiniudn.com",
"clouddn.com",
"qbox.me",
]) {
expect(assetUrl).toContain(`\"${host}\"`);
expect(proxy).toContain(`\"${host}\"`);
}
});
test("does not block a completed Tripo task while permanent assets are archived", () => {
const route = readSource("app/api/canvas/3d/tasks/[taskId]/route.ts");
const page = readSource("app/canvas/[id]/model3d/page.tsx");
const imageNode = readSource("components/canvas/nodes/ImageNode.tsx");
expect(route).toContain("return { url: sourceUrl, pending: true }");
expect(route).toContain('String(taskStatus).trim().toLowerCase() === "success" && assetsPending');
expect(route).toContain('? "running"');
expect(route).toContain("assetsPending,");
expect(page).toContain("pollFailureStreakRef.current < 5");
expect(page).toContain("cancelled || pollInFlight");
expect(page).toContain("restoreRecoverableTaskForPolling");
expect(imageNode).toContain("CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES");
});
test("uses the same hidden-model access rule in Style Pro as canvas", () => { test("uses the same hidden-model access rule in Style Pro as canvas", () => {
const route = readSource("app/api/style-pro/model-options/route.ts"); const route = readSource("app/api/style-pro/model-options/route.ts");
const canvasModelsRoute = readSource("app/(chat)/api/models/route.ts");
expect(route).toContain("getUserById"); expect(route).toContain("getUserById");
expect(route).toContain('session.user.type === "admin"'); expect(route).toContain("canAccessHiddenAiModels");
expect(route).toContain("Boolean(currentUser?.isHighVip)"); expect(route).toContain("databaseUserRole: currentUser?.role");
expect(route).toContain("includeHidden: allowHiddenModels"); expect(route).toContain("includeHidden: allowHiddenModels");
expect(route).toContain('"Cache-Control": "private, no-store, max-age=0"');
expect(canvasModelsRoute).toContain("canAccessHiddenAiModels");
expect(canvasModelsRoute).toContain("databaseUserRole: currentUser?.role");
});
test("refreshes Style Pro models after an administrator changes the catalog", () => {
const workspace = readSource(
"components/style-pro/StyleProWorkspace.tsx"
);
expect(workspace).toContain("refreshInterval: 30_000");
expect(workspace).toContain("revalidateOnFocus: true");
expect(workspace).toContain("revalidateOnReconnect: true");
});
test("enforces Style Pro model availability on both display and execution", () => {
const options = readSource("lib/style-pro/model-options.ts");
const availability = readSource(
"lib/style-pro/model-availability.ts"
);
const referenceCapability = readSource(
"lib/ai/reference-image-capability.ts"
);
expect(options).toContain(".filter(isStyleProModelRunnable)");
expect(options).toContain("getStyleProModelAvailability(entry)");
expect(availability).toContain("modelSupportsReferenceImageInput");
expect(availability).toContain('availabilityLabel: "仅文生图"');
expect(availability).toContain('availabilityLabel: "暂不可用"');
expect(referenceCapability).toContain("GEMINI_TEXT_ONLY_IMAGE_MODEL_REGEX");
const task = readSource("lib/style-pro/task.ts");
expect(task).toContain("const runnableModels = await getStyleProImageModelEntries");
expect(task).toContain("当前不支持参考图,请重新选择");
});
test("keeps known text-only Gemini image aliases out of reference-image tools", () => {
expect(
modelSupportsReferenceImageInput(
{
modelId: "nano-banana-pro",
displayName: "BanA Pro",
supportsVision: true,
},
{ type: "gemini" }
)
).toBe(false);
expect(
modelSupportsReferenceImageInput(
{
modelId: "gemini-3-pro-image-preview",
displayName: "BanA Pro V",
supportsVision: true,
},
{ type: "bananaimage" }
)
).toBe(true);
}); });
test("opens the canvas conversation panel by default", () => { test("opens the canvas conversation panel by default", () => {