fix(ai): preserve realtime canvas and normalize model types
This commit is contained in:
parent
96c4b76250
commit
2ad794d6e1
@ -60,6 +60,16 @@ export interface Vip98kCatalogModel {
|
|||||||
invocation?: Vip98kInvocationContract;
|
invocation?: Vip98kInvocationContract;
|
||||||
taskTypes?: unknown;
|
taskTypes?: unknown;
|
||||||
task_types?: unknown;
|
task_types?: unknown;
|
||||||
|
releasedAt?: unknown;
|
||||||
|
released_at?: unknown;
|
||||||
|
releaseDate?: unknown;
|
||||||
|
release_date?: unknown;
|
||||||
|
releaseYear?: unknown;
|
||||||
|
release_year?: unknown;
|
||||||
|
publishedAt?: unknown;
|
||||||
|
published_at?: unknown;
|
||||||
|
launchDate?: unknown;
|
||||||
|
launch_date?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,9 +115,79 @@ const TYPE_SORT_ORDER: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const IMAGE_TOOL_MODEL_IDS = new Set([
|
const IMAGE_TOOL_MODEL_IDS = new Set([
|
||||||
|
"background-generator",
|
||||||
|
"clothing-segmentation",
|
||||||
|
"colorize",
|
||||||
|
"compress",
|
||||||
|
"enhance",
|
||||||
|
"face-swap-image",
|
||||||
|
"head-cutout",
|
||||||
|
"id-photo",
|
||||||
|
"image-expansion",
|
||||||
"image-upscaler",
|
"image-upscaler",
|
||||||
|
"photo-cleanup",
|
||||||
|
"remove-background",
|
||||||
|
"remove-object",
|
||||||
|
"remove-watermark",
|
||||||
|
"smart-crop",
|
||||||
|
"youchuan/v8.1/remove-background",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const VIDEO_CAPABILITY_KEYS = new Set([
|
||||||
|
"audio-to-video",
|
||||||
|
"first-frame-to-video",
|
||||||
|
"image-to-video",
|
||||||
|
"reference-to-video",
|
||||||
|
"start-end-frame-to-video",
|
||||||
|
"text-to-video",
|
||||||
|
"video-extend",
|
||||||
|
"video-to-video",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IMAGE_CAPABILITY_KEYS = new Set([
|
||||||
|
"image-to-image",
|
||||||
|
"reference-to-image",
|
||||||
|
"text-to-image",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IMAGE_TOOL_OPERATION_BY_MODEL_ID: Record<string, string> = {
|
||||||
|
"background-generator": "background-generation",
|
||||||
|
"clothing-segmentation": "segmentation",
|
||||||
|
colorize: "colorize",
|
||||||
|
compress: "compress",
|
||||||
|
enhance: "enhance",
|
||||||
|
"face-swap-image": "face-swap",
|
||||||
|
"head-cutout": "remove-background",
|
||||||
|
"id-photo": "id-photo",
|
||||||
|
"image-expansion": "extend",
|
||||||
|
"image-upscaler": "enhance",
|
||||||
|
"photo-cleanup": "cleanup",
|
||||||
|
"remove-background": "remove-background",
|
||||||
|
"remove-object": "remove-object",
|
||||||
|
"remove-watermark": "remove-watermark",
|
||||||
|
"smart-crop": "smart-crop",
|
||||||
|
"youchuan/v8.1/remove-background": "remove-background",
|
||||||
|
};
|
||||||
|
|
||||||
|
// The VIP98K catalog currently has no release-date field. Unknown image models stay
|
||||||
|
// available to backend routing but remain hidden until their 2026+ release is verified.
|
||||||
|
const VERIFIED_2026_IMAGE_MODEL_PATTERNS = [
|
||||||
|
/^nano-banana-2(?:$|[-/])/,
|
||||||
|
/^gpt-image-2(?:$|[-/])/,
|
||||||
|
/^alibaba\/wan-2\.(?:6|7)(?:$|[-/])/,
|
||||||
|
/^black-forest-labs\/flux-2(?:$|[-/])/,
|
||||||
|
/^bytedance\/seedream-v5(?:$|[.\-/])/,
|
||||||
|
/^google\/nano-banana-2(?:$|[-/])/,
|
||||||
|
/^hidream-o1-1\.5(?:$|[-/])/,
|
||||||
|
/^ideogram\/v4(?:$|[-/])/,
|
||||||
|
/^krea-2(?:$|[-/])/,
|
||||||
|
/^microsoft\/mai-image-2\.5(?:$|[-/])/,
|
||||||
|
/^nvidia\/cosmos-3(?:$|[-/])/,
|
||||||
|
/^openai\/gpt-image-2(?:$|[-/])/,
|
||||||
|
/^qwen\/qwen-image-2\.0(?:$|[-/])/,
|
||||||
|
/^youchuan\/v8\.1(?:$|[-/])/,
|
||||||
|
];
|
||||||
|
|
||||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||||
return value as Record<string, unknown>;
|
return value as Record<string, unknown>;
|
||||||
@ -148,6 +228,35 @@ function normalizeCatalogType(model: Vip98kCatalogModel): Vip98kCatalogModelType
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReleaseYear(model: Vip98kCatalogModel): number | null {
|
||||||
|
const candidates = [
|
||||||
|
model.releaseYear,
|
||||||
|
model.release_year,
|
||||||
|
model.releasedAt,
|
||||||
|
model.released_at,
|
||||||
|
model.releaseDate,
|
||||||
|
model.release_date,
|
||||||
|
model.publishedAt,
|
||||||
|
model.published_at,
|
||||||
|
model.launchDate,
|
||||||
|
model.launch_date,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const match = String(candidate || "").match(/(?:19|20)\d{2}/);
|
||||||
|
if (match) return Number(match[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datedModelId = getModelId(model).match(/(?:^|[^\d])((?:19|20)\d{2})\d{4}(?:[^\d]|$)/);
|
||||||
|
return datedModelId?.[1] ? Number(datedModelId[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVip98kImageModelVisible(model: Vip98kCatalogModel) {
|
||||||
|
const releaseYear = getReleaseYear(model);
|
||||||
|
if (releaseYear !== null) return releaseYear >= 2026;
|
||||||
|
const modelId = getModelId(model).toLowerCase();
|
||||||
|
return VERIFIED_2026_IMAGE_MODEL_PATTERNS.some((pattern) => pattern.test(modelId));
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
||||||
const catalogType = normalizeCatalogType(model);
|
const catalogType = normalizeCatalogType(model);
|
||||||
const modelId = getModelId(model).toLowerCase();
|
const modelId = getModelId(model).toLowerCase();
|
||||||
@ -160,10 +269,34 @@ function resolvePresetType(model: Vip98kCatalogModel): Vip98kCatalogModelType {
|
|||||||
return "model_3d";
|
return "model_3d";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (catalogType === "image" && IMAGE_TOOL_MODEL_IDS.has(modelId)) {
|
if (catalogType === "world") return "world";
|
||||||
|
|
||||||
|
if (catalogType === "image_tool" || IMAGE_TOOL_MODEL_IDS.has(modelId)) {
|
||||||
return "image_tool";
|
return "image_tool";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
catalogType === "video" ||
|
||||||
|
capabilities.some(
|
||||||
|
(capability) =>
|
||||||
|
VIDEO_CAPABILITY_KEYS.has(capability) ||
|
||||||
|
capability.endsWith("-to-video") ||
|
||||||
|
capability.startsWith("video-"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
catalogType === "image" ||
|
||||||
|
capabilities.some(
|
||||||
|
(capability) =>
|
||||||
|
IMAGE_CAPABILITY_KEYS.has(capability) || capability.endsWith("-to-image"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
|
||||||
return catalogType;
|
return catalogType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,7 +460,7 @@ function createImagePreset(
|
|||||||
type: "image",
|
type: "image",
|
||||||
supportsVision: supportsReferenceImages,
|
supportsVision: supportsReferenceImages,
|
||||||
supportsImageGen: true,
|
supportsImageGen: true,
|
||||||
isHidden: false,
|
isHidden: !isVip98kImageModelVisible(model),
|
||||||
capabilities: {
|
capabilities: {
|
||||||
provider: "vip98k",
|
provider: "vip98k",
|
||||||
kind: "image",
|
kind: "image",
|
||||||
@ -596,6 +729,7 @@ function createToolPreset(
|
|||||||
const inputModes = getInputModes(model);
|
const inputModes = getInputModes(model);
|
||||||
const outputModes = getOutputModes(model);
|
const outputModes = getOutputModes(model);
|
||||||
const isImageTool = type === "image_tool";
|
const isImageTool = type === "image_tool";
|
||||||
|
const toolOperation = IMAGE_TOOL_OPERATION_BY_MODEL_ID[modelId.toLowerCase()] || type;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
modelId,
|
modelId,
|
||||||
@ -614,12 +748,14 @@ function createToolPreset(
|
|||||||
inputModes,
|
inputModes,
|
||||||
outputModes,
|
outputModes,
|
||||||
supportedModes: getCapabilities(model),
|
supportedModes: getCapabilities(model),
|
||||||
|
toolOperation,
|
||||||
vip98k: getVip98kMetadata(model, type),
|
vip98k: getVip98kMetadata(model, type),
|
||||||
},
|
},
|
||||||
defaultParams: {
|
defaultParams: {
|
||||||
provider: "vip98k",
|
provider: "vip98k",
|
||||||
kind: type.replace(/_/g, "-"),
|
kind: type.replace(/_/g, "-"),
|
||||||
endpoint: modelId,
|
endpoint: modelId,
|
||||||
|
toolOperation,
|
||||||
taskApi: type === "audio" ? "openai-compatible" : "external-task",
|
taskApi: type === "audio" ? "openai-compatible" : "external-task",
|
||||||
},
|
},
|
||||||
imageGenConfig: null,
|
imageGenConfig: null,
|
||||||
|
|||||||
@ -28,6 +28,9 @@ const REQUIRED_CAPABILITY_MODELS = [
|
|||||||
"tencent/hunyuan3d-pro/text-to-3d",
|
"tencent/hunyuan3d-pro/text-to-3d",
|
||||||
"tencent/hunyuan3d-pro/image-to-3d",
|
"tencent/hunyuan3d-pro/image-to-3d",
|
||||||
] as const;
|
] as const;
|
||||||
|
const PROTECTED_SYSTEM_SETTING_KEYS = new Set([
|
||||||
|
"realtime_canvas_config",
|
||||||
|
]);
|
||||||
|
|
||||||
type ProviderRow = {
|
type ProviderRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -45,6 +48,7 @@ type ModelRow = {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
supportsVision: boolean;
|
supportsVision: boolean;
|
||||||
supportsImageGen: boolean;
|
supportsImageGen: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
capabilities: unknown;
|
capabilities: unknown;
|
||||||
imageGenConfig: unknown;
|
imageGenConfig: unknown;
|
||||||
};
|
};
|
||||||
@ -224,6 +228,12 @@ async function main() {
|
|||||||
providers: sortById(falProviders),
|
providers: sortById(falProviders),
|
||||||
models: sortById(models.filter((model) => falProviderIds.has(model.providerId))),
|
models: sortById(models.filter((model) => falProviderIds.has(model.providerId))),
|
||||||
});
|
});
|
||||||
|
const protectedSettingsBefore = await sql<{ key: string; value: unknown }[]>`
|
||||||
|
select key, value from "SystemSettings"
|
||||||
|
where key = any(${Array.from(PROTECTED_SYSTEM_SETTING_KEYS)}::text[])
|
||||||
|
order by key
|
||||||
|
`;
|
||||||
|
const protectedSettingsFingerprintBefore = stableHash(protectedSettingsBefore);
|
||||||
const removableProviders = providers.filter(
|
const removableProviders = providers.filter(
|
||||||
(provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
|
(provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
|
||||||
);
|
);
|
||||||
@ -367,6 +377,7 @@ async function main() {
|
|||||||
select id, key, value from "SystemSettings"
|
select id, key, value from "SystemSettings"
|
||||||
`;
|
`;
|
||||||
for (const setting of settings) {
|
for (const setting of settings) {
|
||||||
|
if (PROTECTED_SYSTEM_SETTING_KEYS.has(setting.key)) continue;
|
||||||
let nextValue = rewriteSettingValue({
|
let nextValue = rewriteSettingValue({
|
||||||
value: setting.value,
|
value: setting.value,
|
||||||
oldProviders: oldProviderMap,
|
oldProviders: oldProviderMap,
|
||||||
@ -415,6 +426,36 @@ async function main() {
|
|||||||
if (falFingerprintAfter !== falFingerprintBefore) {
|
if (falFingerprintAfter !== falFingerprintBefore) {
|
||||||
throw new Error("FAL provider/model fingerprint changed; restore the verified database backup");
|
throw new Error("FAL provider/model fingerprint changed; restore the verified database backup");
|
||||||
}
|
}
|
||||||
|
const protectedSettingsAfter = await sql<{ key: string; value: unknown }[]>`
|
||||||
|
select key, value from "SystemSettings"
|
||||||
|
where key = any(${Array.from(PROTECTED_SYSTEM_SETTING_KEYS)}::text[])
|
||||||
|
order by key
|
||||||
|
`;
|
||||||
|
if (stableHash(protectedSettingsAfter) !== protectedSettingsFingerprintBefore) {
|
||||||
|
throw new Error("Realtime canvas configuration changed; restore the verified database backup");
|
||||||
|
}
|
||||||
|
const vipProviderIds = new Set(
|
||||||
|
remainingProviders
|
||||||
|
.filter((provider) => provider.type === "vip98k")
|
||||||
|
.map((provider) => provider.id),
|
||||||
|
);
|
||||||
|
const invalidVipModels = remainingModels.filter((model) => {
|
||||||
|
if (!vipProviderIds.has(model.providerId)) return false;
|
||||||
|
const capabilities = model.capabilities as Record<string, unknown> | null;
|
||||||
|
const kind = String(capabilities?.kind || "").trim().toLowerCase();
|
||||||
|
if (["video", "model-3d", "world", "chat"].includes(kind)) {
|
||||||
|
return model.supportsImageGen;
|
||||||
|
}
|
||||||
|
return kind === "image-tool" && !model.isHidden;
|
||||||
|
});
|
||||||
|
if (invalidVipModels.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`VIP98K model classification invalid: ${invalidVipModels
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((model) => model.modelId)
|
||||||
|
.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const invalidTypes = remainingProviders.filter(
|
const invalidTypes = remainingProviders.filter(
|
||||||
(provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
|
(provider) => !KEEP_PROVIDER_TYPES.has(provider.type),
|
||||||
);
|
);
|
||||||
@ -433,6 +474,7 @@ async function main() {
|
|||||||
),
|
),
|
||||||
).length,
|
).length,
|
||||||
falFingerprintUnchanged: true,
|
falFingerprintUnchanged: true,
|
||||||
|
realtimeCanvasConfigUnchanged: true,
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
} from "@/lib/ai/model-classification";
|
} from "@/lib/ai/model-classification";
|
||||||
|
import { buildVip98kModelPresets } from "@/lib/ai/vip98k-models";
|
||||||
|
|
||||||
const atlasHunyuan3D = {
|
const atlasHunyuan3D = {
|
||||||
model: "tencent/hunyuan3d-pro/text-to-3d",
|
model: "tencent/hunyuan3d-pro/text-to-3d",
|
||||||
@ -305,4 +306,100 @@ test.describe("AI model media classification", () => {
|
|||||||
watermark: true,
|
watermark: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
test("normalizes VIP98K media and tool types before they reach the frontend", () => {
|
||||||
|
const presets = buildVip98kModelPresets([
|
||||||
|
{
|
||||||
|
modelId: "mislabelled-video",
|
||||||
|
displayName: "Mislabelled video",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["text_to_video"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "enhance",
|
||||||
|
displayName: "Enhance",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["image_processing"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "image-expansion",
|
||||||
|
displayName: "Extend",
|
||||||
|
type: "image_tool",
|
||||||
|
capabilities: ["outpainting"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "remove-background",
|
||||||
|
displayName: "Remove background",
|
||||||
|
type: "image_tool",
|
||||||
|
capabilities: ["image_processing"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "multimodal-model",
|
||||||
|
displayName: "Multimodal model",
|
||||||
|
type: "model",
|
||||||
|
capabilities: ["multimodal"],
|
||||||
|
inputModes: ["text", "image"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(presets.find((item) => item.modelId === "mislabelled-video")).toMatchObject({
|
||||||
|
type: "video",
|
||||||
|
supportsImageGen: false,
|
||||||
|
});
|
||||||
|
expect(presets.find((item) => item.modelId === "enhance")).toMatchObject({
|
||||||
|
type: "image_tool",
|
||||||
|
isHidden: true,
|
||||||
|
capabilities: { toolOperation: "enhance" },
|
||||||
|
});
|
||||||
|
expect(presets.find((item) => item.modelId === "image-expansion")).toMatchObject({
|
||||||
|
type: "image_tool",
|
||||||
|
capabilities: { toolOperation: "extend" },
|
||||||
|
});
|
||||||
|
expect(presets.find((item) => item.modelId === "remove-background")).toMatchObject({
|
||||||
|
type: "image_tool",
|
||||||
|
capabilities: { toolOperation: "remove-background" },
|
||||||
|
});
|
||||||
|
expect(presets.find((item) => item.modelId === "multimodal-model")).toMatchObject({
|
||||||
|
type: "model",
|
||||||
|
supportsVision: true,
|
||||||
|
supportsImageGen: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only shows VIP98K image models verified as released in 2026 or later", () => {
|
||||||
|
const presets = buildVip98kModelPresets([
|
||||||
|
{
|
||||||
|
modelId: "black-forest-labs/flux-dev",
|
||||||
|
displayName: "Flux Dev",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["text_to_image"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "google/nano-banana-2/text-to-image",
|
||||||
|
displayName: "Nano Banana 2",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["text_to_image"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "future-image-with-date",
|
||||||
|
displayName: "Future image",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["text_to_image"],
|
||||||
|
releasedAt: "2027-02-01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
modelId: "old-image-with-date",
|
||||||
|
displayName: "Old image",
|
||||||
|
type: "image",
|
||||||
|
capabilities: ["text_to_image"],
|
||||||
|
releasedAt: "2025-12-15",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(presets.find((item) => item.modelId === "black-forest-labs/flux-dev")?.isHidden).toBe(true);
|
||||||
|
expect(
|
||||||
|
presets.find((item) => item.modelId === "google/nano-banana-2/text-to-image")?.isHidden,
|
||||||
|
).toBe(false);
|
||||||
|
expect(presets.find((item) => item.modelId === "future-image-with-date")?.isHidden).toBe(false);
|
||||||
|
expect(presets.find((item) => item.modelId === "old-image-with-date")?.isHidden).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user