Compare commits
9 Commits
main
...
codex/prod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8878f53bdd | ||
|
|
c0a155ea80 | ||
|
|
fee4e056ac | ||
|
|
9d14e71fc1 | ||
|
|
04ea2da58f | ||
|
|
a3e3938f7b | ||
|
|
cd1639b4d7 | ||
|
|
667bba8f53 | ||
|
|
e7182e7faa |
@ -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,
|
||||
@ -54,6 +55,7 @@ import {
|
||||
|
||||
export function LocalAiImagePipelineSections({
|
||||
modelsData,
|
||||
providersData,
|
||||
rmbgConfig,
|
||||
setRmbgConfig,
|
||||
enhanceConfig,
|
||||
@ -114,8 +116,21 @@ 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 providerNamesById = new Map(
|
||||
(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) => {
|
||||
if (controlId === "creativity") {
|
||||
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
|
||||
@ -1427,7 +1442,7 @@ export function LocalAiImagePipelineSections({
|
||||
<LocalAiSectionShell
|
||||
description={
|
||||
<>
|
||||
配置无限画布图片“局部编辑”和“比例调整”后端优先使用的生图模型。为空时仍沿用旧的 AI详情默认模型或用户当前选择,避免影响已有流程。
|
||||
配置无限画布图片“局部编辑”和“比例调整”使用的生图模型。指定后由后台强制使用该供应商的精确模型,不再被用户前端当前选择覆盖;为空时才沿用旧默认或用户当前选择。
|
||||
</>
|
||||
}
|
||||
expanded={isLocalAiSectionExpanded("canvas-local-edit")}
|
||||
@ -1465,7 +1480,9 @@ export function LocalAiImagePipelineSections({
|
||||
modelId: value === "__none__" ? "" : value,
|
||||
}))
|
||||
}
|
||||
value={canvasLocalEditConfig.modelId || "__none__"}
|
||||
value={resolveCanvasLocalEditSelectValue(
|
||||
canvasLocalEditConfig.modelId
|
||||
)}
|
||||
>
|
||||
<SelectTrigger id="canvasLocalEditModel">
|
||||
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
|
||||
@ -1474,7 +1491,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 +1516,9 @@ export function LocalAiImagePipelineSections({
|
||||
scaleAdjustModelId: value === "__none__" ? "" : value,
|
||||
}))
|
||||
}
|
||||
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
|
||||
value={resolveCanvasLocalEditSelectValue(
|
||||
canvasLocalEditConfig.scaleAdjustModelId
|
||||
)}
|
||||
>
|
||||
<SelectTrigger id="canvasScaleAdjustModel">
|
||||
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
|
||||
@ -1508,7 +1527,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>
|
||||
))
|
||||
|
||||
@ -307,6 +307,7 @@ export type LayerSplitConfig = {
|
||||
|
||||
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
modelsData?: AdminModelsData;
|
||||
providersData?: AdminProvidersData;
|
||||
rmbgConfig: RmbgConfig;
|
||||
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
|
||||
enhanceConfig: EnhanceConfig;
|
||||
|
||||
@ -18449,6 +18449,7 @@ export function AdminSettingsClient() {
|
||||
layerSplitConfig={layerSplitConfig}
|
||||
modelsData={modelsData}
|
||||
pointsSystemConfig={pointsSystemConfig}
|
||||
providersData={providersData}
|
||||
renderEnhanceProPresetEditor={renderEnhanceProPresetEditor}
|
||||
rmbgConfig={rmbgConfig}
|
||||
savedEnhanceProKeyRows={savedEnhanceProKeyRows}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
getUserById,
|
||||
} from "@/lib/db/queries";
|
||||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||||
import { canAccessHiddenAiModels } from "@/lib/ai/model-visibility";
|
||||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
@ -146,8 +147,11 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
const allowHiddenModels = canAccessHiddenAiModels({
|
||||
sessionUserType: session.user.type,
|
||||
databaseUserRole: currentUser?.role,
|
||||
isHighVip: currentUser?.isHighVip,
|
||||
});
|
||||
const includeUnavailable =
|
||||
new URL(request.url).searchParams.get("includeUnavailable") === "1";
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -14,6 +14,11 @@ const ALLOWED_MODEL_HOST_DOMAINS = [
|
||||
"worldlabs.ai",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"yybb.top",
|
||||
"qiniucs.com",
|
||||
"qiniudn.com",
|
||||
"clouddn.com",
|
||||
"qbox.me",
|
||||
"blob.vercel-storage.com",
|
||||
"aliyuncs.com",
|
||||
"amazonaws.com",
|
||||
|
||||
@ -28,6 +28,11 @@ type RemoteAssetArchiveCacheItem = {
|
||||
archivedUrl: string;
|
||||
};
|
||||
|
||||
type RemoteAssetArchiveResult = {
|
||||
url: string | null;
|
||||
pending: boolean;
|
||||
};
|
||||
|
||||
function isLikelyArchivedStorageUrl(parsed: URL) {
|
||||
const hostname = parsed.hostname.toLowerCase();
|
||||
return hostname.includes("blob.vercel-storage.com");
|
||||
@ -120,23 +125,22 @@ async function archiveTripoModelIfNeeded(params: {
|
||||
sourceUrl?: string | null;
|
||||
filenamePrefix: string;
|
||||
fallbackExtension: string;
|
||||
}) {
|
||||
}): Promise<RemoteAssetArchiveResult> {
|
||||
const sourceUrl = String(params.sourceUrl || "").trim();
|
||||
if (!shouldArchiveRemoteAssetUrl(sourceUrl)) {
|
||||
return sourceUrl || null;
|
||||
return { url: sourceUrl || null, pending: false };
|
||||
}
|
||||
|
||||
const cache = getTripoArchiveCache();
|
||||
const cached = cache.get(params.cacheKey);
|
||||
if (cached && cached.sourceUrl === sourceUrl) {
|
||||
return cached.archivedUrl;
|
||||
return { url: cached.archivedUrl, pending: false };
|
||||
}
|
||||
|
||||
const inflight = getTripoArchiveInflight();
|
||||
const existingTask = inflight.get(params.cacheKey);
|
||||
if (existingTask) {
|
||||
const result = await existingTask;
|
||||
return result || sourceUrl;
|
||||
return { url: sourceUrl, pending: true };
|
||||
}
|
||||
|
||||
const archiveTask = (async () => {
|
||||
@ -190,15 +194,19 @@ async function archiveTripoModelIfNeeded(params: {
|
||||
sourceUrl,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
cache.set(params.cacheKey, {
|
||||
sourceUrl,
|
||||
archivedUrl: sourceUrl,
|
||||
});
|
||||
return sourceUrl;
|
||||
} finally {
|
||||
inflight.delete(params.cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(params.cacheKey, archiveTask);
|
||||
const archivedUrl = await archiveTask;
|
||||
return archivedUrl || sourceUrl;
|
||||
void archiveTask;
|
||||
return { url: sourceUrl, pending: true };
|
||||
}
|
||||
|
||||
function replaceArchivedUrls(value: unknown, replacements: Map<string, string>): unknown {
|
||||
@ -346,20 +354,23 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
: 0;
|
||||
const modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
|
||||
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
|
||||
const stableModelUrl = await archiveTripoModelIfNeeded({
|
||||
const modelArchive = await archiveTripoModelIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
cacheKey: `tripo:${normalizedTaskId}:model`,
|
||||
sourceUrl: modelUrl,
|
||||
filenamePrefix: `tripo-model-${normalizedTaskId}`,
|
||||
fallbackExtension: "glb",
|
||||
});
|
||||
const stablePreviewImageUrl = await archiveTripoModelIfNeeded({
|
||||
const previewArchive = await archiveTripoModelIfNeeded({
|
||||
taskId: normalizedTaskId,
|
||||
cacheKey: `tripo:${normalizedTaskId}:preview`,
|
||||
sourceUrl: previewImageUrl,
|
||||
filenamePrefix: `tripo-preview-${normalizedTaskId}`,
|
||||
fallbackExtension: "jpg",
|
||||
});
|
||||
const stableModelUrl = modelArchive.url;
|
||||
const stablePreviewImageUrl = previewArchive.url;
|
||||
const assetsPending = modelArchive.pending || previewArchive.pending;
|
||||
const replacements = new Map<string, string>();
|
||||
if (modelUrl && stableModelUrl && modelUrl !== stableModelUrl) {
|
||||
replacements.set(modelUrl, stableModelUrl);
|
||||
@ -376,15 +387,23 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
? replaceArchivedUrls(output, replacements)
|
||||
: output;
|
||||
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({
|
||||
success: true,
|
||||
data: {
|
||||
taskId: result.data.task_id,
|
||||
type: result.data.type,
|
||||
status: result.data.status,
|
||||
progress: result.data.progress ?? null,
|
||||
status: responseStatus,
|
||||
progress: responseProgress,
|
||||
output: archivedOutput,
|
||||
modelUrl: stableModelUrl,
|
||||
previewImageUrl: stablePreviewImageUrl,
|
||||
@ -392,6 +411,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
providerId: config.providerId || null,
|
||||
providerName: config.providerName || null,
|
||||
storageArchivedAt,
|
||||
assetsPending,
|
||||
sourceModelUrl:
|
||||
modelUrl && stableModelUrl && modelUrl !== stableModelUrl
|
||||
? modelUrl
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { canAccessHiddenAiModels } from "@/lib/ai/model-visibility";
|
||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
@ -27,8 +28,11 @@ export async function GET() {
|
||||
}
|
||||
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
const allowHiddenModels = canAccessHiddenAiModels({
|
||||
sessionUserType: session.user.type,
|
||||
databaseUserRole: currentUser?.role,
|
||||
isHighVip: currentUser?.isHighVip,
|
||||
});
|
||||
|
||||
const [displayEntries, entries, realisticModelEntries, setting] =
|
||||
await Promise.all([
|
||||
@ -67,14 +71,21 @@ export async function GET() {
|
||||
})
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
realisticUnitCost,
|
||||
models: sortStyleProImageModelEntries(displayEntries).map(
|
||||
toPublicStyleProModelOption
|
||||
),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
realisticUnitCost,
|
||||
models: sortStyleProImageModelEntries(displayEntries).map(
|
||||
toPublicStyleProModelOption
|
||||
),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store, max-age=0",
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Style Pro Model Options] Failed:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@ -285,6 +285,23 @@ const FINAL_TASK_STATUS = new Set<Model3DTaskStatus>([
|
||||
"cancelled",
|
||||
"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>([
|
||||
"text",
|
||||
"image",
|
||||
@ -850,6 +867,7 @@ export default function CanvasModel3DPage() {
|
||||
);
|
||||
|
||||
const pollAttemptsRef = useRef(0);
|
||||
const pollFailureStreakRef = useRef(0);
|
||||
const multiViewSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||
const batchImageSlotsRef = useRef<MultiViewSlot[]>([]);
|
||||
|
||||
@ -1186,6 +1204,7 @@ export default function CanvasModel3DPage() {
|
||||
let normalized = list
|
||||
.map((item) => mapApiEntityToTaskRecord(item))
|
||||
.filter((item): item is TaskRecord => Boolean(item))
|
||||
.map(restoreRecoverableTaskForPolling)
|
||||
.slice(0, TASK_HISTORY_LIMIT);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
@ -1418,34 +1437,39 @@ export default function CanvasModel3DPage() {
|
||||
|
||||
pollAttemptsRef.current = 0;
|
||||
let cancelled = false;
|
||||
let pollInFlight = false;
|
||||
|
||||
const runPoll = async () => {
|
||||
if (cancelled) return;
|
||||
if (cancelled || pollInFlight) return;
|
||||
pollInFlight = true;
|
||||
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 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 status = normalizeTaskStatus(result.data.status);
|
||||
const worldResultData = isWorldTask
|
||||
? (result.data as NonNullable<
|
||||
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 = {
|
||||
...nextRecord,
|
||||
status: "failed",
|
||||
@ -1510,6 +1534,9 @@ export default function CanvasModel3DPage() {
|
||||
setCurrentTask(timeoutRecord);
|
||||
upsertTaskHistory(timeoutRecord);
|
||||
toast.error("任务轮询超时,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -300,6 +300,7 @@ function configureMaterialTextures(
|
||||
|
||||
function cloneSceneWithIndependentMaterials(scene: Object3D) {
|
||||
const clonedScene = scene.clone(true);
|
||||
const clonedTextures = new Map<THREE.Texture, THREE.Texture>();
|
||||
|
||||
clonedScene.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
@ -309,6 +310,24 @@ function cloneSceneWithIndependentMaterials(scene: Object3D) {
|
||||
: [node.material];
|
||||
const clonedMaterials = sourceMaterials.map((material) => {
|
||||
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);
|
||||
return clonedMaterial;
|
||||
});
|
||||
@ -407,12 +426,20 @@ function ModelScene({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const disposedTextures = new Set<THREE.Texture>();
|
||||
scene.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
const materials = Array.isArray(node.material)
|
||||
? node.material
|
||||
: [node.material];
|
||||
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();
|
||||
}
|
||||
});
|
||||
@ -437,7 +464,7 @@ function ModelScene({
|
||||
|
||||
return (
|
||||
<group rotation={modelRotation || [0, 0, 0]}>
|
||||
<primitive object={scene} scale={1} />
|
||||
<primitive dispose={null} object={scene} scale={1} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@ -645,6 +645,7 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
|
||||
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
|
||||
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
|
||||
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_LIMIT = 80;
|
||||
const HYDRATED_IMAGE_TASK_RECOVERY_DELAY_MS = 15 * 1000;
|
||||
@ -7107,6 +7108,7 @@ export function ImageNode({
|
||||
sourceImageUrl: string;
|
||||
previewImageUrl?: string | null;
|
||||
}) => {
|
||||
let consecutiveQueryFailures = 0;
|
||||
for (let attempt = 0; attempt < CANVAS_3D_MAX_POLL_ATTEMPTS; attempt += 1) {
|
||||
await sleep(CANVAS_3D_POLL_INTERVAL_MS);
|
||||
const result = await model3DGenerationService.getTask(params.taskId, {
|
||||
@ -7116,6 +7118,12 @@ export function ImageNode({
|
||||
const progress = result.data?.progress ?? null;
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
consecutiveQueryFailures += 1;
|
||||
if (
|
||||
consecutiveQueryFailures < CANVAS_3D_MAX_CONSECUTIVE_QUERY_FAILURES
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const error = result.error || "3D 模型任务查询失败";
|
||||
updateNode(params.nodeId, {
|
||||
data: {
|
||||
@ -7155,6 +7163,7 @@ export function ImageNode({
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
consecutiveQueryFailures = 0;
|
||||
|
||||
if (
|
||||
status === "failed" ||
|
||||
|
||||
@ -1001,7 +1001,12 @@ export function StyleProWorkspace({
|
||||
const { data: configData, isLoading: configLoading } =
|
||||
useSWR<StyleProConfigResponse>("/api/style-pro/config", fetcher);
|
||||
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 modelOptions = useMemo(
|
||||
() => (modelsData?.models || []).filter((model) => Boolean(model.id)),
|
||||
|
||||
@ -178,6 +178,18 @@ export function isImageGenerationModelDescriptor(params: {
|
||||
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: {
|
||||
providerType?: string | null;
|
||||
modelId?: string | null;
|
||||
|
||||
19
lib/ai/model-visibility.ts
Normal file
19
lib/ai/model-visibility.ts
Normal 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())
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
const REFERENCE_IMAGE_MODE_REGEX =
|
||||
/^(?: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) {
|
||||
if (!source || typeof source !== "object" || !(key in source)) return "";
|
||||
@ -86,6 +87,14 @@ export function modelLacksReferenceImageInputSupport(
|
||||
provider?: ProviderDescriptor | null
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -7,6 +7,11 @@ const PROXY_HOST_KEYWORDS = [
|
||||
"marble",
|
||||
"s3.1080p.hk",
|
||||
"img.yydd.ai",
|
||||
"yybb.top",
|
||||
"qiniucs.com",
|
||||
"qiniudn.com",
|
||||
"clouddn.com",
|
||||
"qbox.me",
|
||||
"blob.vercel-storage.com",
|
||||
"aliyuncs.com",
|
||||
"amazonaws.com",
|
||||
|
||||
51
lib/style-pro/model-availability.ts
Normal file
51
lib/style-pro/model-availability.ts
Normal 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;
|
||||
}
|
||||
@ -6,13 +6,17 @@ import {
|
||||
getImageModelCreditDiscountLabel,
|
||||
} from "@/lib/ai/image-model-pricing";
|
||||
import {
|
||||
getAiModelFrontendAvailability,
|
||||
type ModelFrontendAvailabilityStatus,
|
||||
} from "@/lib/ai/model-frontend-availability";
|
||||
import { isStillImageGenerationModelDescriptor } from "@/lib/ai/model-classification";
|
||||
import {
|
||||
getEnabledAiModelsWithProvider,
|
||||
getFrontendDisplayAiModelsWithProvider,
|
||||
} from "@/lib/db/queries";
|
||||
import {
|
||||
getStyleProModelAvailability,
|
||||
isStyleProModelRunnable,
|
||||
} from "./model-availability";
|
||||
|
||||
export type StyleProModelEntry = Awaited<
|
||||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||||
@ -37,127 +41,15 @@ export type PublicStyleProModelOption = {
|
||||
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(
|
||||
entry: StyleProModelEntry | StyleProDisplayModelEntry
|
||||
) {
|
||||
if (!entry.model.supportsImageGen) return false;
|
||||
if (isExplicitImageGenerationModel(entry)) return true;
|
||||
return !isVideoLikeModel(entry);
|
||||
return isStillImageGenerationModelDescriptor({
|
||||
providerType: entry.provider.type,
|
||||
modelId: entry.model.modelId,
|
||||
capabilities: entry.model.capabilities,
|
||||
supportsImageGen: entry.model.supportsImageGen,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStyleProImageModelEntries(options?: {
|
||||
@ -167,7 +59,9 @@ export async function getStyleProImageModelEntries(options?: {
|
||||
false,
|
||||
options?.includeHidden === true
|
||||
);
|
||||
return models.filter(isStyleProImageModel);
|
||||
return models
|
||||
.filter(isStyleProImageModel)
|
||||
.filter(isStyleProModelRunnable);
|
||||
}
|
||||
|
||||
export async function getStyleProImageModelDisplayEntries(options?: {
|
||||
@ -216,7 +110,7 @@ export function toPublicStyleProModelOption(
|
||||
): PublicStyleProModelOption {
|
||||
const imageGenConfig = (entry.model.imageGenConfig ||
|
||||
null) as DiscountableImageGenConfigLike | null;
|
||||
const availability = getAiModelFrontendAvailability(entry.model);
|
||||
const availability = getStyleProModelAvailability(entry);
|
||||
return {
|
||||
id: entry.model.id,
|
||||
modelId: entry.model.modelId,
|
||||
|
||||
@ -11,6 +11,10 @@ import type {
|
||||
StyleProCustomInputMap,
|
||||
StyleProSelectionMap,
|
||||
} from "./config";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
} from "./model-options";
|
||||
|
||||
export type StyleProTaskSelectedOption = {
|
||||
categoryId: string;
|
||||
@ -266,6 +270,18 @@ export async function executeStyleProTask(params: {
|
||||
userId: string;
|
||||
}) {
|
||||
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 imageSize = request.prompts.imageSize || "2K";
|
||||
const negativePrompt = request.prompts.negativePrompt || undefined;
|
||||
|
||||
@ -13,6 +13,7 @@ export default defineConfig({
|
||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/model-visibility\.test\.ts/,
|
||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||
],
|
||||
fullyParallel: true,
|
||||
|
||||
471
scripts/storage/migrate-historical-generated-images.ts
Normal file
471
scripts/storage/migrate-historical-generated-images.ts
Normal 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);
|
||||
});
|
||||
@ -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,44 @@ 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("providerNamesById.get(model.providerId)");
|
||||
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 };
|
||||
|
||||
|
||||
@ -1,8 +1,38 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
isImageGenerationModelDescriptor,
|
||||
isStillImageGenerationModelDescriptor,
|
||||
isVideoModelDescriptor,
|
||||
} 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("keeps multi-reference BananaImage models classified as image models", () => {
|
||||
@ -32,4 +62,55 @@ test.describe("model classification", () => {
|
||||
})
|
||||
).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);
|
||||
});
|
||||
});
|
||||
|
||||
31
tests/routes/model-visibility.test.ts
Normal file
31
tests/routes/model-visibility.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { modelSupportsReferenceImageInput } from "@/lib/ai/reference-image-capability";
|
||||
|
||||
function readSource(path: string) {
|
||||
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("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(
|
||||
"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", () => {
|
||||
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('session.user.type === "admin"');
|
||||
expect(route).toContain("Boolean(currentUser?.isHighVip)");
|
||||
expect(route).toContain("canAccessHiddenAiModels");
|
||||
expect(route).toContain("databaseUserRole: currentUser?.role");
|
||||
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", () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user