fix: classify media models and open canvas chat

This commit is contained in:
134 2026-07-21 11:53:58 +08:00 committed by root
parent e8d1aa25aa
commit fd47e0ae61
6 changed files with 317 additions and 50 deletions

View File

@ -173,6 +173,7 @@ import {
getImageSizeCreditMultiplier,
} from "@/lib/ai/image-model-pricing";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
isVideoModelDescriptor,
} from "@/lib/ai/model-classification";
@ -1586,7 +1587,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
{ value: "atlascloud", label: "Atlas Cloud 图片模型" },
{ value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@ -2083,10 +2084,23 @@ export function AdminSettingsClient() {
const detailAgentModelOptions = useMemo(
() =>
(modelsData?.models || []).filter(
(model) => !model.supportsImageGen && model.isEnabled
),
[modelsData?.models]
(modelsData?.models || []).filter((model) => {
if (model.supportsImageGen || !model.isEnabled) return false;
const provider = (providersData?.providers || []).find(
(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(
() =>
@ -4176,6 +4190,11 @@ export function AdminSettingsClient() {
capabilities: modelForm.capabilities,
supportsImageGen: modelForm.supportsImageGen,
});
const is3DProviderForModelConfig = is3DModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
capabilities: modelForm.capabilities,
});
const isImageProviderForModelConfig = isImageGenerationModelDescriptor({
providerType: selectedModelProviderType,
modelId: modelForm.modelId,
@ -4198,7 +4217,9 @@ export function AdminSettingsClient() {
? `${
selectedModelProviderType === "krea" ? "Krea" : "Runway"
} 同时支持图片与视频模型,填写模型 ID 后系统会自动识别标签。`
: isVideoProviderForModelConfig
: is3DProviderForModelConfig
? "当前识别为 3D 模型,将与图片、视频和对话模型分开管理。"
: isVideoProviderForModelConfig
? "当前识别为视频模型,将使用视频能力、视频积分和视频路由。"
: isImageProviderForModelConfig
? "当前识别为图片模型,将使用生图能力、图片积分和生图路由。"
@ -6452,7 +6473,7 @@ export function AdminSettingsClient() {
} catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning(
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 全部图片模型"按钮'
'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
);
}
}
@ -7513,7 +7534,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
"将从 Atlas Cloud 官方目录同步全部开放图片模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
"将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
)
) {
return;
@ -7532,7 +7553,7 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
`已同步 ${totalSynced} 个 Atlas Cloud 图片模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
`已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
@ -7540,7 +7561,7 @@ export function AdminSettingsClient() {
}
} catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error);
toast.error(error.message || "同步 Atlas Cloud 图片模型失败");
toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
throw error;
}
};
@ -15764,7 +15785,7 @@ export function AdminSettingsClient() {
{providerForm.type === "atlascloud" && (
<p className="text-muted-foreground text-xs">
Atlas Cloud 使用 Bearer API Key 认证,默认地址
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片模型,
https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片与 3D 模型,
并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
</p>
)}
@ -15922,7 +15943,7 @@ export function AdminSettingsClient() {
variant="secondary"
>
<ImageIcon className="mr-1 h-4 w-4" />
同步 Atlas Cloud 全部图片模型
同步 Atlas Cloud 图片 / 3D 模型
</Button>
)}
{provider.type === "bltcy-video" && (
@ -17417,6 +17438,11 @@ export function AdminSettingsClient() {
capabilities: (model as any).capabilities,
supportsImageGen: model.supportsImageGen,
});
const is3DModel = is3DModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
capabilities: (model as any).capabilities,
});
const isImageModel = isImageGenerationModelDescriptor({
providerType: provider?.type,
modelId: model.modelId,
@ -17479,6 +17505,11 @@ export function AdminSettingsClient() {
Agent
</span>
)}
{is3DModel && (
<span className="rounded bg-amber-100 px-2 py-0.5 text-amber-800 text-xs">
3D模型
</span>
)}
{isImageModel && (
<span className="rounded bg-sky-100 px-2 py-0.5 text-sky-800 text-xs">
图片模型
@ -17489,7 +17520,7 @@ export function AdminSettingsClient() {
视频模型
</span>
)}
{!isImageModel && !isVideoModel && (
{!is3DModel && !isImageModel && !isVideoModel && (
<span className="rounded bg-slate-100 px-2 py-0.5 text-slate-700 text-xs">
对话模型
</span>

View File

@ -79,7 +79,7 @@ export async function POST(request: Request) {
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: true,
supportsImageGen: preset.supportsImageGen,
isEnabled: true,
isHidden: false,
sortOrder: preset.sortOrder,

View File

@ -2013,6 +2013,7 @@ export default function CanvasEditorLegacyPage({
useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
const chatPanelCanvasIdRef = useRef<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
useState(0);
@ -2036,6 +2037,16 @@ export default function CanvasEditorLegacyPage({
Partial<Record<AgentWorkflowStepKey, string>>
>({});
useLayoutEffect(() => {
if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
return;
}
chatPanelCanvasIdRef.current = id;
setPanelMode("chat");
setIsPanelOpen(true);
}, [id, isVideoMode]);
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);

View File

@ -77,7 +77,7 @@ export interface AtlasCloudImageModelPreset {
avatarUrl?: string;
description: string;
supportsVision: boolean;
supportsImageGen: true;
supportsImageGen: boolean;
sortOrder: number;
capabilities: Record<string, unknown>;
defaultParams: Record<string, unknown>;
@ -86,6 +86,8 @@ export interface AtlasCloudImageModelPreset {
type JsonRecord = Record<string, any>;
export type AtlasCloudMediaKind = "image" | "video" | "3d";
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (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[]) {
return candidates.find((key) => key in properties);
}
@ -361,15 +424,22 @@ export function createAtlasCloudImagePreset(
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
);
const supportedModes = [
...(categories.some((value) => value.includes("text-to-image"))
? ["text-to-image"]
: []),
...(imageInputKey || categories.some((value) => value.includes("image-to-image"))
? ["image-edit", "reference-images"]
: []),
];
if (supportedModes.length === 0) supportedModes.push("text-to-image");
const mediaKind = inferAtlasCloudMediaKind(catalogModel);
const supportedModes =
mediaKind === "3d"
? derive3DSupportedModes(catalogModel)
: [
...(categories.some((value) => value.includes("text-to-image"))
? ["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 = {
submitEndpoint: getPostEndpoint(schema),
@ -409,12 +479,20 @@ export function createAtlasCloudImagePreset(
avatarUrl: catalogModel.avatar || catalogModel.logo || undefined,
description:
catalogModel.profile ||
`Atlas Cloud 图片模型 ${catalogModel.displayName || catalogModel.model}`,
`Atlas Cloud ${mediaKind === "3d" ? "3D" : mediaKind === "video" ? "视频" : "图片"}模型 ${catalogModel.displayName || catalogModel.model}`,
supportsVision: Boolean(imageInputKey),
supportsImageGen: true,
supportsImageGen: mediaKind === "image",
sortOrder: Math.max(0, 20_000 - index),
capabilities: {
kind: imageInputKey ? "image-edit" : "text-to-image",
kind:
mediaKind === "3d"
? "3d"
: mediaKind === "video"
? "video"
: imageInputKey
? "image-edit"
: "text-to-image",
mediaType: mediaKind,
supportedModes,
maxReferenceImages,
supportsMultiImageReference: maxReferenceImages > 1,

View File

@ -21,12 +21,17 @@ const STATIC_IMAGE_PROVIDER_TYPES = new Set([
"openai-image",
"bananaimage",
"visionary",
"atlascloud",
"stepfun-image",
"midjourney",
"volcano-vision",
]);
const STATIC_3D_PROVIDER_TYPES = new Set([
"tripo-3d",
"worldlabs-3d",
"kittycad-cad",
]);
const RUNWAY_IMAGE_MODEL_IDS = new Set([
"gen4_image",
"gen4_image_turbo",
@ -47,10 +52,42 @@ const RUNWAY_VIDEO_MODEL_PREFIXES = [
const VIDEO_MODE_KEYS = new Set([
"text-to-video",
"image-to-video",
"video-to-video",
"audio-to-video",
"first-frame",
"first-last-frame",
]);
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([
"text-to-image",
"text2image",
@ -107,6 +144,48 @@ function hasVideoModes(capabilities: unknown) {
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 {
const normalized = normalizeText(modelId);
if (!normalized) return null;
@ -129,11 +208,15 @@ export function isVideoModelDescriptor(params: {
}): boolean {
const providerType = normalizeText(params.providerType);
if (is3DModelDescriptor(params)) {
return false;
}
if (isStaticVideoProviderType(providerType)) {
return true;
}
if (hasVideoModes(params.capabilities)) {
if (hasVideoModes(params.capabilities) || hasVideoKind(params.capabilities)) {
return true;
}
@ -156,6 +239,10 @@ export function isImageGenerationModelDescriptor(params: {
}): boolean {
const providerType = normalizeText(params.providerType);
if (is3DModelDescriptor(params)) {
return false;
}
if (providerType === "runway") {
const runwayModelKind = inferRunwayModelKind(params.modelId);
if (runwayModelKind === "image") return true;

View File

@ -1,35 +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";
test.describe("model classification", () => {
test("keeps multi-reference BananaImage models classified as image models", () => {
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: "bananaimage",
modelId: "gemini-3-pro-image-preview",
providerType: "atlascloud",
modelId: atlasHunyuan3D.model,
capabilities: {
supportedModes: [
"text-to-image",
"image-to-image",
"reference-images",
],
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(isVideoModelDescriptor(descriptor)).toBe(false);
expect(isImageGenerationModelDescriptor(descriptor)).toBe(true);
});
test("still recognizes explicit video generation modes", () => {
expect(
isVideoModelDescriptor({
providerType: "custom",
modelId: "custom-video-model",
capabilities: { supportedModes: ["text-to-video"] },
})
).toBe(true);
expect(isImageGenerationModelDescriptor(imageDescriptor)).toBe(true);
expect(isVideoModelDescriptor(imageDescriptor)).toBe(false);
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
});
});