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 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>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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" ||
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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;
|
/^(?: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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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,
|
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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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 { 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 };
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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 { 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", () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user