fix: classify media models and open canvas chat
This commit is contained in:
parent
5e46ea698c
commit
42a42e496d
@ -168,6 +168,7 @@ import {
|
|||||||
getImageSizeCreditMultiplier,
|
getImageSizeCreditMultiplier,
|
||||||
} from "@/lib/ai/image-model-pricing";
|
} from "@/lib/ai/image-model-pricing";
|
||||||
import {
|
import {
|
||||||
|
is3DModelDescriptor,
|
||||||
isImageGenerationModelDescriptor,
|
isImageGenerationModelDescriptor,
|
||||||
isVideoModelDescriptor,
|
isVideoModelDescriptor,
|
||||||
} from "@/lib/ai/model-classification";
|
} from "@/lib/ai/model-classification";
|
||||||
@ -1578,7 +1579,7 @@ const PROVIDER_TYPES = [
|
|||||||
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
{ value: "openai-image", label: "OpenAI 图片兼容" },
|
||||||
{ value: "bananaimage", label: "BananaImage 生图" },
|
{ value: "bananaimage", label: "BananaImage 生图" },
|
||||||
{ value: "visionary", label: "Visionary 生图" },
|
{ value: "visionary", label: "Visionary 生图" },
|
||||||
{ value: "atlascloud", label: "Atlas Cloud 图片模型" },
|
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
|
||||||
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
|
||||||
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
{ value: "bltcy-video", label: "柏拉图统一视频" },
|
||||||
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
|
||||||
@ -2059,10 +2060,23 @@ export function AdminSettingsClient() {
|
|||||||
|
|
||||||
const detailAgentModelOptions = useMemo(
|
const detailAgentModelOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(modelsData?.models || []).filter(
|
(modelsData?.models || []).filter((model) => {
|
||||||
(model) => !model.supportsImageGen && model.isEnabled
|
if (model.supportsImageGen || !model.isEnabled) return false;
|
||||||
),
|
const provider = (providersData?.providers || []).find(
|
||||||
[modelsData?.models]
|
(item) => item.id === model.providerId
|
||||||
|
);
|
||||||
|
const descriptor = {
|
||||||
|
providerType: provider?.type,
|
||||||
|
modelId: model.modelId,
|
||||||
|
capabilities: (model as any).capabilities,
|
||||||
|
supportsImageGen: model.supportsImageGen,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
!is3DModelDescriptor(descriptor) &&
|
||||||
|
!isVideoModelDescriptor(descriptor)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[modelsData?.models, providersData?.providers]
|
||||||
);
|
);
|
||||||
const imageGenerationModelOptions = useMemo(
|
const imageGenerationModelOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -4152,6 +4166,11 @@ export function AdminSettingsClient() {
|
|||||||
capabilities: modelForm.capabilities,
|
capabilities: modelForm.capabilities,
|
||||||
supportsImageGen: modelForm.supportsImageGen,
|
supportsImageGen: modelForm.supportsImageGen,
|
||||||
});
|
});
|
||||||
|
const is3DProviderForModelConfig = is3DModelDescriptor({
|
||||||
|
providerType: selectedModelProviderType,
|
||||||
|
modelId: modelForm.modelId,
|
||||||
|
capabilities: modelForm.capabilities,
|
||||||
|
});
|
||||||
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
|
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
|
||||||
providerType: selectedModelProviderType,
|
providerType: selectedModelProviderType,
|
||||||
modelId: modelForm.modelId,
|
modelId: modelForm.modelId,
|
||||||
@ -4174,7 +4193,9 @@ export function AdminSettingsClient() {
|
|||||||
? `${
|
? `${
|
||||||
selectedModelProviderType === "krea" ? "Krea" : "Runway"
|
selectedModelProviderType === "krea" ? "Krea" : "Runway"
|
||||||
} 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。`
|
} 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。`
|
||||||
: isVideoProviderForModelConfig
|
: is3DProviderForModelConfig
|
||||||
|
? "当前识别为 3D 模型,将与图片、视频和对话模型分开管理。"
|
||||||
|
: isVideoProviderForModelConfig
|
||||||
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
|
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
|
||||||
: isImageProviderForModelConfig
|
: isImageProviderForModelConfig
|
||||||
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
|
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
|
||||||
@ -6428,7 +6449,7 @@ export function AdminSettingsClient() {
|
|||||||
} catch (modelError) {
|
} catch (modelError) {
|
||||||
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
|
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
|
||||||
toast.warning(
|
toast.warning(
|
||||||
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 全部图片模型"按钮'
|
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7488,7 +7509,7 @@ export function AdminSettingsClient() {
|
|||||||
if (
|
if (
|
||||||
!options?.skipConfirm &&
|
!options?.skipConfirm &&
|
||||||
!confirm(
|
!confirm(
|
||||||
"将从 Atlas Cloud 官方目录同步全部开放图片模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -7507,7 +7528,7 @@ export function AdminSettingsClient() {
|
|||||||
const totalSynced =
|
const totalSynced =
|
||||||
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
|
||||||
toast.success(
|
toast.success(
|
||||||
`已同步 ${totalSynced} 个 Atlas Cloud 图片模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
|
||||||
);
|
);
|
||||||
mutateModels();
|
mutateModels();
|
||||||
if (data.errors?.length) {
|
if (data.errors?.length) {
|
||||||
@ -7515,7 +7536,7 @@ export function AdminSettingsClient() {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("[Quick Add Atlas Cloud Models] Error:", error);
|
console.error("[Quick Add Atlas Cloud Models] Error:", error);
|
||||||
toast.error(error.message || "同步 Atlas Cloud 图片模型失败");
|
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -15725,7 +15746,7 @@ export function AdminSettingsClient() {
|
|||||||
{providerForm.type === "atlascloud" && (
|
{providerForm.type === "atlascloud" && (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
Atlas Cloud 使用 Bearer API Key 认证,默认地址
|
Atlas Cloud 使用 Bearer API Key 认证,默认地址
|
||||||
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片模型,
|
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片与 3D 模型,
|
||||||
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
|
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -15883,7 +15904,7 @@ export function AdminSettingsClient() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
>
|
>
|
||||||
<ImageIcon className="mr-1 h-4 w-4" />
|
<ImageIcon className="mr-1 h-4 w-4" />
|
||||||
同步 Atlas Cloud 全部图片模型
|
同步 Atlas Cloud 图片 / 3D 模型
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{provider.type === "bltcy-video" && (
|
{provider.type === "bltcy-video" && (
|
||||||
@ -17378,6 +17399,11 @@ export function AdminSettingsClient() {
|
|||||||
capabilities: (model as any).capabilities,
|
capabilities: (model as any).capabilities,
|
||||||
supportsImageGen: model.supportsImageGen,
|
supportsImageGen: model.supportsImageGen,
|
||||||
});
|
});
|
||||||
|
const is3DModel = is3DModelDescriptor({
|
||||||
|
providerType: provider?.type,
|
||||||
|
modelId: model.modelId,
|
||||||
|
capabilities: (model as any).capabilities,
|
||||||
|
});
|
||||||
const isImageModel = isImageGenerationModelDescriptor({
|
const isImageModel = isImageGenerationModelDescriptor({
|
||||||
providerType: provider?.type,
|
providerType: provider?.type,
|
||||||
modelId: model.modelId,
|
modelId: model.modelId,
|
||||||
@ -17440,6 +17466,11 @@ export function AdminSettingsClient() {
|
|||||||
Agent
|
Agent
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{is3DModel && (
|
||||||
|
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800 text-xs">
|
||||||
|
3D模型
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isImageModel && (
|
{isImageModel && (
|
||||||
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-800 text-xs">
|
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-800 text-xs">
|
||||||
图片模型
|
图片模型
|
||||||
@ -17450,7 +17481,7 @@ export function AdminSettingsClient() {
|
|||||||
视频模型
|
视频模型
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!isImageModel && !isVideoModel && (
|
{!is3DModel && !isImageModel && !isVideoModel && (
|
||||||
<span className="rounded bg-slate-100 px-2 py-0.5 text-slate-700 text-xs">
|
<span className="rounded bg-slate-100 px-2 py-0.5 text-slate-700 text-xs">
|
||||||
对话模型
|
对话模型
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export async function POST(request: Request) {
|
|||||||
avatarUrl: preset.avatarUrl || null,
|
avatarUrl: preset.avatarUrl || null,
|
||||||
description: preset.description,
|
description: preset.description,
|
||||||
supportsVision: preset.supportsVision,
|
supportsVision: preset.supportsVision,
|
||||||
supportsImageGen: true,
|
supportsImageGen: preset.supportsImageGen,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
sortOrder: preset.sortOrder,
|
sortOrder: preset.sortOrder,
|
||||||
|
|||||||
@ -2009,6 +2009,7 @@ export default function CanvasEditorLegacyPage({
|
|||||||
useState(false);
|
useState(false);
|
||||||
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
|
||||||
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
|
||||||
|
const chatPanelCanvasIdRef = useRef<string | null>(null);
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
|
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
|
||||||
useState(0);
|
useState(0);
|
||||||
@ -2032,6 +2033,16 @@ export default function CanvasEditorLegacyPage({
|
|||||||
Partial<Record<AgentWorkflowStepKey, string>>
|
Partial<Record<AgentWorkflowStepKey, string>>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatPanelCanvasIdRef.current = id;
|
||||||
|
setPanelMode("chat");
|
||||||
|
setIsPanelOpen(true);
|
||||||
|
}, [id, isVideoMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideoMode) {
|
if (!isVideoMode) {
|
||||||
setVideoCanvasInsertMenu(null);
|
setVideoCanvasInsertMenu(null);
|
||||||
|
|||||||
@ -77,7 +77,7 @@ export interface AtlasCloudImageModelPreset {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
description: string;
|
description: string;
|
||||||
supportsVision: boolean;
|
supportsVision: boolean;
|
||||||
supportsImageGen: true;
|
supportsImageGen: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
capabilities: Record<string, unknown>;
|
capabilities: Record<string, unknown>;
|
||||||
defaultParams: Record<string, unknown>;
|
defaultParams: Record<string, unknown>;
|
||||||
@ -86,6 +86,8 @@ export interface AtlasCloudImageModelPreset {
|
|||||||
|
|
||||||
type JsonRecord = Record<string, any>;
|
type JsonRecord = Record<string, any>;
|
||||||
|
|
||||||
|
export type AtlasCloudMediaKind = "image" | "video" | "3d";
|
||||||
|
|
||||||
function asRecord(value: unknown): JsonRecord {
|
function asRecord(value: unknown): JsonRecord {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as JsonRecord)
|
? (value as JsonRecord)
|
||||||
@ -98,6 +100,67 @@ function uniqueStrings(values: unknown[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeIntentToken(value: unknown) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("_", "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferAtlasCloudMediaKind(
|
||||||
|
catalogModel: AtlasCloudCatalogModel
|
||||||
|
): AtlasCloudMediaKind {
|
||||||
|
const catalogType = normalizeIntentToken(catalogModel.type);
|
||||||
|
const intentTokens = uniqueStrings([
|
||||||
|
catalogModel.model,
|
||||||
|
catalogModel.displayName,
|
||||||
|
...(catalogModel.categories || []),
|
||||||
|
...(catalogModel.tags || []),
|
||||||
|
]).map(normalizeIntentToken);
|
||||||
|
const intentText = intentTokens.join(" ");
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:^|[\s/.-])(?:text|image)-to-3d(?:$|[\s/.-])/.test(intentText) ||
|
||||||
|
/(?:^|[\s/.-])(?:hunyuan3d|seed3d|tripo-h3(?:\.\d+)?)(?:$|[\s/.-])/.test(
|
||||||
|
intentText
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "3d";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
catalogType === "video" ||
|
||||||
|
intentTokens.some((token) =>
|
||||||
|
[
|
||||||
|
"text-to-video",
|
||||||
|
"image-to-video",
|
||||||
|
"video-to-video",
|
||||||
|
"audio-to-video",
|
||||||
|
].includes(token)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
function derive3DSupportedModes(catalogModel: AtlasCloudCatalogModel) {
|
||||||
|
const intentText = uniqueStrings([
|
||||||
|
catalogModel.model,
|
||||||
|
catalogModel.displayName,
|
||||||
|
...(catalogModel.categories || []),
|
||||||
|
...(catalogModel.tags || []),
|
||||||
|
])
|
||||||
|
.map(normalizeIntentToken)
|
||||||
|
.join(" ");
|
||||||
|
const modes = [
|
||||||
|
...(intentText.includes("text-to-3d") ? ["text-to-3d"] : []),
|
||||||
|
...(intentText.includes("image-to-3d") ? ["image-to-3d"] : []),
|
||||||
|
];
|
||||||
|
return modes.length > 0 ? modes : ["3d"];
|
||||||
|
}
|
||||||
|
|
||||||
function findFirstKey(properties: JsonRecord, candidates: string[]) {
|
function findFirstKey(properties: JsonRecord, candidates: string[]) {
|
||||||
return candidates.find((key) => key in properties);
|
return candidates.find((key) => key in properties);
|
||||||
}
|
}
|
||||||
@ -361,15 +424,22 @@ export function createAtlasCloudImagePreset(
|
|||||||
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
|
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
|
||||||
value.toLowerCase()
|
value.toLowerCase()
|
||||||
);
|
);
|
||||||
const supportedModes = [
|
const mediaKind = inferAtlasCloudMediaKind(catalogModel);
|
||||||
...(categories.some((value) => value.includes("text-to-image"))
|
const supportedModes =
|
||||||
? ["text-to-image"]
|
mediaKind === "3d"
|
||||||
: []),
|
? derive3DSupportedModes(catalogModel)
|
||||||
...(imageInputKey || categories.some((value) => value.includes("image-to-image"))
|
: [
|
||||||
? ["image-edit", "reference-images"]
|
...(categories.some((value) => value.includes("text-to-image"))
|
||||||
: []),
|
? ["text-to-image"]
|
||||||
];
|
: []),
|
||||||
if (supportedModes.length === 0) supportedModes.push("text-to-image");
|
...(imageInputKey ||
|
||||||
|
categories.some((value) => value.includes("image-to-image"))
|
||||||
|
? ["image-edit", "reference-images"]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
if (mediaKind === "image" && supportedModes.length === 0) {
|
||||||
|
supportedModes.push("text-to-image");
|
||||||
|
}
|
||||||
|
|
||||||
const runtimeConfig: AtlasCloudRuntimeConfig = {
|
const runtimeConfig: AtlasCloudRuntimeConfig = {
|
||||||
submitEndpoint: getPostEndpoint(schema),
|
submitEndpoint: getPostEndpoint(schema),
|
||||||
@ -409,12 +479,20 @@ export function createAtlasCloudImagePreset(
|
|||||||
avatarUrl: catalogModel.avatar || catalogModel.logo || undefined,
|
avatarUrl: catalogModel.avatar || catalogModel.logo || undefined,
|
||||||
description:
|
description:
|
||||||
catalogModel.profile ||
|
catalogModel.profile ||
|
||||||
`Atlas Cloud 图片模型 ${catalogModel.displayName || catalogModel.model}`,
|
`Atlas Cloud ${mediaKind === "3d" ? "3D" : mediaKind === "video" ? "视频" : "图片"}模型 ${catalogModel.displayName || catalogModel.model}`,
|
||||||
supportsVision: Boolean(imageInputKey),
|
supportsVision: Boolean(imageInputKey),
|
||||||
supportsImageGen: true,
|
supportsImageGen: mediaKind === "image",
|
||||||
sortOrder: Math.max(0, 20_000 - index),
|
sortOrder: Math.max(0, 20_000 - index),
|
||||||
capabilities: {
|
capabilities: {
|
||||||
kind: imageInputKey ? "image-edit" : "text-to-image",
|
kind:
|
||||||
|
mediaKind === "3d"
|
||||||
|
? "3d"
|
||||||
|
: mediaKind === "video"
|
||||||
|
? "video"
|
||||||
|
: imageInputKey
|
||||||
|
? "image-edit"
|
||||||
|
: "text-to-image",
|
||||||
|
mediaType: mediaKind,
|
||||||
supportedModes,
|
supportedModes,
|
||||||
maxReferenceImages,
|
maxReferenceImages,
|
||||||
supportsMultiImageReference: maxReferenceImages > 1,
|
supportsMultiImageReference: maxReferenceImages > 1,
|
||||||
|
|||||||
@ -21,12 +21,17 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
|
|||||||
"openai-image",
|
"openai-image",
|
||||||
"bananaimage",
|
"bananaimage",
|
||||||
"visionary",
|
"visionary",
|
||||||
"atlascloud",
|
|
||||||
"stepfun-image",
|
"stepfun-image",
|
||||||
"midjourney",
|
"midjourney",
|
||||||
"volcano-vision",
|
"volcano-vision",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const STATIC_3D_PROVIDER_TYPES = new Set([
|
||||||
|
"tripo-3d",
|
||||||
|
"worldlabs-3d",
|
||||||
|
"kittycad-cad",
|
||||||
|
]);
|
||||||
|
|
||||||
const RUNWAY_IMAGE_MODEL_IDS = new Set([
|
const RUNWAY_IMAGE_MODEL_IDS = new Set([
|
||||||
"gen4_image",
|
"gen4_image",
|
||||||
"gen4_image_turbo",
|
"gen4_image_turbo",
|
||||||
@ -47,11 +52,43 @@ const RUNWAY_VIDEO_MODEL_PREFIXES = [
|
|||||||
|
|
||||||
const VIDEO_MODE_KEYS = new Set([
|
const VIDEO_MODE_KEYS = new Set([
|
||||||
"text-to-video",
|
"text-to-video",
|
||||||
|
"image-to-video",
|
||||||
|
"video-to-video",
|
||||||
|
"audio-to-video",
|
||||||
"first-frame",
|
"first-frame",
|
||||||
"first-last-frame",
|
"first-last-frame",
|
||||||
"reference-images",
|
"reference-images",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const VIDEO_KIND_KEYS = new Set([
|
||||||
|
"video",
|
||||||
|
"text-to-video",
|
||||||
|
"image-to-video",
|
||||||
|
"video-to-video",
|
||||||
|
"audio-to-video",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const THREE_D_MODE_KEYS = new Set([
|
||||||
|
"3d",
|
||||||
|
"text-to-3d",
|
||||||
|
"text_to_3d",
|
||||||
|
"image-to-3d",
|
||||||
|
"image_to_3d",
|
||||||
|
"mesh-generation",
|
||||||
|
"mesh_generation",
|
||||||
|
"cad",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const THREE_D_KIND_KEYS = new Set([
|
||||||
|
"3d",
|
||||||
|
"model-3d",
|
||||||
|
"model_3d",
|
||||||
|
"mesh",
|
||||||
|
"mesh-generation",
|
||||||
|
"mesh_generation",
|
||||||
|
"cad",
|
||||||
|
]);
|
||||||
|
|
||||||
const TEXT_TO_IMAGE_MODE_KEYS = new Set([
|
const TEXT_TO_IMAGE_MODE_KEYS = new Set([
|
||||||
"text-to-image",
|
"text-to-image",
|
||||||
"text2image",
|
"text2image",
|
||||||
@ -108,6 +145,48 @@ function hasVideoModes(capabilities: unknown) {
|
|||||||
return supportedModes.some((mode) => VIDEO_MODE_KEYS.has(String(mode || "").trim()));
|
return supportedModes.some((mode) => VIDEO_MODE_KEYS.has(String(mode || "").trim()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVideoKind(capabilities: unknown) {
|
||||||
|
return ["kind", "mediaType", "modelType", "outputType"].some((key) =>
|
||||||
|
VIDEO_KIND_KEYS.has(getStringField(capabilities, key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function has3DModes(capabilities: unknown) {
|
||||||
|
const supportedModes = getStringArrayField(capabilities, "supportedModes");
|
||||||
|
return supportedModes.some((mode) => THREE_D_MODE_KEYS.has(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function has3DKind(capabilities: unknown) {
|
||||||
|
return ["kind", "mediaType", "modelType", "outputType"].some((key) =>
|
||||||
|
THREE_D_KIND_KEYS.has(getStringField(capabilities, key))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelIdLooks3D(modelId?: string | null) {
|
||||||
|
const normalized = normalizeText(modelId);
|
||||||
|
if (!normalized) return false;
|
||||||
|
return (
|
||||||
|
/(?:^|[/_.-])(?:text|image)[_-]to[_-]3d(?:$|[/_.-])/.test(normalized) ||
|
||||||
|
/(?:^|[/_.-])(?:hunyuan3d|seed3d|tripo-h3(?:\.\d+)?)(?:$|[/_.-])/.test(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function is3DModelDescriptor(params: {
|
||||||
|
providerType?: string | null;
|
||||||
|
modelId?: string | null;
|
||||||
|
capabilities?: unknown;
|
||||||
|
}): boolean {
|
||||||
|
const providerType = normalizeText(params.providerType);
|
||||||
|
return (
|
||||||
|
STATIC_3D_PROVIDER_TYPES.has(providerType) ||
|
||||||
|
has3DModes(params.capabilities) ||
|
||||||
|
has3DKind(params.capabilities) ||
|
||||||
|
modelIdLooks3D(params.modelId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function inferRunwayModelKind(modelId?: string | null): "image" | "video" | null {
|
export function inferRunwayModelKind(modelId?: string | null): "image" | "video" | null {
|
||||||
const normalized = normalizeText(modelId);
|
const normalized = normalizeText(modelId);
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
@ -130,11 +209,15 @@ export function isVideoModelDescriptor(params: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const providerType = normalizeText(params.providerType);
|
const providerType = normalizeText(params.providerType);
|
||||||
|
|
||||||
|
if (is3DModelDescriptor(params)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (isStaticVideoProviderType(providerType)) {
|
if (isStaticVideoProviderType(providerType)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasVideoModes(params.capabilities)) {
|
if (hasVideoModes(params.capabilities) || hasVideoKind(params.capabilities)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,6 +240,10 @@ export function isImageGenerationModelDescriptor(params: {
|
|||||||
}): boolean {
|
}): boolean {
|
||||||
const providerType = normalizeText(params.providerType);
|
const providerType = normalizeText(params.providerType);
|
||||||
|
|
||||||
|
if (is3DModelDescriptor(params)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (providerType === "runway") {
|
if (providerType === "runway") {
|
||||||
const runwayModelKind = inferRunwayModelKind(params.modelId);
|
const runwayModelKind = inferRunwayModelKind(params.modelId);
|
||||||
if (runwayModelKind === "image") return true;
|
if (runwayModelKind === "image") return true;
|
||||||
|
|||||||
95
tests/routes/model-classification.test.ts
Normal file
95
tests/routes/model-classification.test.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
createAtlasCloudImagePreset,
|
||||||
|
inferAtlasCloudMediaKind,
|
||||||
|
} from "@/lib/ai/atlascloud-models";
|
||||||
|
import {
|
||||||
|
is3DModelDescriptor,
|
||||||
|
isImageGenerationModelDescriptor,
|
||||||
|
isVideoModelDescriptor,
|
||||||
|
} from "@/lib/ai/model-classification";
|
||||||
|
|
||||||
|
const atlasHunyuan3D = {
|
||||||
|
model: "tencent/hunyuan3d-pro/text-to-3d",
|
||||||
|
type: "Image",
|
||||||
|
displayName: "Hunyuan 3D Pro Text-to-3D",
|
||||||
|
categories: ["TEXT-TO-3D"],
|
||||||
|
tags: ["TEXT-TO-3D", "NEW"],
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("AI model media classification", () => {
|
||||||
|
test("keeps Atlas 3D entries out of image and video categories", () => {
|
||||||
|
expect(inferAtlasCloudMediaKind(atlasHunyuan3D)).toBe("3d");
|
||||||
|
|
||||||
|
const descriptor = {
|
||||||
|
providerType: "atlascloud",
|
||||||
|
modelId: atlasHunyuan3D.model,
|
||||||
|
capabilities: {
|
||||||
|
kind: "3d",
|
||||||
|
mediaType: "3d",
|
||||||
|
supportedModes: ["text-to-3d"],
|
||||||
|
},
|
||||||
|
supportsImageGen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(is3DModelDescriptor(descriptor)).toBe(true);
|
||||||
|
expect(isImageGenerationModelDescriptor(descriptor)).toBe(false);
|
||||||
|
expect(isVideoModelDescriptor(descriptor)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes 3D Atlas presets with model-level capabilities", () => {
|
||||||
|
const preset = createAtlasCloudImagePreset(
|
||||||
|
atlasHunyuan3D,
|
||||||
|
{
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
model: { type: "string" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["model", "prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(preset.supportsImageGen).toBe(false);
|
||||||
|
expect(preset.capabilities).toMatchObject({
|
||||||
|
kind: "3d",
|
||||||
|
mediaType: "3d",
|
||||||
|
supportedModes: ["text-to-3d"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still distinguishes Atlas image and video descriptors", () => {
|
||||||
|
const imageDescriptor = {
|
||||||
|
providerType: "atlascloud",
|
||||||
|
modelId: "black-forest-labs/flux-2-pro/text-to-image",
|
||||||
|
capabilities: {
|
||||||
|
kind: "text-to-image",
|
||||||
|
mediaType: "image",
|
||||||
|
supportedModes: ["text-to-image"],
|
||||||
|
},
|
||||||
|
supportsImageGen: true,
|
||||||
|
};
|
||||||
|
const videoDescriptor = {
|
||||||
|
providerType: "atlascloud",
|
||||||
|
modelId: "veed/fabric-1.0/image-to-video",
|
||||||
|
capabilities: {
|
||||||
|
kind: "video",
|
||||||
|
mediaType: "video",
|
||||||
|
supportedModes: ["image-to-video"],
|
||||||
|
},
|
||||||
|
supportsImageGen: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(isImageGenerationModelDescriptor(imageDescriptor)).toBe(true);
|
||||||
|
expect(isVideoModelDescriptor(imageDescriptor)).toBe(false);
|
||||||
|
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
|
||||||
|
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user