Compare commits

...

9 Commits

27 changed files with 1142 additions and 193 deletions

View File

@ -41,6 +41,7 @@ import {
type EnhanceProKreaModel,
type EnhanceProPreset,
} from "@/lib/enhance-pro/config";
import { resolveCanvasLocalEditModelRef } from "@/lib/canvas/local-edit-config";
import { cn } from "@/lib/utils";
import type {
EnhanceProKeyRow,
@ -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>
))

View File

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

View File

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

View File

@ -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";

View File

@ -56,6 +56,7 @@ import {
import {
CANVAS_LOCAL_EDIT_CONFIG_KEY,
normalizeCanvasLocalEditConfig,
resolveCanvasLocalEditModelRef,
} from "@/lib/canvas/local-edit-config";
import {
DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY,
@ -69,6 +70,7 @@ import {
STYLE_PRO_CONFIG_KEY,
normalizeStyleProConfig,
} from "@/lib/style-pro/config";
import { getStyleProImageModelEntries } from "@/lib/style-pro/model-options";
import {
EXTERNAL_IMAGE_API_CONFIG_KEY,
normalizeExternalImageApiConfig,
@ -380,7 +382,36 @@ export async function POST(request: Request) {
}
if (key === CANVAS_LOCAL_EDIT_CONFIG_KEY) {
nextValue = normalizeCanvasLocalEditConfig(value);
const normalizedValue = normalizeCanvasLocalEditConfig(value);
const imageModels = (
await getStyleProImageModelEntries({ includeHidden: true })
).map(({ model }) => ({ id: model.id, modelId: model.modelId }));
const localEditModel = resolveCanvasLocalEditModelRef(
imageModels,
normalizedValue.modelId
);
const scaleAdjustModel = resolveCanvasLocalEditModelRef(
imageModels,
normalizedValue.scaleAdjustModelId
);
if (normalizedValue.modelId && !localEditModel) {
return new ChatSDKError(
"bad_request:api",
"普通局部编辑模型不存在、已停用或不支持生图"
).toResponse();
}
if (normalizedValue.scaleAdjustModelId && !scaleAdjustModel) {
return new ChatSDKError(
"bad_request:api",
"比例调整模型不存在、已停用或不支持生图"
).toResponse();
}
nextValue = {
modelId: localEditModel?.id || "",
scaleAdjustModelId: scaleAdjustModel?.id || "",
};
}
if (key === DINGTALK_ROBOT_NOTIFICATION_CONFIG_KEY) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
};

View File

@ -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>
);
}

View File

@ -645,6 +645,7 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
const 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" ||

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ const BFL_REFERENCE_IMAGE_MODEL_REGEX =
/^(?:bfl\/)?(?:flux-2-(?:max|flex|pro(?:-preview)?|klein-(?:4b|9b)(?:-preview)?)|flux-kontext-(?:pro|max))$/i;
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;
}

View File

@ -5,6 +5,11 @@ export type CanvasLocalEditConfig = {
scaleAdjustModelId: string;
};
export type CanvasLocalEditModelIdentity = {
id: string;
modelId: string;
};
export function defaultCanvasLocalEditConfig(): CanvasLocalEditConfig {
return {
modelId: "",
@ -16,6 +21,22 @@ function normalizeModelRef(value: unknown) {
return String(value || "").trim().slice(0, 128);
}
export function resolveCanvasLocalEditModelRef<
T extends CanvasLocalEditModelIdentity,
>(models: T[], value: unknown): T | null {
const modelRef = normalizeModelRef(value);
if (!modelRef) return null;
// New settings persist the database UUID so identically named runtime
// models from different providers cannot be confused. Keep modelId lookup
// as a compatibility path for settings saved before UUID persistence.
return (
models.find((model) => model.id === modelRef) ||
models.find((model) => model.modelId === modelRef) ||
null
);
}
export function normalizeCanvasLocalEditConfig(
value: unknown
): CanvasLocalEditConfig {

View File

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

View File

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

View File

@ -6,13 +6,17 @@ import {
getImageModelCreditDiscountLabel,
} 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,

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@ import { resolve } from "node:path";
import { hasScaleAdjustTargetChanged } from "../../components/canvas/nodes/image-node/image-node-local-edit";
import { 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 };

View File

@ -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);
});
});

View File

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

View File

@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { 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", () => {