From e8d1aa25aa5aa67e93e9114573b48eda2b55d491 Mon Sep 17 00:00:00 2001
From: 134
Date: Tue, 21 Jul 2026 11:01:43 +0800
Subject: [PATCH 01/83] feat: add Atlas Cloud image provider
---
app/(admin)/settings/_lib/types.ts | 1 +
.../settings/admin-settings-client.tsx | 73 +++
app/(chat)/api/chat/_lib/image-failover.ts | 2 +
app/(chat)/api/chat/_lib/image-media.ts | 1 +
app/(chat)/api/chat/_lib/stream.ts | 1 +
app/(chat)/api/chat/route.ts | 13 +
app/api/admin/ai-providers/route.ts | 4 +
app/api/admin/atlascloud-models/route.ts | 155 +++++
app/api/canvas/text-edit/route.ts | 12 +-
app/api/scene-fusion/route.ts | 58 +-
components/image-generation-loading.tsx | 1 +
components/image-params-bar.tsx | 1 +
components/image-preview.tsx | 3 +
lib/ai/atlascloud-models.ts | 523 +++++++++++++++
lib/ai/dynamic-provider.ts | 13 +
lib/ai/model-classification.ts | 1 +
lib/ai/model-router.ts | 13 +-
lib/ai/provider-defaults.ts | 1 +
lib/ai/providers/atlascloud.ts | 596 ++++++++++++++++++
lib/canvas/generate-image.ts | 25 +-
lib/db/migrate.ts | 3 +-
.../0092_add_atlascloud_provider_type.sql | 40 ++
lib/db/migrations/meta/_journal.json | 7 +
lib/db/queries.ts | 4 +-
lib/db/schema.ts | 2 +-
lib/external-image-api/service.ts | 1 +
lib/scene-fusion/output-capabilities.ts | 2 +
27 files changed, 1541 insertions(+), 15 deletions(-)
create mode 100644 app/api/admin/atlascloud-models/route.ts
create mode 100644 lib/ai/atlascloud-models.ts
create mode 100644 lib/ai/providers/atlascloud.ts
create mode 100644 lib/db/migrations/0092_add_atlascloud_provider_type.sql
diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts
index 7a89fcbe5..38740da8f 100644
--- a/app/(admin)/settings/_lib/types.ts
+++ b/app/(admin)/settings/_lib/types.ts
@@ -90,6 +90,7 @@ export type AdminAiProvider = {
| "openai-image"
| "bananaimage"
| "visionary"
+ | "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index d26512e1f..37bb911de 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -625,6 +625,7 @@ type AiProvider = {
| "openai-image"
| "bananaimage"
| "visionary"
+ | "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"
@@ -1585,6 +1586,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
+ { value: "atlascloud", label: "Atlas Cloud 图片模型" },
{ value: "stepfun-image", label: "阶跃星辰 Step Image Edit" },
{ value: "bltcy-video", label: "柏拉图统一视频" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
@@ -1621,6 +1623,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record = {
"openai-image": "https://api.openai.com/v1",
bananaimage: "https://bananaimage.aigenmedia.art",
visionary: "https://api.visionary.beer",
+ atlascloud: "https://api.atlascloud.ai",
"stepfun-image": "https://api.stepfun.com",
"bltcy-video": "https://api.bltcy.ai",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
@@ -4039,6 +4042,7 @@ export function AdminSettingsClient() {
| "openai-image"
| "bananaimage"
| "visionary"
+ | "atlascloud"
| "stepfun-image"
| "midjourney"
| "fal-video"
@@ -6277,6 +6281,7 @@ export function AdminSettingsClient() {
const isSnapgenImageProvider = providerForm.type === "snapgen-image";
const isBananaImageProvider = providerForm.type === "bananaimage";
const isVisionaryProvider = providerForm.type === "visionary";
+ const isAtlasCloudProvider = providerForm.type === "atlascloud";
const isBltcyVideoProvider = providerForm.type === "bltcy-video";
const isLemonProvider =
providerForm.type === "openai" &&
@@ -6441,6 +6446,17 @@ export function AdminSettingsClient() {
}
}
+ if (isNewProvider && isAtlasCloudProvider && providerId) {
+ try {
+ await handleQuickAddAtlasCloudModels(providerId, { skipConfirm: true });
+ } catch (modelError) {
+ console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
+ toast.warning(
+ 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 全部图片模型"按钮'
+ );
+ }
+ }
+
if (isNewProvider && isBltcyVideoProvider && providerId) {
try {
await handleQuickAddBltcyVideoModels(providerId, {
@@ -7490,6 +7506,45 @@ export function AdminSettingsClient() {
}
};
+ const handleQuickAddAtlasCloudModels = async (
+ providerId: string,
+ options?: { skipConfirm?: boolean }
+ ) => {
+ if (
+ !options?.skipConfirm &&
+ !confirm(
+ "将从 Atlas Cloud 官方目录同步全部开放图片模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
+ )
+ ) {
+ return;
+ }
+
+ try {
+ toast.info("正在读取 Atlas Cloud 官方目录和模型参数...");
+ const response = await fetch("/api/admin/atlascloud-models", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ providerId }),
+ });
+ const data = await response.json();
+ if (!response.ok) throw new Error(data.error || "同步失败");
+
+ const totalSynced =
+ Number(data.addedCount || 0) + Number(data.updatedCount || 0);
+ toast.success(
+ `已同步 ${totalSynced} 个 Atlas Cloud 图片模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
+ );
+ mutateModels();
+ if (data.errors?.length) {
+ toast.warning(`${data.errors.length} 个 Atlas Cloud 模型读取或同步失败`);
+ }
+ } catch (error: any) {
+ console.error("[Quick Add Atlas Cloud Models] Error:", error);
+ toast.error(error.message || "同步 Atlas Cloud 图片模型失败");
+ throw error;
+ }
+ };
+
const handleUseFalImageProviderPreset = () => {
setProviderForm({
id: "",
@@ -15706,6 +15761,13 @@ export function AdminSettingsClient() {
质量档位和 1K/2K/4K 输出。
)}
+ {providerForm.type === "atlascloud" && (
+
+ Atlas Cloud 使用 Bearer API Key 认证,默认地址
+ https://api.atlascloud.ai。保存后会从官方目录同步全部开放图片模型,
+ 并按每个模型自己的 Schema 配置多图数量、比例及 1K/2K/4K 档位。
+
+ )}
{providerForm.type === "bfl-image" &&
isBltcyBflBaseUrl(providerForm.baseUrl) && (
@@ -15852,6 +15914,17 @@ export function AdminSettingsClient() {
快速添加 Visionary 图片预设
)}
+ {provider.type === "atlascloud" && (
+
+ )}
{provider.type === "bltcy-video" && (
)}
{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
)}
+ {is3DModel && (
+
+ 3D模型
+
+ )}
{isImageModel && (
图片模型
@@ -17489,7 +17520,7 @@ export function AdminSettingsClient() {
视频模型
)}
- {!isImageModel && !isVideoModel && (
+ {!is3DModel && !isImageModel && !isVideoModel && (
对话模型
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
index ba4803a76..74636b29d 100644
--- a/app/api/admin/atlascloud-models/route.ts
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -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,
diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
index 14d633723..06d23ce15 100644
--- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
+++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx
@@ -2013,6 +2013,7 @@ export default function CanvasEditorLegacyPage({
useState(false);
// 面板模式: 'chat' 聊天面板, 'fileList' 文件列表
const [panelMode, setPanelMode] = useState<"chat" | "fileList">("chat");
+ const chatPanelCanvasIdRef = useRef(null);
const [viewportWidth, setViewportWidth] = useState(0);
const [horizontalFloatingToolbarWidth, setHorizontalFloatingToolbarWidth] =
useState(0);
@@ -2036,6 +2037,16 @@ export default function CanvasEditorLegacyPage({
Partial>
>({});
+ useLayoutEffect(() => {
+ if (!id || isVideoMode || chatPanelCanvasIdRef.current === id) {
+ return;
+ }
+
+ chatPanelCanvasIdRef.current = id;
+ setPanelMode("chat");
+ setIsPanelOpen(true);
+ }, [id, isVideoMode]);
+
useEffect(() => {
if (!isVideoMode) {
setVideoCanvasInsertMenu(null);
diff --git a/lib/ai/atlascloud-models.ts b/lib/ai/atlascloud-models.ts
index 2ab430267..21f10e4a0 100644
--- a/lib/ai/atlascloud-models.ts
+++ b/lib/ai/atlascloud-models.ts
@@ -77,7 +77,7 @@ export interface AtlasCloudImageModelPreset {
avatarUrl?: string;
description: string;
supportsVision: boolean;
- supportsImageGen: true;
+ supportsImageGen: boolean;
sortOrder: number;
capabilities: Record;
defaultParams: Record;
@@ -86,6 +86,8 @@ export interface AtlasCloudImageModelPreset {
type JsonRecord = Record;
+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,
diff --git a/lib/ai/model-classification.ts b/lib/ai/model-classification.ts
index c861c6947..8e45c9552 100644
--- a/lib/ai/model-classification.ts
+++ b/lib/ai/model-classification.ts
@@ -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;
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index 04cc84f1a..0289d8932 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -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);
});
});
From 758be188cf333363cf7a7f60253ac0b4d54cd767 Mon Sep 17 00:00:00 2001
From: 134
Date: Tue, 21 Jul 2026 11:59:11 +0800
Subject: [PATCH 03/83] test: preserve image model classification coverage
---
tests/routes/model-classification.test.ts | 28 +++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index 0289d8932..c0b50796b 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -18,6 +18,34 @@ const atlasHunyuan3D = {
};
test.describe("AI model media classification", () => {
+ test("keeps multi-reference BananaImage models classified as image models", () => {
+ const descriptor = {
+ providerType: "bananaimage",
+ modelId: "gemini-3-pro-image-preview",
+ capabilities: {
+ supportedModes: [
+ "text-to-image",
+ "image-to-image",
+ "reference-images",
+ ],
+ },
+ supportsImageGen: true,
+ };
+
+ 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);
+ });
+
test("keeps Atlas 3D entries out of image and video categories", () => {
expect(inferAtlasCloudMediaKind(atlasHunyuan3D)).toBe("3d");
From 75cecdfcadbbb7d3b6aabfa00ca63fab15bc89e1 Mon Sep 17 00:00:00 2001
From: 134
Date: Wed, 22 Jul 2026 10:25:32 +0800
Subject: [PATCH 04/83] fix: validate AtlasCloud frontend model requests
---
app/(chat)/api/files/upload/route.ts | 20 +-
components/canvas/CanvasChatPanel.tsx | 206 +++++++++++++-
.../canvas/chat-panel/canvas-chat-session.ts | 4 +
lib/ai/atlascloud-models.ts | 49 ++++
lib/ai/atlascloud-request.ts | 209 ++++++++++++++
lib/ai/providers/atlascloud.ts | 268 ++++--------------
scripts/sync-atlascloud-models.ts | 92 ++++++
scripts/verify-atlascloud-frontend.ts | 204 +++++++++++++
tests/routes/model-classification.test.ts | 68 +++++
9 files changed, 908 insertions(+), 212 deletions(-)
create mode 100644 lib/ai/atlascloud-request.ts
create mode 100644 scripts/sync-atlascloud-models.ts
create mode 100644 scripts/verify-atlascloud-frontend.ts
diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts
index 6c11efe0b..0a5297cb1 100644
--- a/app/(chat)/api/files/upload/route.ts
+++ b/app/(chat)/api/files/upload/route.ts
@@ -18,6 +18,11 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/heic",
"image/heif",
]);
+const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
+ "video/mp4",
+ "video/quicktime",
+ "video/webm",
+]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
@@ -197,6 +202,9 @@ export async function POST(request: Request) {
let filename = originalFilename;
let contentType = originalContentType;
const uploadPurpose = String(formData.get("purpose") || "");
+ const isAtlasCloudReferenceVideo =
+ uploadPurpose === "canvas-video-process" &&
+ ALLOWED_ATLASCLOUD_VIDEO_TYPES.has(contentType);
const clientUploadId =
String(formData.get("clientUploadId") || "").trim() || null;
const isSvgUpload = contentType === "image/svg+xml";
@@ -227,7 +235,10 @@ export async function POST(request: Request) {
return NextResponse.json({ error: errorMessage }, { status: 400 });
}
- if (!ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType)) {
+ if (
+ !ALLOWED_UPLOAD_IMAGE_TYPES.has(contentType) &&
+ !isAtlasCloudReferenceVideo
+ ) {
const errorMessage = createUnsupportedImageTypeMessage(contentType, filename);
logger.warn("[Upload API] Unsupported upload content type", {
contentType,
@@ -361,18 +372,19 @@ export async function POST(request: Request) {
try {
const asset = await upsertMediaAsset({
userId: session.user.id,
- mediaType: "image",
+ mediaType: isAtlasCloudReferenceVideo ? "video" : "image",
sourceType: "upload",
status: "ready",
originalUrl: data.url,
- previewUrl: data.url,
- thumbnailUrl: data.url,
+ previewUrl: isAtlasCloudReferenceVideo ? null : data.url,
+ thumbnailUrl: isAtlasCloudReferenceVideo ? null : data.url,
mimeType: data.contentType || contentType || null,
displayName: filename,
sourceTable: "upload",
metadata: {
pathname: data.pathname,
size: optimizedBuffer.length,
+ purpose: uploadPurpose || null,
clientUploadId,
convertedFrom,
},
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index 5a8000c56..8c35ab4a2 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -2197,6 +2197,39 @@ function isLikelyVideoAttachment(url: string, contentType: string) {
);
}
+function collectMessageVideoAttachments(message: ChatMessage) {
+ const attachments: PickedObjectAttachment[] = [];
+ const seenUrls = new Set();
+ const append = (value: any, fallbackName: string) => {
+ const url = String(value?.url || value?.data || "").trim();
+ if (!isValidChatHttpUrl(url) || seenUrls.has(url)) return;
+ const contentType = normalizeChatMediaType(
+ value?.contentType || value?.mediaType || value?.mimeType
+ );
+ if (!isLikelyVideoAttachment(url, contentType)) return;
+ const attachment = sanitizeChatAttachment({
+ url,
+ name: normalizeChatAttachmentName(value?.name || fallbackName),
+ contentType,
+ assetId:
+ typeof value?.assetId === "string" && value.assetId.trim()
+ ? value.assetId.trim()
+ : undefined,
+ } as PickedObjectAttachment);
+ if (!attachment) return;
+ seenUrls.add(url);
+ attachments.push(attachment);
+ };
+
+ (message.parts || []).forEach((part: any, index: number) => {
+ if (part?.type === "file") append(part, `参考视频-${index + 1}.mp4`);
+ });
+ ((message as any).attachments || []).forEach((attachment: any, index: number) => {
+ append(attachment, `参考视频-${index + 1}.mp4`);
+ });
+ return attachments;
+}
+
function buildCopiedMessageAttachment(
value: any,
fallbackName: string
@@ -4206,6 +4239,7 @@ export function CanvasChatPanel({
videoToolCapability?: VideoToolCapabilityContract | null;
imageGenConfig?: {
maxImages?: number;
+ maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@@ -4234,6 +4268,7 @@ export function CanvasChatPanel({
isMultimodal?: boolean;
imageGenConfig?: {
maxImages?: number;
+ maxVideoReferences?: number;
supportsSize?: boolean;
supports1K?: boolean;
supports2K?: boolean;
@@ -4731,6 +4766,25 @@ export function CanvasChatPanel({
[visualModels]
);
const currentModel = allModels.find((model) => model.id === selectedModelId);
+ const atlasVideoReferenceLimit =
+ currentModel?.providerType === "atlascloud"
+ ? Math.max(
+ 0,
+ Math.min(
+ 4,
+ Number(
+ currentModel.capabilities?.maxVideoReferences ||
+ currentModel.imageGenConfig?.maxVideoReferences ||
+ 0
+ )
+ )
+ )
+ : 0;
+ const supportsAtlasVideoReference = atlasVideoReferenceLimit > 0;
+ const requiresAtlasVideoReference = Boolean(
+ supportsAtlasVideoReference &&
+ currentModel?.capabilities?.requiresVideoReference === true
+ );
const isCanvasVideoModel = useCallback(
(model?: (typeof allModels)[number] | null) =>
Boolean(
@@ -11295,6 +11349,20 @@ export function CanvasChatPanel({
let effectiveAttachments = attachments
.map((attachment) => sanitizeChatAttachment(attachment))
.filter((attachment): attachment is PickedObjectAttachment => Boolean(attachment));
+ if (supportsAtlasVideoReference) {
+ let keptVideoCount = 0;
+ effectiveAttachments = effectiveAttachments.filter((attachment) => {
+ if (!isLikelyVideoAttachment(attachment.url, attachment.contentType)) {
+ return true;
+ }
+ keptVideoCount += 1;
+ return keptVideoCount <= atlasVideoReferenceLimit;
+ });
+ if (requiresAtlasVideoReference && keptVideoCount === 0) {
+ toast.error("当前模型需要先上传 1 个参考视频");
+ return;
+ }
+ }
if (hasAgentUploadSlots) {
if (effectiveAttachments.length > agentRequiredUploadCount) {
effectiveAttachments = effectiveAttachments.slice(0, agentRequiredUploadCount);
@@ -11918,6 +11986,57 @@ export function CanvasChatPanel({
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
+ const uploadVideoFileForComposerAttachment = useCallback(async (
+ file: File,
+ progressOptions?: CanvasUploadProgressOptions
+ ): Promise => {
+ if (!file.type.startsWith("video/")) {
+ toast.error("请选择视频文件");
+ return null;
+ }
+ if (file.size > 15 * 1024 * 1024) {
+ toast.error("参考视频大小不能超过 15MB");
+ return null;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("purpose", "canvas-video-process");
+ if (progressOptions) {
+ updateComposerUploadProgress(progressOptions);
+ }
+ const response = await fetchUploadWithRetry(
+ formData,
+ progressOptions
+ ? {
+ onProgress: (progress) => {
+ updateComposerUploadProgress(progressOptions, progress);
+ },
+ }
+ : undefined
+ );
+ if (!response.ok) {
+ throw new Error("上传失败");
+ }
+
+ const data = await response.json();
+ clearComposerUploadProgress(250);
+ return {
+ imageUrl: String(data.url || ""),
+ name: file.name || `参考视频-${Date.now()}.mp4`,
+ contentType: file.type || "video/mp4",
+ assetId:
+ typeof data.assetId === "string" && data.assetId.trim()
+ ? data.assetId.trim()
+ : undefined,
+ };
+ } catch (_error) {
+ toast.error("参考视频上传失败");
+ return null;
+ }
+ }, [clearComposerUploadProgress, updateComposerUploadProgress]);
+
const handlePastedImages = useCallback(async (files: File[]) => {
if (files.length === 0) return;
@@ -12250,6 +12369,58 @@ export function CanvasChatPanel({
e.target.value = "";
return;
}
+
+ if (supportsAtlasVideoReference) {
+ const selectedVideoFiles = selectedFiles.filter((file) =>
+ file.type.startsWith("video/")
+ );
+ const existingVideoCount = attachments.filter((attachment) =>
+ isLikelyVideoAttachment(attachment.url, attachment.contentType)
+ ).length;
+ const availableVideoSlots = Math.max(
+ 0,
+ atlasVideoReferenceLimit - existingVideoCount
+ );
+ const acceptedVideoFiles = selectedVideoFiles.slice(0, availableVideoSlots);
+
+ if (selectedVideoFiles.length > acceptedVideoFiles.length) {
+ toast.info(`当前模型最多支持 ${atlasVideoReferenceLimit} 个参考视频`);
+ }
+ if (acceptedVideoFiles.length > 0) {
+ let uploadedCount = 0;
+ try {
+ for (const [index, file] of acceptedVideoFiles.entries()) {
+ const uploaded = await uploadVideoFileForComposerAttachment(file, {
+ title: "正在上传参考视频",
+ fileName: file.name,
+ fileIndex: index,
+ fileTotal: acceptedVideoFiles.length,
+ target: "composer-reference",
+ });
+ if (!uploaded?.imageUrl) continue;
+ addUploadedImageAttachmentToComposer(uploaded);
+ uploadedCount += 1;
+ }
+ } finally {
+ clearComposerUploadProgress(450);
+ }
+ if (uploadedCount > 0) {
+ toast.success(
+ uploadedCount === 1
+ ? "参考视频已添加到对话"
+ : `已添加 ${uploadedCount} 个参考视频`
+ );
+ }
+ }
+
+ selectedFiles = selectedFiles.filter(
+ (file) => !file.type.startsWith("video/")
+ );
+ if (selectedFiles.length === 0) {
+ e.target.value = "";
+ return;
+ }
+ }
let firstImageUrl: string | null = null;
// 记录本次上传开始时的节点数量,用于计算新图片位置
@@ -13802,6 +13973,10 @@ export function CanvasChatPanel({
message.role === "user"
? collectMessageImageAttachments(message as any)
: [];
+ const userVideoAttachments =
+ message.role === "user"
+ ? collectMessageVideoAttachments(message as any)
+ : [];
const assistantTextParts = message.role === "assistant"
? (message.parts || []).filter((part: any) => part.type === "text" && part.text)
: [];
@@ -13954,6 +14129,25 @@ export function CanvasChatPanel({
);
});
+ userVideoAttachments.forEach((attachment, idx) => {
+ renderedUserParts.push(
+
+
+
+ {attachment.name || "参考视频"}
+
+
+ );
+ });
+
message.parts?.forEach((part: any, idx: number) => {
if (part.type === 'text' && part.text) {
const text = String(part.text)
@@ -15619,8 +15813,8 @@ export function CanvasChatPanel({
className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-dashed border-[#d9dde4] bg-[#f3f3f4] text-[#8f97a3] transition-colors hover:border-[#c6ccd6] hover:bg-[#ececee] hover:text-[#20242a] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/12 dark:bg-white/8 dark:text-zinc-400 dark:hover:border-white/20 dark:hover:bg-white/12 dark:hover:text-white"
onClick={() => fileInputRef.current?.click()}
disabled={isSubmitting}
- title="添加图片"
- aria-label="添加图片"
+ title={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
+ aria-label={supportsAtlasVideoReference ? "添加图片或参考视频" : "添加图片"}
>
{composerReferenceUploadProgress ? (
@@ -15926,7 +16120,13 @@ export function CanvasChatPanel({
;
imageInputKey?: string;
imageInputMode?: "array" | "single";
+ videoClipInput?: {
+ key: string;
+ maxItems: number;
+ required: boolean;
+ itemDefaults: Record;
+ };
resolutionKey?: string;
aspectRatioKey?: string;
outputCountKey?: string;
aspectRatioValueMap: Record;
maxReferenceImages: number;
+ maxVideoReferences: number;
maxImages: number;
defaults: Record;
enums: Record>;
@@ -196,6 +203,39 @@ function getImageInputs(properties: JsonRecord, requiredFields: string[]) {
.sort((a, b) => Number(b.required) - Number(a.required));
}
+function getVideoClipInput(properties: JsonRecord, requiredFields: string[]) {
+ const entry = Object.entries(properties).find(([key, property]) => {
+ const normalizedKey = key.toLowerCase();
+ const record = asRecord(property);
+ const itemProperties = asRecord(asRecord(record.items).properties);
+ return (
+ record.type === "array" &&
+ normalizedKey.includes("video") &&
+ ("url" in itemProperties || normalizedKey.includes("clip"))
+ );
+ });
+ if (!entry) return undefined;
+
+ const [key, property] = entry;
+ const record = asRecord(property);
+ const itemProperties = asRecord(asRecord(record.items).properties);
+ const itemDefaults: Record = {};
+ for (const [itemKey, itemProperty] of Object.entries(itemProperties)) {
+ if (itemKey === "url") continue;
+ const value = asRecord(itemProperty).default;
+ if (["string", "number", "boolean"].includes(typeof value)) {
+ itemDefaults[itemKey] = value;
+ }
+ }
+
+ return {
+ key,
+ maxItems: Math.max(1, Math.min(4, Number(record.maxItems || 1))),
+ required: requiredFields.includes(key),
+ itemDefaults,
+ };
+}
+
function getEnumValues(property: unknown) {
const record = asRecord(property);
const values = Array.isArray(record.enum) ? record.enum : [];
@@ -396,6 +436,7 @@ export function createAtlasCloudImagePreset(
const properties = asRecord(input.properties);
const requiredFields = uniqueStrings(Array.isArray(input.required) ? input.required : []);
const imageInputs = getImageInputs(properties, requiredFields);
+ const videoClipInput = getVideoClipInput(properties, requiredFields);
const imageInputKey = imageInputs[0]?.key;
const resolutionKey = RESOLUTION_KEYS.find((key) => {
if (!(key in properties)) return false;
@@ -420,6 +461,7 @@ export function createAtlasCloudImagePreset(
14,
imageInputs.reduce((total, input) => total + input.maxItems, 0)
);
+ const maxVideoReferences = videoClipInput?.maxItems || 0;
const maxImages = deriveMaxImages(properties, outputCountKey);
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
@@ -436,6 +478,7 @@ export function createAtlasCloudImagePreset(
categories.some((value) => value.includes("image-to-image"))
? ["image-edit", "reference-images"]
: []),
+ ...(videoClipInput ? ["reference-video"] : []),
];
if (mediaKind === "image" && supportedModes.length === 0) {
supportedModes.push("text-to-image");
@@ -449,11 +492,13 @@ export function createAtlasCloudImagePreset(
imageInputs,
imageInputKey,
imageInputMode: imageProperty.type === "array" ? "array" : "single",
+ videoClipInput,
resolutionKey,
aspectRatioKey,
outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map,
maxReferenceImages,
+ maxVideoReferences,
maxImages,
defaults: buildScalarDefaults(properties),
enums: buildEnums(properties),
@@ -495,7 +540,10 @@ export function createAtlasCloudImagePreset(
mediaType: mediaKind,
supportedModes,
maxReferenceImages,
+ maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1,
+ supportsVideoReference: maxVideoReferences > 0,
+ requiresVideoReference: videoClipInput?.required === true,
provider: "atlascloud",
schemaUrl: catalogModel.schema || null,
},
@@ -509,6 +557,7 @@ export function createAtlasCloudImagePreset(
apiFormat: "atlascloud-unified-image",
maxImages,
maxReferenceImages,
+ maxVideoReferences,
supportsSize,
supports1K: supportedImageSizes.includes("1K"),
supports2K: supportedImageSizes.includes("2K"),
diff --git a/lib/ai/atlascloud-request.ts b/lib/ai/atlascloud-request.ts
new file mode 100644
index 000000000..4607c4853
--- /dev/null
+++ b/lib/ai/atlascloud-request.ts
@@ -0,0 +1,209 @@
+import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
+
+export interface AtlasCloudImageParams {
+ aspectRatio?: string;
+ imageSize?: string;
+ imageCount?: number;
+ negativePrompt?: string;
+ promptExtend?: boolean;
+ quality?: string;
+ seed?: number;
+ watermark?: boolean;
+}
+
+export interface AtlasCloudModelConfig {
+ maxReferenceImages?: number;
+ maxVideoReferences?: number;
+ supportedAspectRatios?: string[];
+ supportedImageSizes?: string[];
+ atlasCloud?: AtlasCloudRuntimeConfig;
+}
+
+function normalizeAspectRatio(
+ requested: string | undefined,
+ supported: string[] | undefined
+) {
+ const values = (supported || []).map(String);
+ const normalized = String(requested || "auto").trim().toLowerCase();
+ const exact = values.find((value) => value.toLowerCase() === normalized);
+ if (exact) return exact;
+ return (
+ values.find((value) => value === "auto") ||
+ values.find((value) => value === "1:1") ||
+ values[0] ||
+ "1:1"
+ );
+}
+
+function normalizeImageSize(requested: string | undefined, supported?: string[]) {
+ const values = (supported || []).map((value) => String(value).trim().toUpperCase());
+ const normalized = String(requested || "2K").trim().toUpperCase();
+ if (values.includes(normalized)) return normalized;
+ return values.includes("2K") ? "2K" : values[0] || normalized;
+}
+
+function matchEnumValue(values: Array | undefined, value: unknown) {
+ if (!values?.length) return value;
+ const normalized = String(value || "").trim().toLowerCase();
+ return values.find(
+ (item) => String(item).trim().toLowerCase() === normalized
+ );
+}
+
+function parseRatio(value: string) {
+ const match = value.match(/^(\d+):(\d+)$/);
+ if (!match) return 1;
+ return Number(match[1]) / Number(match[2]);
+}
+
+function pickClosestSizeValue(
+ map: Record>,
+ imageSize: string,
+ aspectRatio: string
+) {
+ const tiers = [imageSize, "2K", "1K", "4K"];
+ const values = tiers.map((tier) => map[tier]).find(Boolean);
+ if (!values) return undefined;
+ if (values[aspectRatio]) return values[aspectRatio];
+ const target = parseRatio(aspectRatio);
+ return Object.entries(values).sort(
+ ([a], [b]) =>
+ Math.abs(Math.log(target / parseRatio(a))) -
+ Math.abs(Math.log(target / parseRatio(b)))
+ )[0]?.[1];
+}
+
+export function createAtlasCloudRequestBody(params: {
+ modelId: string;
+ prompt: string;
+ referenceImages: string[];
+ referenceVideos?: string[];
+ imageParams?: AtlasCloudImageParams;
+ modelConfig: AtlasCloudModelConfig;
+}) {
+ const runtime = params.modelConfig.atlasCloud;
+ if (!runtime) {
+ throw new Error("Atlas Cloud 模型运行参数缺失,请重新同步模型");
+ }
+ const body: Record = { ...runtime.defaults };
+ if (
+ runtime.propertyNames.includes("model") ||
+ runtime.requiredFields.includes("model")
+ ) {
+ body.model = params.modelId;
+ }
+ if (
+ runtime.propertyNames.includes("prompt") ||
+ runtime.requiredFields.includes("prompt")
+ ) {
+ body.prompt = params.prompt;
+ }
+
+ const aspectRatio = normalizeAspectRatio(
+ params.imageParams?.aspectRatio,
+ params.modelConfig.supportedAspectRatios
+ );
+ const imageSize = normalizeImageSize(
+ params.imageParams?.imageSize,
+ params.modelConfig.supportedImageSizes
+ );
+
+ if (runtime.aspectRatioKey) {
+ body[runtime.aspectRatioKey] =
+ runtime.aspectRatioValueMap?.[aspectRatio] ||
+ matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) ||
+ aspectRatio;
+ }
+ if (runtime.resolutionKey) {
+ body[runtime.resolutionKey] =
+ matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
+ matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
+ imageSize.toLowerCase();
+ } else {
+ const sizeValue = pickClosestSizeValue(
+ runtime.sizeValueMap,
+ imageSize,
+ aspectRatio === "auto" ? "1:1" : aspectRatio
+ );
+ if (sizeValue) body.size = sizeValue;
+ }
+
+ const imageInputs = runtime.imageInputs?.length
+ ? runtime.imageInputs
+ : runtime.imageInputKey
+ ? [
+ {
+ key: runtime.imageInputKey,
+ mode: runtime.imageInputMode || ("single" as const),
+ maxItems: Math.max(1, runtime.maxReferenceImages || 1),
+ required: runtime.requiredFields.includes(runtime.imageInputKey),
+ },
+ ]
+ : [];
+ let referenceCursor = 0;
+ for (const input of imageInputs) {
+ const values = params.referenceImages.slice(
+ referenceCursor,
+ referenceCursor + input.maxItems
+ );
+ if (values.length > 0) {
+ body[input.key] = input.mode === "single" ? values[0] : values;
+ referenceCursor += values.length;
+ } else if (input.required) {
+ throw new Error(
+ `该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
+ );
+ }
+ }
+ if (imageInputs.length === 0 && params.referenceImages.length > 0) {
+ throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
+ }
+
+ const referenceVideos = params.referenceVideos || [];
+ if (runtime.videoClipInput) {
+ const values = referenceVideos.slice(0, runtime.videoClipInput.maxItems);
+ if (values.length > 0) {
+ body[runtime.videoClipInput.key] = values.map((url) => ({
+ ...runtime.videoClipInput?.itemDefaults,
+ url,
+ }));
+ } else if (runtime.videoClipInput.required) {
+ throw new Error("该 Atlas Cloud 模型至少需要 1 个参考视频");
+ }
+ } else if (referenceVideos.length > 0) {
+ throw new Error("当前 Atlas Cloud 模型不支持参考视频");
+ }
+
+ if (runtime.outputCountKey) {
+ const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
+ body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
+ }
+ const optionalParams: Array<[string, unknown]> = [
+ ["negative_prompt", params.imageParams?.negativePrompt],
+ ["prompt_extend", params.imageParams?.promptExtend],
+ ["quality", params.imageParams?.quality],
+ ["seed", params.imageParams?.seed],
+ ["watermark", params.imageParams?.watermark],
+ ];
+ for (const [key, value] of optionalParams) {
+ if (value !== undefined && runtime.propertyNames.includes(key)) {
+ body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
+ }
+ }
+ if (runtime.propertyNames.includes("enable_sync_mode")) {
+ body.enable_sync_mode = false;
+ }
+ if (runtime.propertyNames.includes("enable_base64_output")) {
+ body.enable_base64_output = false;
+ }
+
+ const unsupportedRequiredFields = runtime.requiredFields.filter(
+ (key) => body[key] === undefined
+ );
+ if (unsupportedRequiredFields.length > 0) {
+ throw new Error(
+ `当前界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
+ );
+ }
+ return { body, imageSize };
+}
diff --git a/lib/ai/providers/atlascloud.ts b/lib/ai/providers/atlascloud.ts
index e55f82e09..81a0c79f2 100644
--- a/lib/ai/providers/atlascloud.ts
+++ b/lib/ai/providers/atlascloud.ts
@@ -1,10 +1,12 @@
import "server-only";
import sharp from "sharp";
+import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
- ATLASCLOUD_BASE_URL,
- type AtlasCloudRuntimeConfig,
-} from "@/lib/ai/atlascloud-models";
+ createAtlasCloudRequestBody,
+ type AtlasCloudImageParams,
+ type AtlasCloudModelConfig,
+} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
@@ -13,24 +15,6 @@ interface AtlasCloudConfig {
baseURL?: string;
}
-interface AtlasCloudImageParams {
- aspectRatio?: string;
- imageSize?: string;
- imageCount?: number;
- negativePrompt?: string;
- promptExtend?: boolean;
- quality?: string;
- seed?: number;
- watermark?: boolean;
-}
-
-interface AtlasCloudModelConfig {
- maxReferenceImages?: number;
- supportedAspectRatios?: string[];
- supportedImageSizes?: string[];
- atlasCloud?: AtlasCloudRuntimeConfig;
-}
-
interface UploadedImage {
thumbnail: string;
original: string;
@@ -116,18 +100,19 @@ function extractOutputUrls(payload: any) {
return urls;
}
-function extractPromptAndImages(
+function extractPromptAndMedia(
messages: Array<{ role: string; content?: any; parts?: any[] }>
) {
const message = messages.filter((item) => item.role === "user").pop();
if (!message) throw new Error("未找到用户消息");
const texts: string[] = [];
const imageUrls: string[] = [];
+ const videoUrls: string[] = [];
const pushText = (value: unknown) => {
if (typeof value === "string" && value.trim()) texts.push(value.trim());
};
- const pushImage = (value: unknown) => {
+ const pushMedia = (value: unknown, mediaType?: unknown) => {
const url = String(
typeof value === "string"
? value
@@ -137,24 +122,31 @@ function extractPromptAndImages(
(value as any)?.src ||
""
).trim();
- if (
- url &&
- (/^https?:\/\//i.test(url) || url.startsWith("data:image/")) &&
- !imageUrls.includes(url)
- ) {
- imageUrls.push(url);
- }
+ if (!url || !/^(?:https?:\/\/|data:)/i.test(url)) return;
+ const normalizedMediaType = String(mediaType || "").toLowerCase();
+ const isVideo =
+ normalizedMediaType.startsWith("video/") ||
+ url.startsWith("data:video/") ||
+ /\.(?:mp4|mov|webm|m4v)(?:[?#].*)?$/i.test(url);
+ const target = isVideo ? videoUrls : imageUrls;
+ if (!target.includes(url)) target.push(url);
};
const visitPart = (part: any) => {
if (!part) return;
if (typeof part === "string") return pushText(part);
if (part.type === "text" || typeof part.text === "string") pushText(part.text);
- if (part.type === "image_url") pushImage(part.image_url);
- if (part.type === "image") pushImage(part.image || part.url);
- if (part.type === "file") pushImage(part.url || part.file || part.data);
+ if (part.type === "image_url") pushMedia(part.image_url, "image/jpeg");
+ if (part.type === "image") pushMedia(part.image || part.url, "image/jpeg");
+ if (part.type === "file") {
+ pushMedia(
+ part.url || part.file || part.data,
+ part.mediaType || part.contentType || part.mimeType
+ );
+ }
if (part.inlineData?.data) {
- pushImage(
- `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`
+ pushMedia(
+ `data:${part.inlineData.mimeType || "image/png"};base64,${part.inlineData.data}`,
+ part.inlineData.mimeType
);
}
};
@@ -165,31 +157,31 @@ function extractPromptAndImages(
const prompt = texts.join("\n").trim();
if (!prompt) throw new Error("请输入图片生成或编辑要求");
- return { prompt, imageUrls };
+ return { prompt, imageUrls, videoUrls };
}
function parseDataUrl(value: string) {
const match = value.match(/^data:([^;,]+);base64,(.+)$/s);
- if (!match) throw new Error("Atlas Cloud 参考图 Base64 格式无效");
+ if (!match) throw new Error("Atlas Cloud 参考素材 Base64 格式无效");
return {
mimeType: match[1] || "image/png",
buffer: Buffer.from(match[2] || "", "base64"),
};
}
-async function loadImageSource(imageUrl: string) {
- if (imageUrl.startsWith("data:")) return parseDataUrl(imageUrl);
- const response = await fetch(imageUrl, {
+async function loadMediaSource(mediaUrl: string) {
+ if (mediaUrl.startsWith("data:")) return parseDataUrl(mediaUrl);
+ const response = await fetch(mediaUrl, {
cache: "no-store",
redirect: "follow",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
headers: {
- Accept: "image/avif,image/webp,image/png,image/jpeg,image/*,*/*;q=0.8",
+ Accept: "image/*,video/*,*/*;q=0.8",
"User-Agent": "yedir-ai-atlascloud/1.0",
},
});
if (!response.ok) {
- throw new Error(`Atlas Cloud 参考图下载失败(HTTP ${response.status})`);
+ throw new Error(`Atlas Cloud 参考素材下载失败(HTTP ${response.status})`);
}
return {
mimeType: response.headers.get("content-type")?.split(";")[0] || "image/png",
@@ -201,16 +193,19 @@ function detectExtension(mimeType: string) {
if (/jpe?g/i.test(mimeType)) return "jpg";
if (/webp/i.test(mimeType)) return "webp";
if (/gif/i.test(mimeType)) return "gif";
+ if (/quicktime/i.test(mimeType)) return "mov";
+ if (/webm/i.test(mimeType)) return "webm";
+ if (/mp4/i.test(mimeType)) return "mp4";
return "png";
}
-async function uploadReferenceImage(
+async function uploadReferenceMedia(
baseURL: string | undefined,
apiKey: string,
- imageUrl: string
+ mediaUrl: string
) {
- if (/^https:\/\//i.test(imageUrl)) return imageUrl;
- const source = await loadImageSource(imageUrl);
+ if (/^https:\/\//i.test(mediaUrl)) return mediaUrl;
+ const source = await loadMediaSource(mediaUrl);
const form = new FormData();
form.append(
"file",
@@ -226,171 +221,17 @@ async function uploadReferenceImage(
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw new Error(
- `${extractErrorMessage(payload, "Atlas Cloud 参考图上传失败")}(HTTP ${response.status})`
+ `${extractErrorMessage(payload, "Atlas Cloud 参考素材上传失败")}(HTTP ${response.status})`
);
}
const data = payload?.data && typeof payload.data === "object" ? payload.data : payload;
const url = String(
data?.url || data?.file_url || data?.fileUrl || data?.media_url || data?.mediaUrl || ""
).trim();
- if (!url) throw new Error("Atlas Cloud 参考图上传成功但未返回文件地址");
+ if (!url) throw new Error("Atlas Cloud 参考素材上传成功但未返回文件地址");
return url;
}
-function normalizeAspectRatio(
- requested: string | undefined,
- supported: string[] | undefined
-) {
- const values = (supported || []).map(String);
- const normalized = String(requested || "auto").trim().toLowerCase();
- const exact = values.find((value) => value.toLowerCase() === normalized);
- if (exact) return exact;
- return values.find((value) => value === "auto") || values.find((value) => value === "1:1") || values[0] || "1:1";
-}
-
-function normalizeImageSize(requested: string | undefined, supported?: string[]) {
- const values = (supported || []).map((value) => String(value).trim().toUpperCase());
- const normalized = String(requested || "2K").trim().toUpperCase();
- if (values.includes(normalized)) return normalized;
- return values.includes("2K") ? "2K" : values[0] || normalized;
-}
-
-function matchEnumValue(values: Array | undefined, value: unknown) {
- if (!values?.length) return value;
- const normalized = String(value || "").trim().toLowerCase();
- return values.find((item) => String(item).trim().toLowerCase() === normalized);
-}
-
-function parseRatio(value: string) {
- const match = value.match(/^(\d+):(\d+)$/);
- if (!match) return 1;
- return Number(match[1]) / Number(match[2]);
-}
-
-function pickClosestSizeValue(
- map: Record>,
- imageSize: string,
- aspectRatio: string
-) {
- const tiers = [imageSize, "2K", "1K", "4K"];
- const values = tiers.map((tier) => map[tier]).find(Boolean);
- if (!values) return undefined;
- if (values[aspectRatio]) return values[aspectRatio];
- const target = parseRatio(aspectRatio);
- return Object.entries(values).sort(
- ([a], [b]) =>
- Math.abs(Math.log(target / parseRatio(a))) -
- Math.abs(Math.log(target / parseRatio(b)))
- )[0]?.[1];
-}
-
-function createRequestBody(params: {
- modelId: string;
- prompt: string;
- referenceImages: string[];
- imageParams?: AtlasCloudImageParams;
- modelConfig: AtlasCloudModelConfig;
-}) {
- const runtime = params.modelConfig.atlasCloud;
- if (!runtime) throw new Error("Atlas Cloud 模型运行参数缺失,请重新同步模型");
- const body: Record = {
- ...runtime.defaults,
- };
- if (runtime.propertyNames.includes("model")) body.model = params.modelId;
- if (runtime.propertyNames.includes("prompt")) body.prompt = params.prompt;
- const aspectRatio = normalizeAspectRatio(
- params.imageParams?.aspectRatio,
- params.modelConfig.supportedAspectRatios
- );
- const imageSize = normalizeImageSize(
- params.imageParams?.imageSize,
- params.modelConfig.supportedImageSizes
- );
-
- if (runtime.aspectRatioKey) {
- body[runtime.aspectRatioKey] =
- runtime.aspectRatioValueMap?.[aspectRatio] ||
- matchEnumValue(runtime.enums[runtime.aspectRatioKey], aspectRatio) || aspectRatio;
- }
- if (runtime.resolutionKey) {
- const resolutionValue =
- matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize.toLowerCase()) ||
- matchEnumValue(runtime.enums[runtime.resolutionKey], imageSize) ||
- imageSize.toLowerCase();
- body[runtime.resolutionKey] = resolutionValue;
- } else {
- const sizeValue = pickClosestSizeValue(
- runtime.sizeValueMap,
- imageSize,
- aspectRatio === "auto" ? "1:1" : aspectRatio
- );
- if (sizeValue) body.size = sizeValue;
- }
-
- const imageInputs = runtime.imageInputs?.length
- ? runtime.imageInputs
- : runtime.imageInputKey
- ? [
- {
- key: runtime.imageInputKey,
- mode: runtime.imageInputMode || "single",
- maxItems: Math.max(1, runtime.maxReferenceImages || 1),
- required: runtime.requiredFields.includes(runtime.imageInputKey),
- },
- ]
- : [];
- let referenceCursor = 0;
- for (const input of imageInputs) {
- const values = params.referenceImages.slice(
- referenceCursor,
- referenceCursor + input.maxItems
- );
- if (values.length > 0) {
- body[input.key] = input.mode === "single" ? values[0] : values;
- referenceCursor += values.length;
- } else if (input.required) {
- throw new Error(
- `该 Atlas Cloud 模型至少需要 ${referenceCursor + 1} 张参考图`
- );
- }
- }
- if (imageInputs.length === 0 && params.referenceImages.length > 0) {
- throw new Error("当前 Atlas Cloud 模型不支持参考图,请选择图片编辑模型");
- }
-
- if (runtime.outputCountKey) {
- const requestedCount = Math.max(1, params.imageParams?.imageCount || 1);
- body[runtime.outputCountKey] = Math.min(runtime.maxImages, requestedCount);
- }
- const optionalParams: Array<[string, unknown]> = [
- ["negative_prompt", params.imageParams?.negativePrompt],
- ["prompt_extend", params.imageParams?.promptExtend],
- ["quality", params.imageParams?.quality],
- ["seed", params.imageParams?.seed],
- ["watermark", params.imageParams?.watermark],
- ];
- for (const [key, value] of optionalParams) {
- if (value !== undefined && runtime.propertyNames.includes(key)) {
- body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
- }
- }
- if (runtime.propertyNames.includes("enable_sync_mode")) {
- body.enable_sync_mode = false;
- }
- if (runtime.propertyNames.includes("enable_base64_output")) {
- body.enable_base64_output = false;
- }
- const unsupportedRequiredFields = runtime.requiredFields.filter(
- (key) => body[key] === undefined
- );
- if (unsupportedRequiredFields.length > 0) {
- throw new Error(
- `当前界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
- );
- }
- return { body, imageSize };
-}
-
async function submitTask(params: {
apiKey: string;
baseURL?: string;
@@ -540,7 +381,9 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
if (!modelConfig.atlasCloud) {
throw new Error("Atlas Cloud 模型能力配置缺失,请在系统设置中重新同步模型");
}
- const { prompt, imageUrls } = extractPromptAndImages(options.messages);
+ const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(
+ options.messages
+ );
const maxReferenceImages = Math.max(
0,
Math.min(14, modelConfig.maxReferenceImages ?? modelConfig.atlasCloud.maxReferenceImages)
@@ -548,12 +391,27 @@ export function createAtlasCloud({ apiKey, baseURL }: AtlasCloudConfig) {
const referenceImages = await Promise.all(
imageUrls
.slice(0, maxReferenceImages)
- .map((url) => uploadReferenceImage(baseURL, apiKey, url))
+ .map((url) => uploadReferenceMedia(baseURL, apiKey, url))
);
- const { body, imageSize } = createRequestBody({
+ const maxVideoReferences = Math.max(
+ 0,
+ Math.min(
+ 4,
+ modelConfig.maxVideoReferences ??
+ modelConfig.atlasCloud.maxVideoReferences ??
+ 0
+ )
+ );
+ const referenceVideos = await Promise.all(
+ videoUrls
+ .slice(0, maxVideoReferences)
+ .map((url) => uploadReferenceMedia(baseURL, apiKey, url))
+ );
+ const { body, imageSize } = createAtlasCloudRequestBody({
modelId: runtimeModelId,
prompt,
referenceImages,
+ referenceVideos,
imageParams: options.imageParams,
modelConfig,
});
diff --git a/scripts/sync-atlascloud-models.ts b/scripts/sync-atlascloud-models.ts
new file mode 100644
index 000000000..0455116e6
--- /dev/null
+++ b/scripts/sync-atlascloud-models.ts
@@ -0,0 +1,92 @@
+import { and, eq } from "drizzle-orm";
+import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
+import { db } from "../lib/db";
+import { aiModel, aiProvider } from "../lib/db/schema";
+
+async function main() {
+ const providers = await db
+ .select()
+ .from(aiProvider)
+ .where(and(eq(aiProvider.type, "atlascloud"), eq(aiProvider.isEnabled, true)));
+ if (providers.length === 0) {
+ throw new Error("没有找到已启用的 AtlasCloud 服务商");
+ }
+
+ const catalog = await fetchAtlasCloudImagePresets();
+ if (catalog.errors.length > 0) {
+ throw new Error(
+ `AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
+ .map((item) => item.modelId)
+ .join(", ")}`
+ );
+ }
+
+ const summaries = [];
+ for (const provider of providers) {
+ let added = 0;
+ let updated = 0;
+ for (const preset of catalog.presets) {
+ const existing = await db.query.aiModel.findFirst({
+ where: and(
+ eq(aiModel.providerId, provider.id),
+ eq(aiModel.modelId, preset.modelId)
+ ),
+ });
+ const values = {
+ displayName: preset.displayName,
+ avatarUrl: preset.avatarUrl || null,
+ description: preset.description,
+ supportsVision: preset.supportsVision,
+ supportsImageGen: preset.supportsImageGen,
+ isEnabled: true,
+ isHidden: false,
+ sortOrder: preset.sortOrder,
+ capabilities: preset.capabilities,
+ defaultParams: preset.defaultParams,
+ imageGenConfig: preset.imageGenConfig,
+ updatedAt: new Date(),
+ };
+
+ if (existing) {
+ await db
+ .update(aiModel)
+ .set(values)
+ .where(eq(aiModel.id, existing.id));
+ updated += 1;
+ } else {
+ await db.insert(aiModel).values({
+ providerId: provider.id,
+ modelId: preset.modelId,
+ ...values,
+ isDefault: false,
+ isSubModel: false,
+ parentModelId: null,
+ });
+ added += 1;
+ }
+ }
+ summaries.push({
+ providerId: provider.id,
+ providerName: provider.name,
+ added,
+ updated,
+ });
+ }
+
+ console.log(
+ JSON.stringify(
+ {
+ catalogModels: catalog.catalogCount,
+ availableModels: catalog.presets.length,
+ providers: summaries,
+ },
+ null,
+ 2
+ )
+ );
+}
+
+void main().catch((error) => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+});
diff --git a/scripts/verify-atlascloud-frontend.ts b/scripts/verify-atlascloud-frontend.ts
new file mode 100644
index 000000000..2bfe269bd
--- /dev/null
+++ b/scripts/verify-atlascloud-frontend.ts
@@ -0,0 +1,204 @@
+import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
+import { createAtlasCloudRequestBody } from "../lib/ai/atlascloud-request";
+import {
+ is3DModelDescriptor,
+ isImageGenerationModelDescriptor,
+ isVideoModelDescriptor,
+} from "../lib/ai/model-classification";
+
+const FAKE_IMAGES = Array.from(
+ { length: 14 },
+ (_, index) => `https://example.com/reference-${index + 1}.png`
+);
+const FAKE_VIDEOS = Array.from(
+ { length: 4 },
+ (_, index) => `https://example.com/reference-${index + 1}.mp4`
+);
+
+type Failure = {
+ modelId: string;
+ scenario: string;
+ error: string;
+};
+
+function validateBody(
+ body: Record,
+ runtime: any
+) {
+ const missing = runtime.requiredFields.filter(
+ (key: string) => body[key] === undefined
+ );
+ if (missing.length > 0) {
+ throw new Error(`缺少必填参数:${missing.join(", ")}`);
+ }
+ for (const [key, values] of Object.entries(
+ runtime.enums as Record>
+ )) {
+ if (body[key] === undefined || !values.length) continue;
+ if (!values.some((value) => String(value) === String(body[key]))) {
+ throw new Error(`${key}=${String(body[key])} 不在官方枚举中`);
+ }
+ }
+ if (runtime.videoClipInput && body[runtime.videoClipInput.key] !== undefined) {
+ const clips = body[runtime.videoClipInput.key];
+ if (!Array.isArray(clips) || clips.length === 0) {
+ throw new Error("video_clips 请求格式无效");
+ }
+ for (const clip of clips) {
+ if (!clip || typeof clip !== "object" || !("url" in clip)) {
+ throw new Error("video_clips 缺少 url");
+ }
+ }
+ }
+}
+
+async function main() {
+ const catalog = await fetchAtlasCloudImagePresets();
+ const failures: Failure[] = catalog.errors.map((item) => ({
+ modelId: item.modelId,
+ scenario: "catalog-schema",
+ error: item.error,
+ }));
+ let simulatedCases = 0;
+ let imageModels = 0;
+ let model3dModels = 0;
+ let videoReferenceModels = 0;
+
+ for (const preset of catalog.presets) {
+ const capabilities = preset.capabilities as Record;
+ const descriptor = {
+ providerType: "atlascloud",
+ modelId: preset.modelId,
+ capabilities,
+ supportsImageGen: preset.supportsImageGen,
+ };
+ const mediaType = String(capabilities.mediaType || "");
+
+ if (mediaType === "3d") {
+ model3dModels += 1;
+ simulatedCases += 1;
+ if (
+ !is3DModelDescriptor(descriptor) ||
+ isImageGenerationModelDescriptor(descriptor) ||
+ isVideoModelDescriptor(descriptor)
+ ) {
+ failures.push({
+ modelId: preset.modelId,
+ scenario: "3d-classification",
+ error: "3D 模型被错误归入图片或视频模型",
+ });
+ }
+ continue;
+ }
+
+ imageModels += 1;
+ if (
+ !isImageGenerationModelDescriptor(descriptor) ||
+ is3DModelDescriptor(descriptor) ||
+ isVideoModelDescriptor(descriptor)
+ ) {
+ failures.push({
+ modelId: preset.modelId,
+ scenario: "image-classification",
+ error: "图片模型分类不正确",
+ });
+ continue;
+ }
+
+ const imageGenConfig = preset.imageGenConfig as Record;
+ const runtime = imageGenConfig.atlasCloud;
+ if (!runtime) {
+ failures.push({
+ modelId: preset.modelId,
+ scenario: "runtime-config",
+ error: "缺少 AtlasCloud 运行配置",
+ });
+ continue;
+ }
+
+ const ratios = imageGenConfig.supportedAspectRatios?.length
+ ? imageGenConfig.supportedAspectRatios
+ : [String(preset.defaultParams.aspectRatio || "1:1")];
+ const sizes = imageGenConfig.supportedImageSizes?.length
+ ? imageGenConfig.supportedImageSizes
+ : [String(preset.defaultParams.imageSize || "1K")];
+ const counts = runtime.outputCountKey && runtime.maxImages > 1
+ ? [1, runtime.maxImages]
+ : [1];
+ const requiredImageCount = runtime.imageInputs
+ .filter((input: any) => input.required)
+ .reduce((total: number, input: any) => total + input.maxItems, 0);
+ const imageReferenceCounts = new Set([
+ requiredImageCount,
+ ...(runtime.maxReferenceImages > requiredImageCount
+ ? [runtime.maxReferenceImages]
+ : []),
+ ]);
+ const requiredVideoCount = runtime.videoClipInput?.required
+ ? runtime.videoClipInput.maxItems
+ : 0;
+ const videoReferenceCounts = new Set([
+ requiredVideoCount,
+ ...(runtime.maxVideoReferences > requiredVideoCount
+ ? [runtime.maxVideoReferences]
+ : []),
+ ]);
+ if (runtime.maxVideoReferences > 0) videoReferenceModels += 1;
+
+ for (const ratio of ratios) {
+ for (const size of sizes) {
+ for (const imageCount of counts) {
+ for (const referenceImageCount of imageReferenceCounts) {
+ for (const referenceVideoCount of videoReferenceCounts) {
+ const scenario = [
+ `ratio=${ratio}`,
+ `size=${size}`,
+ `count=${imageCount}`,
+ `images=${referenceImageCount}`,
+ `videos=${referenceVideoCount}`,
+ ].join(",");
+ simulatedCases += 1;
+ try {
+ const { body } = createAtlasCloudRequestBody({
+ modelId: preset.modelId,
+ prompt: "AtlasCloud 前端请求模拟测试",
+ referenceImages: FAKE_IMAGES.slice(0, referenceImageCount),
+ referenceVideos: FAKE_VIDEOS.slice(0, referenceVideoCount),
+ imageParams: {
+ aspectRatio: ratio,
+ imageSize: size,
+ imageCount,
+ },
+ modelConfig: imageGenConfig,
+ });
+ validateBody(body, runtime);
+ } catch (error) {
+ failures.push({
+ modelId: preset.modelId,
+ scenario,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ const summary = {
+ catalogModels: catalog.catalogCount,
+ availableModels: catalog.presets.length,
+ imageModels,
+ model3dModels,
+ videoReferenceModels,
+ simulatedCases,
+ passedCases: simulatedCases - failures.length,
+ failedCases: failures.length,
+ failures,
+ };
+ console.log(JSON.stringify(summary, null, 2));
+ if (failures.length > 0) process.exitCode = 1;
+}
+
+void main();
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index c0b50796b..9bbf17e91 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -3,6 +3,7 @@ import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
} from "@/lib/ai/atlascloud-models";
+import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
@@ -120,4 +121,71 @@ test.describe("AI model media classification", () => {
expect(isVideoModelDescriptor(videoDescriptor)).toBe(true);
expect(isImageGenerationModelDescriptor(videoDescriptor)).toBe(false);
});
+
+ test("maps Atlas reference-video image models to a valid frontend request", () => {
+ const preset = createAtlasCloudImagePreset(
+ {
+ model: "google/nano-banana-2/reference-to-image",
+ type: "Image",
+ displayName: "Nano Banana 2 Reference to Image",
+ categories: ["IMAGE-TO-IMAGE"],
+ },
+ {
+ paths: {
+ "/api/v1/model/generateImage": { post: {} },
+ "/api/v1/model/prediction/{request_id}": { get: {} },
+ },
+ components: {
+ schemas: {
+ Input: {
+ type: "object",
+ properties: {
+ model: { type: "string" },
+ prompt: { type: "string" },
+ resolution: { type: "string", enum: ["1k", "2k", "4k"] },
+ video_clips: {
+ type: "array",
+ minItems: 1,
+ maxItems: 1,
+ items: {
+ type: "object",
+ properties: {
+ url: { type: "string" },
+ start: { type: "number", default: 0 },
+ ends: { type: "number", default: 0 },
+ fps: { type: "number", default: 1 },
+ },
+ },
+ },
+ },
+ required: ["model", "prompt", "video_clips"],
+ },
+ },
+ },
+ }
+ );
+
+ expect(preset.capabilities).toMatchObject({
+ mediaType: "image",
+ supportsVideoReference: true,
+ requiresVideoReference: true,
+ maxVideoReferences: 1,
+ });
+ const { body } = createAtlasCloudRequestBody({
+ modelId: preset.modelId,
+ prompt: "参考视频生成商品图",
+ referenceImages: [],
+ referenceVideos: ["https://example.com/reference.mp4"],
+ imageParams: { imageSize: "2K", aspectRatio: "1:1" },
+ modelConfig: preset.imageGenConfig,
+ });
+ expect(body.video_clips).toEqual([
+ {
+ url: "https://example.com/reference.mp4",
+ start: 0,
+ ends: 0,
+ fps: 1,
+ },
+ ]);
+ });
});
From 1a91b8ada93f124a4dc8179dd12da84abc3d6ee5 Mon Sep 17 00:00:00 2001
From: 134
Date: Wed, 22 Jul 2026 10:34:45 +0800
Subject: [PATCH 05/83] fix: hide stale AtlasCloud catalog entries
---
scripts/sync-atlascloud-models.ts | 28 +++++++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/scripts/sync-atlascloud-models.ts b/scripts/sync-atlascloud-models.ts
index 0455116e6..1de03d9ac 100644
--- a/scripts/sync-atlascloud-models.ts
+++ b/scripts/sync-atlascloud-models.ts
@@ -1,4 +1,4 @@
-import { and, eq } from "drizzle-orm";
+import { and, eq, inArray } from "drizzle-orm";
import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema";
@@ -22,6 +22,9 @@ async function main() {
}
const summaries = [];
+ const catalogModelIds = new Set(
+ catalog.presets.map((preset) => preset.modelId)
+ );
for (const provider of providers) {
let added = 0;
let updated = 0;
@@ -65,11 +68,34 @@ async function main() {
added += 1;
}
}
+ const providerModels = await db
+ .select({ modelId: aiModel.modelId })
+ .from(aiModel)
+ .where(eq(aiModel.providerId, provider.id));
+ const staleModelIds = providerModels
+ .map((model) => model.modelId)
+ .filter((modelId) => !catalogModelIds.has(modelId));
+ if (staleModelIds.length > 0) {
+ await db
+ .update(aiModel)
+ .set({
+ isEnabled: false,
+ isHidden: true,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(aiModel.providerId, provider.id),
+ inArray(aiModel.modelId, staleModelIds)
+ )
+ );
+ }
summaries.push({
providerId: provider.id,
providerName: provider.name,
added,
updated,
+ disabledStale: staleModelIds.length,
});
}
From 6ccf04a786a33ef4b2bb5e80b2a4263997679664 Mon Sep 17 00:00:00 2001
From: 134
Date: Wed, 22 Jul 2026 10:53:19 +0800
Subject: [PATCH 06/83] fix: preserve AtlasCloud model aliases
---
app/api/admin/atlascloud-models/route.ts | 11 +++++++++--
lib/ai/atlascloud-models.ts | 11 +++++++++++
scripts/sync-atlascloud-models.ts | 11 +++++++++--
tests/routes/model-classification.test.ts | 18 ++++++++++++++++++
4 files changed, 47 insertions(+), 4 deletions(-)
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
index 74636b29d..44ae614a1 100644
--- a/app/api/admin/atlascloud-models/route.ts
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -1,6 +1,9 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
-import { fetchAtlasCloudImagePresets } from "@/lib/ai/atlascloud-models";
+import {
+ fetchAtlasCloudImagePresets,
+ resolveAtlasCloudDisplayName,
+} from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
import { type AiModel, aiModel } from "@/lib/db/schema";
@@ -75,7 +78,11 @@ export async function POST(request: Request) {
),
});
const values = {
- displayName: preset.displayName,
+ displayName: resolveAtlasCloudDisplayName(
+ existing?.displayName,
+ preset.displayName,
+ preset.modelId
+ ),
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
diff --git a/lib/ai/atlascloud-models.ts b/lib/ai/atlascloud-models.ts
index 91998fb70..342865628 100644
--- a/lib/ai/atlascloud-models.ts
+++ b/lib/ai/atlascloud-models.ts
@@ -95,6 +95,17 @@ type JsonRecord = Record;
export type AtlasCloudMediaKind = "image" | "video" | "3d";
+export function resolveAtlasCloudDisplayName(
+ existingDisplayName: unknown,
+ catalogDisplayName: unknown,
+ modelId: string
+) {
+ const existing = String(existingDisplayName || "").trim();
+ if (existing) return existing;
+
+ return String(catalogDisplayName || "").trim() || modelId;
+}
+
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
diff --git a/scripts/sync-atlascloud-models.ts b/scripts/sync-atlascloud-models.ts
index 1de03d9ac..dabcf5309 100644
--- a/scripts/sync-atlascloud-models.ts
+++ b/scripts/sync-atlascloud-models.ts
@@ -1,5 +1,8 @@
import { and, eq, inArray } from "drizzle-orm";
-import { fetchAtlasCloudImagePresets } from "../lib/ai/atlascloud-models";
+import {
+ fetchAtlasCloudImagePresets,
+ resolveAtlasCloudDisplayName,
+} from "../lib/ai/atlascloud-models";
import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema";
@@ -36,7 +39,11 @@ async function main() {
),
});
const values = {
- displayName: preset.displayName,
+ displayName: resolveAtlasCloudDisplayName(
+ existing?.displayName,
+ preset.displayName,
+ preset.modelId
+ ),
avatarUrl: preset.avatarUrl || null,
description: preset.description,
supportsVision: preset.supportsVision,
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index 9bbf17e91..f8b8a697a 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
+ resolveAtlasCloudDisplayName,
} from "@/lib/ai/atlascloud-models";
import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
import {
@@ -19,6 +20,23 @@ const atlasHunyuan3D = {
};
test.describe("AI model media classification", () => {
+ test("preserves administrator-defined AtlasCloud display names", () => {
+ expect(
+ resolveAtlasCloudDisplayName(
+ "视觉 Flux Kontext Pro",
+ "Flux Kontext Pro",
+ "black-forest-labs/flux-kontext-pro"
+ )
+ ).toBe("视觉 Flux Kontext Pro");
+ expect(
+ resolveAtlasCloudDisplayName(
+ "",
+ "Flux Kontext Pro",
+ "black-forest-labs/flux-kontext-pro"
+ )
+ ).toBe("Flux Kontext Pro");
+ });
+
test("keeps multi-reference BananaImage models classified as image models", () => {
const descriptor = {
providerType: "bananaimage",
From 974a4b1b59c602e258f0fec29657b7b2875b8540 Mon Sep 17 00:00:00 2001
From: 134
Date: Wed, 22 Jul 2026 11:42:39 +0800
Subject: [PATCH 07/83] chore: rename hidden model control
---
app/(admin)/settings/admin-settings-client.tsx | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index 328c66183..ed16889a0 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -16464,10 +16464,7 @@ export function AdminSettingsClient() {
})
}
/>
-
+
开启后,该模型不会出现在前端,也不会参与第三方生图
From 3096eb67526085c8bfdbd017fe795cd57f7f44a5 Mon Sep 17 00:00:00 2001
From: 134
Date: Thu, 23 Jul 2026 10:52:29 +0800
Subject: [PATCH 08/83] feat: add AtlasCloud video model support
---
.../settings/admin-settings-client.tsx | 12 +-
app/api/admin/atlascloud-models/route.ts | 48 ++-
lib/ai/atlascloud-models.ts | 281 ++++++++++++++++--
lib/ai/atlascloud-request.ts | 123 ++++++++
lib/ai/providers/atlascloud.ts | 77 ++++-
lib/ai/tools/generate-video.ts | 36 ++-
scripts/sync-atlascloud-models.ts | 71 ++---
tests/routes/model-classification.test.ts | 101 ++++++-
8 files changed, 667 insertions(+), 82 deletions(-)
diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx
index ed16889a0..ce81cfce2 100644
--- a/app/(admin)/settings/admin-settings-client.tsx
+++ b/app/(admin)/settings/admin-settings-client.tsx
@@ -1587,7 +1587,7 @@ const PROVIDER_TYPES = [
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "bananaimage", label: "BananaImage 生图" },
{ value: "visionary", label: "Visionary 生图" },
- { value: "atlascloud", label: "Atlas Cloud 图片 / 3D 模型" },
+ { 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" },
@@ -6473,7 +6473,7 @@ export function AdminSettingsClient() {
} catch (modelError) {
console.error("[Auto Add Atlas Cloud Models] Error:", modelError);
toast.warning(
- 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 图片 / 3D 模型"按钮'
+ 'Atlas Cloud 模型同步失败,请手动点击"同步 Atlas Cloud 模型"按钮'
);
}
}
@@ -7534,7 +7534,7 @@ export function AdminSettingsClient() {
if (
!options?.skipConfirm &&
!confirm(
- "将从 Atlas Cloud 官方目录同步全部开放图片与 3D 模型,并读取每个模型的参数 Schema。同步数量较多,可能需要几十秒。\n\n继续吗?"
+ "将从 Atlas Cloud 官方目录同步图片、视频与 3D 模型,并读取每个模型的参数 Schema。已删除的模型不会被恢复。同步数量较多,可能需要几十秒。\n\n继续吗?"
)
) {
return;
@@ -7553,7 +7553,7 @@ export function AdminSettingsClient() {
const totalSynced =
Number(data.addedCount || 0) + Number(data.updatedCount || 0);
toast.success(
- `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
+ `已同步 ${totalSynced} 个 Atlas Cloud 图片 / 视频 / 3D 模型(官方目录共 ${Number(data.counts?.total || totalSynced)} 个)`
);
mutateModels();
if (data.errors?.length) {
@@ -7561,7 +7561,7 @@ export function AdminSettingsClient() {
}
} catch (error: any) {
console.error("[Quick Add Atlas Cloud Models] Error:", error);
- toast.error(error.message || "同步 Atlas Cloud 图片 / 3D 模型失败");
+ toast.error(error.message || "同步 Atlas Cloud 模型失败");
throw error;
}
};
@@ -15943,7 +15943,7 @@ export function AdminSettingsClient() {
variant="secondary"
>
- 同步 Atlas Cloud 图片 / 3D 模型
+ 同步 Atlas Cloud 模型
)}
{provider.type === "bltcy-video" && (
diff --git a/app/api/admin/atlascloud-models/route.ts b/app/api/admin/atlascloud-models/route.ts
index 44ae614a1..965957638 100644
--- a/app/api/admin/atlascloud-models/route.ts
+++ b/app/api/admin/atlascloud-models/route.ts
@@ -1,8 +1,9 @@
import { and, eq } from "drizzle-orm";
import { NextResponse } from "next/server";
import {
- fetchAtlasCloudImagePresets,
+ fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
import { db } from "@/lib/db";
import { getAiProviderById } from "@/lib/db/queries";
@@ -15,7 +16,7 @@ export const maxDuration = 300;
export async function GET() {
try {
await requireAdmin();
- const result = await fetchAtlasCloudImagePresets();
+ const result = await fetchAtlasCloudModelPresets();
return NextResponse.json({
presets: result.presets,
errors: result.errors.length > 0 ? result.errors : undefined,
@@ -32,7 +33,7 @@ export async function GET() {
error:
error instanceof Error
? error.message
- : "Failed to fetch Atlas Cloud image models",
+ : "Failed to fetch Atlas Cloud models",
},
{ status: 500 }
);
@@ -61,22 +62,39 @@ export async function POST(request: Request) {
);
}
- const catalog = await fetchAtlasCloudImagePresets();
+ const catalog = await fetchAtlasCloudModelPresets();
const presets = requestedModelIds
? catalog.presets.filter((preset) => requestedModelIds.has(preset.modelId))
: catalog.presets;
const addedModels: AiModel[] = [];
const updatedModels: AiModel[] = [];
const errors = [...catalog.errors];
+ const providerModels = await db.query.aiModel.findMany({
+ where: eq(aiModel.providerId, providerId),
+ });
+ const existingByModelId = new Map(
+ providerModels.map((model) => [model.modelId, model])
+ );
+ const existingMediaKinds = new Set(
+ providerModels.map((model) => {
+ const capabilities = model.capabilities as Record | null;
+ return String(capabilities?.mediaType || "image");
+ })
+ );
+ let preservedDeletedCount = 0;
for (const preset of presets) {
try {
- const existing = await db.query.aiModel.findFirst({
- where: and(
- eq(aiModel.providerId, providerId),
- eq(aiModel.modelId, preset.modelId)
- ),
- });
+ const existing = existingByModelId.get(preset.modelId);
+ const presetCapabilities = preset.capabilities as Record;
+ const mediaKind = String(presetCapabilities.mediaType || "image");
+ if (
+ !existing &&
+ !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
+ ) {
+ preservedDeletedCount += 1;
+ continue;
+ }
const values = {
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
@@ -87,9 +105,7 @@ export async function POST(request: Request) {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
- isEnabled: true,
- isHidden: false,
- sortOrder: preset.sortOrder,
+ sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
@@ -115,6 +131,8 @@ export async function POST(request: Request) {
providerId,
modelId: preset.modelId,
...values,
+ isEnabled: true,
+ isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
@@ -145,6 +163,8 @@ export async function POST(request: Request) {
requested: presets.length,
total: catalog.catalogCount,
available: catalog.presets.length,
+ skippedUnsupported: catalog.skipped.length,
+ preservedDeleted: preservedDeletedCount,
},
});
} catch (error) {
@@ -154,7 +174,7 @@ export async function POST(request: Request) {
error:
error instanceof Error
? error.message
- : "Failed to sync Atlas Cloud image models",
+ : "Failed to sync Atlas Cloud models",
},
{ status: 500 }
);
diff --git a/lib/ai/atlascloud-models.ts b/lib/ai/atlascloud-models.ts
index 342865628..5a2cc4970 100644
--- a/lib/ai/atlascloud-models.ts
+++ b/lib/ai/atlascloud-models.ts
@@ -29,8 +29,25 @@ const IMAGE_INPUT_KEYS = [
"reference_image",
];
const RESOLUTION_KEYS = ["resolution", "image_size", "output_resolution"];
-const ASPECT_RATIO_KEYS = ["aspect_ratio", "aspectRatio"];
+const VIDEO_RESOLUTION_KEYS = [
+ "resolution",
+ "output_resolution",
+ "target_resolution",
+];
+const ASPECT_RATIO_KEYS = ["aspect_ratio", "aspectRatio", "ratio"];
const OUTPUT_COUNT_KEYS = ["num_images", "number_of_images", "n", "output_count"];
+const VIDEO_DURATION_KEYS = ["duration", "video_duration", "seconds"];
+const VIDEO_REQUEST_KEYS = new Set([
+ "model",
+ "prompt",
+ ...VIDEO_RESOLUTION_KEYS,
+ ...ASPECT_RATIO_KEYS,
+ ...VIDEO_DURATION_KEYS,
+ "seed",
+ "watermark",
+ "camera_fixed",
+ "return_last_frame",
+]);
export interface AtlasCloudCatalogModel {
uuid?: string;
@@ -67,14 +84,17 @@ export interface AtlasCloudRuntimeConfig {
itemDefaults: Record;
};
resolutionKey?: string;
+ durationKey?: string;
aspectRatioKey?: string;
outputCountKey?: string;
aspectRatioValueMap: Record;
+ resolutionValueMap?: Record;
maxReferenceImages: number;
maxVideoReferences: number;
maxImages: number;
defaults: Record;
enums: Record>;
+ numericRanges?: Record;
sizeValueMap: Record>;
}
@@ -106,6 +126,13 @@ export function resolveAtlasCloudDisplayName(
return String(catalogDisplayName || "").trim() || modelId;
}
+export function shouldInsertMissingAtlasCloudPreset(
+ existingMediaKinds: ReadonlySet,
+ mediaKind: string
+) {
+ return !existingMediaKinds.has(mediaKind);
+}
+
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonRecord)
@@ -342,6 +369,7 @@ function closestCommonRatio(ratio: string) {
function normalizeAspectRatioEnum(value: unknown) {
const normalized = String(value || "").trim().toLowerCase();
if (normalized === "auto") return "auto";
+ if (normalized === "adaptive") return "adaptive";
if (/^\d+:\d+$/.test(normalized)) return normalized;
if (normalized.startsWith("square")) return "1:1";
const namedRatio = normalized.match(
@@ -352,6 +380,115 @@ function normalizeAspectRatioEnum(value: unknown) {
return orientation === "portrait" ? `${second}:${first}` : `${first}:${second}`;
}
+function normalizeVideoResolution(value: unknown) {
+ const normalized = String(value || "").trim().toLowerCase();
+ const match = normalized.match(/(?:^|[^0-9])(480|720|1080)p(?:$|[^0-9])/);
+ return match?.[1] ? `${match[1]}p` : "";
+}
+
+function buildVideoResolutionValueMap(
+ properties: JsonRecord,
+ resolutionKey?: string
+) {
+ if (!resolutionKey) return {};
+ const map: Record = {};
+ for (const value of getEnumValues(properties[resolutionKey])) {
+ const normalized = normalizeVideoResolution(value);
+ if (!normalized) continue;
+ const raw = String(value);
+ const current = map[normalized];
+ if (!current || raw.toLowerCase() === normalized) {
+ map[normalized] = raw;
+ }
+ }
+ return map;
+}
+
+function buildNumericRanges(properties: JsonRecord) {
+ const ranges: Record = {};
+ for (const [key, property] of Object.entries(properties)) {
+ const record = asRecord(property);
+ const min = Number(record.minimum ?? record.min);
+ const max = Number(record.maximum ?? record.max);
+ const range = {
+ ...(Number.isFinite(min) ? { min } : {}),
+ ...(Number.isFinite(max) ? { max } : {}),
+ };
+ if (Object.keys(range).length > 0) ranges[key] = range;
+ }
+ return ranges;
+}
+
+function deriveVideoDurationOptions(
+ properties: JsonRecord,
+ durationKey?: string
+) {
+ if (!durationKey) return [];
+ return Array.from(
+ new Set(
+ getEnumValues(properties[durationKey])
+ .map(Number)
+ .filter((value) => Number.isFinite(value) && value > 0)
+ .map((value) => Math.round(value))
+ )
+ ).sort((a, b) => a - b);
+}
+
+function deriveAtlasCloudVideoModes(params: {
+ catalogModel: AtlasCloudCatalogModel;
+ properties: JsonRecord;
+ requiredFields: string[];
+ imageInputs: AtlasCloudRuntimeConfig["imageInputs"];
+}) {
+ const { catalogModel, properties, requiredFields, imageInputs } = params;
+ const intentText = uniqueStrings([
+ catalogModel.model,
+ catalogModel.displayName,
+ ...(catalogModel.categories || []),
+ ...(catalogModel.tags || []),
+ ])
+ .map(normalizeIntentToken)
+ .join(" ");
+ const imageKeys = new Set(imageInputs.map((input) => input.key));
+ const hasUnsupportedRequiredInput = requiredFields.some((key) => {
+ if (imageKeys.has(key) || VIDEO_REQUEST_KEYS.has(key)) return false;
+ const defaultValue = asRecord(properties[key]).default;
+ return !["string", "number", "boolean"].includes(typeof defaultValue);
+ });
+ const requiredImages = imageInputs.filter((input) => input.required);
+ const singleImageInputs = imageInputs.filter((input) => input.mode === "single");
+ const arrayImageInput = imageInputs.find(
+ (input) => input.mode === "array" && input.maxItems > 1
+ );
+ const modes: string[] = [];
+
+ if (
+ !hasUnsupportedRequiredInput &&
+ requiredImages.length === 0 &&
+ ("prompt" in properties || intentText.includes("text-to-video"))
+ ) {
+ modes.push("text-to-video");
+ }
+ if (!hasUnsupportedRequiredInput && imageInputs.length > 0) {
+ if (requiredImages.length <= 1) modes.push("first-frame");
+ if (
+ singleImageInputs.length >= 2 ||
+ /(?:first|start).*(?:last|end)|(?:last|end).*(?:first|start)/.test(intentText)
+ ) {
+ modes.push("first-last-frame");
+ }
+ if (
+ arrayImageInput ||
+ imageInputs.length > 2 ||
+ /reference-to-video|reference-images/.test(intentText)
+ ) {
+ modes.push("reference-images");
+ }
+ }
+
+ return uniqueStrings(modes);
+}
+
function buildAspectRatioValueMap(properties: JsonRecord) {
for (const key of [...ASPECT_RATIO_KEYS, "image_size"]) {
if (!(key in properties)) continue;
@@ -449,20 +586,37 @@ export function createAtlasCloudImagePreset(
const imageInputs = getImageInputs(properties, requiredFields);
const videoClipInput = getVideoClipInput(properties, requiredFields);
const imageInputKey = imageInputs[0]?.key;
- const resolutionKey = RESOLUTION_KEYS.find((key) => {
+ const mediaKind = inferAtlasCloudMediaKind(catalogModel);
+ const resolutionCandidates =
+ mediaKind === "video" ? VIDEO_RESOLUTION_KEYS : RESOLUTION_KEYS;
+ const resolutionKey = resolutionCandidates.find((key) => {
if (!(key in properties)) return false;
const values = getEnumValues(properties[key]);
- return values.length === 0 || values.some((value) => Boolean(normalizeKSize(value)));
+ return (
+ values.length === 0 ||
+ values.some((value) =>
+ mediaKind === "video"
+ ? Boolean(normalizeVideoResolution(value))
+ : Boolean(normalizeKSize(value))
+ )
+ );
});
+ const durationKey = findFirstKey(properties, VIDEO_DURATION_KEYS);
const aspectRatioConfig = buildAspectRatioValueMap(properties);
const aspectRatioKey = aspectRatioConfig.key;
const outputCountKey = findFirstKey(properties, OUTPUT_COUNT_KEYS);
const sizeValueMap = buildSizeValueMap(getEnumValues(properties.size));
- const supportedImageSizes = deriveSupportedImageSizes(
+ const supportedImageSizes =
+ mediaKind === "video"
+ ? []
+ : deriveSupportedImageSizes(properties, resolutionKey, sizeValueMap);
+ const resolutionValueMap = buildVideoResolutionValueMap(
properties,
- resolutionKey,
- sizeValueMap
+ mediaKind === "video" ? resolutionKey : undefined
);
+ const supportedVideoResolutions = Object.keys(resolutionValueMap);
+ const durationOptions = deriveVideoDurationOptions(properties, durationKey);
+ const numericRanges = buildNumericRanges(properties);
const supportedAspectRatios = deriveAspectRatios(
aspectRatioConfig.map,
sizeValueMap
@@ -477,10 +631,16 @@ export function createAtlasCloudImagePreset(
const categories = uniqueStrings(catalogModel.categories || []).map((value) =>
value.toLowerCase()
);
- const mediaKind = inferAtlasCloudMediaKind(catalogModel);
const supportedModes =
mediaKind === "3d"
? derive3DSupportedModes(catalogModel)
+ : mediaKind === "video"
+ ? deriveAtlasCloudVideoModes({
+ catalogModel,
+ properties,
+ requiredFields,
+ imageInputs,
+ })
: [
...(categories.some((value) => value.includes("text-to-image"))
? ["text-to-image"]
@@ -505,14 +665,17 @@ export function createAtlasCloudImagePreset(
imageInputMode: imageProperty.type === "array" ? "array" : "single",
videoClipInput,
resolutionKey,
+ durationKey,
aspectRatioKey,
outputCountKey,
aspectRatioValueMap: aspectRatioConfig.map,
+ resolutionValueMap,
maxReferenceImages,
maxVideoReferences,
maxImages,
defaults: buildScalarDefaults(properties),
enums: buildEnums(properties),
+ numericRanges,
sizeValueMap,
};
@@ -524,10 +687,23 @@ export function createAtlasCloudImagePreset(
supportedImageSizes[0] ||
"1K";
const defaultAspectRatio =
- supportedAspectRatios.find((value) => value === "auto") ||
+ supportedAspectRatios.find((value) =>
+ mediaKind === "video" ? value === "adaptive" : value === "auto"
+ ) ||
supportedAspectRatios.find((value) => value === "1:1") ||
supportedAspectRatios[0] ||
- "1:1";
+ (mediaKind === "video" ? "16:9" : "1:1");
+ const defaultVideoResolution =
+ supportedVideoResolutions.find((value) => value === "720p") ||
+ supportedVideoResolutions[0] ||
+ "720p";
+ const durationRange = numericRanges[durationKey || ""] || {};
+ const defaultDuration =
+ Number(runtimeConfig.defaults[durationKey || ""]) ||
+ durationOptions.find((value) => value === 5) ||
+ durationOptions[0] ||
+ durationRange.min ||
+ 5;
return {
modelId: catalogModel.model,
@@ -550,6 +726,27 @@ export function createAtlasCloudImagePreset(
: "text-to-image",
mediaType: mediaKind,
supportedModes,
+ ...(mediaKind === "video"
+ ? {
+ supportedResolutions: supportedVideoResolutions,
+ supportedRatios: supportedAspectRatios,
+ durationOptions,
+ durationRange: {
+ min:
+ durationOptions[0] ||
+ durationRange.min ||
+ defaultDuration,
+ max:
+ durationOptions[durationOptions.length - 1] ||
+ durationRange.max ||
+ defaultDuration,
+ },
+ supportWatermark: "watermark" in properties,
+ supportCameraFixed: "camera_fixed" in properties,
+ supportSeed: "seed" in properties,
+ supportReturnLastFrame: "return_last_frame" in properties,
+ }
+ : {}),
maxReferenceImages,
maxVideoReferences,
supportsMultiImageReference: maxReferenceImages > 1,
@@ -559,13 +756,24 @@ export function createAtlasCloudImagePreset(
schemaUrl: catalogModel.schema || null,
},
defaultParams: {
- imageSize: defaultImageSize,
- aspectRatio: defaultAspectRatio,
- imageCount: 1,
+ ...(mediaKind === "video"
+ ? {
+ resolution: defaultVideoResolution,
+ ratio: defaultAspectRatio,
+ duration: defaultDuration,
+ }
+ : {
+ imageSize: defaultImageSize,
+ aspectRatio: defaultAspectRatio,
+ imageCount: 1,
+ }),
runtimeModelId: catalogModel.model,
},
imageGenConfig: {
- apiFormat: "atlascloud-unified-image",
+ apiFormat:
+ mediaKind === "video"
+ ? "atlascloud-unified-video"
+ : "atlascloud-unified-image",
maxImages,
maxReferenceImages,
maxVideoReferences,
@@ -575,6 +783,7 @@ export function createAtlasCloudImagePreset(
supports4K: supportedImageSizes.includes("4K"),
supportedImageSizes,
supportedAspectRatios,
+ videoDurationOptions: durationOptions,
supportsSeed: "seed" in properties,
supportsNegativePrompt: "negative_prompt" in properties,
supportsWatermark: "watermark" in properties,
@@ -602,7 +811,7 @@ async function mapWithConcurrency(
return results;
}
-export async function fetchAtlasCloudImagePresets() {
+async function fetchAtlasCloudPresets(mediaTypes: Array<"image" | "video">) {
const catalogResponse = await fetch(ATLASCLOUD_CATALOG_URL, {
cache: "no-store",
headers: { Accept: "application/json", "X-Source-From": "website" },
@@ -618,7 +827,9 @@ export async function fetchAtlasCloudImagePresets() {
const models = catalogModels
.filter(
(model: AtlasCloudCatalogModel) =>
- String(model?.type || "").toLowerCase() === "image" &&
+ mediaTypes.includes(
+ String(model?.type || "").toLowerCase() as "image" | "video"
+ ) &&
model?.display_console !== false &&
Boolean(model?.model) &&
Boolean(model?.schema)
@@ -638,10 +849,28 @@ export async function fetchAtlasCloudImagePresets() {
if (!schemaResponse.ok) {
throw new Error(`HTTP ${schemaResponse.status}`);
}
- return {
- preset: createAtlasCloudImagePreset(model, await schemaResponse.json(), index),
- error: null,
- };
+ const preset = createAtlasCloudImagePreset(
+ model,
+ await schemaResponse.json(),
+ index
+ );
+ const supportedModes = Array.isArray(preset.capabilities.supportedModes)
+ ? preset.capabilities.supportedModes
+ : [];
+ if (
+ preset.capabilities.mediaType === "video" &&
+ supportedModes.length === 0
+ ) {
+ return {
+ preset: null,
+ error: null,
+ skipped: {
+ modelId: model.model,
+ reason: "当前视频工具不支持该模型要求的视频或音频输入",
+ },
+ };
+ }
+ return { preset, error: null, skipped: null };
} catch (error) {
return {
preset: null,
@@ -649,6 +878,7 @@ export async function fetchAtlasCloudImagePresets() {
modelId: model.model,
error: error instanceof Error ? error.message : String(error),
},
+ skipped: null,
};
}
});
@@ -656,6 +886,19 @@ export async function fetchAtlasCloudImagePresets() {
return {
presets: settled.flatMap((result) => (result.preset ? [result.preset] : [])),
errors: settled.flatMap((result) => (result.error ? [result.error] : [])),
+ skipped: settled.flatMap((result) => (result.skipped ? [result.skipped] : [])),
catalogCount: models.length,
};
}
+
+export function fetchAtlasCloudImagePresets() {
+ return fetchAtlasCloudPresets(["image"]);
+}
+
+export function fetchAtlasCloudVideoPresets() {
+ return fetchAtlasCloudPresets(["video"]);
+}
+
+export function fetchAtlasCloudModelPresets() {
+ return fetchAtlasCloudPresets(["image", "video"]);
+}
diff --git a/lib/ai/atlascloud-request.ts b/lib/ai/atlascloud-request.ts
index 4607c4853..79455fe64 100644
--- a/lib/ai/atlascloud-request.ts
+++ b/lib/ai/atlascloud-request.ts
@@ -1,4 +1,5 @@
import type { AtlasCloudRuntimeConfig } from "@/lib/ai/atlascloud-models";
+import type { VideoGenerationOptions } from "@/types/ai";
export interface AtlasCloudImageParams {
aspectRatio?: string;
@@ -19,6 +20,10 @@ export interface AtlasCloudModelConfig {
atlasCloud?: AtlasCloudRuntimeConfig;
}
+export interface AtlasCloudVideoModelConfig extends AtlasCloudModelConfig {
+ videoDurationOptions?: number[];
+}
+
function normalizeAspectRatio(
requested: string | undefined,
supported: string[] | undefined
@@ -207,3 +212,121 @@ export function createAtlasCloudRequestBody(params: {
}
return { body, imageSize };
}
+
+function setAtlasCloudImages(params: {
+ body: Record;
+ runtime: AtlasCloudRuntimeConfig;
+ images: string[];
+}) {
+ const imageInputs = params.runtime.imageInputs?.length
+ ? params.runtime.imageInputs
+ : params.runtime.imageInputKey
+ ? [
+ {
+ key: params.runtime.imageInputKey,
+ mode: params.runtime.imageInputMode || ("single" as const),
+ maxItems: Math.max(1, params.runtime.maxReferenceImages || 1),
+ required: params.runtime.requiredFields.includes(
+ params.runtime.imageInputKey
+ ),
+ },
+ ]
+ : [];
+ let cursor = 0;
+ for (const input of imageInputs) {
+ const values = params.images.slice(cursor, cursor + input.maxItems);
+ if (values.length > 0) {
+ params.body[input.key] = input.mode === "single" ? values[0] : values;
+ cursor += values.length;
+ } else if (input.required) {
+ throw new Error(`该 Atlas Cloud 视频模型至少需要 ${cursor + 1} 张参考图`);
+ }
+ }
+ if (imageInputs.length === 0 && params.images.length > 0) {
+ throw new Error("当前 Atlas Cloud 视频模型不支持参考图");
+ }
+}
+
+export function createAtlasCloudVideoRequestBody(params: {
+ modelId: string;
+ options: VideoGenerationOptions;
+ referenceImages: string[];
+ modelConfig: AtlasCloudVideoModelConfig;
+}) {
+ const runtime = params.modelConfig.atlasCloud;
+ if (!runtime) {
+ throw new Error("Atlas Cloud 视频模型运行参数缺失,请重新同步模型");
+ }
+ const body: Record = { ...runtime.defaults };
+ if (
+ runtime.propertyNames.includes("model") ||
+ runtime.requiredFields.includes("model")
+ ) {
+ body.model = params.modelId;
+ }
+ if (
+ runtime.propertyNames.includes("prompt") ||
+ runtime.requiredFields.includes("prompt")
+ ) {
+ body.prompt = params.options.prompt;
+ }
+
+ if (runtime.resolutionKey && params.options.resolution) {
+ body[runtime.resolutionKey] =
+ runtime.resolutionValueMap?.[params.options.resolution] ||
+ matchEnumValue(
+ runtime.enums[runtime.resolutionKey],
+ params.options.resolution
+ ) ||
+ params.options.resolution;
+ }
+ if (runtime.aspectRatioKey && params.options.ratio) {
+ const requestedRatio = params.options.ratio;
+ body[runtime.aspectRatioKey] =
+ runtime.aspectRatioValueMap?.[requestedRatio] ||
+ runtime.aspectRatioValueMap?.[
+ requestedRatio === "adaptive" ? "auto" : requestedRatio
+ ] ||
+ matchEnumValue(runtime.enums[runtime.aspectRatioKey], requestedRatio) ||
+ requestedRatio;
+ }
+ if (runtime.durationKey && params.options.duration !== undefined) {
+ body[runtime.durationKey] =
+ matchEnumValue(runtime.enums[runtime.durationKey], params.options.duration) ??
+ params.options.duration;
+ }
+
+ setAtlasCloudImages({
+ body,
+ runtime,
+ images: params.referenceImages,
+ });
+
+ const optionalParams: Array<[string, unknown]> = [
+ ["seed", params.options.seed],
+ ["watermark", params.options.watermark],
+ ["camera_fixed", params.options.cameraFixed],
+ ["return_last_frame", params.options.returnLastFrame],
+ ];
+ for (const [key, value] of optionalParams) {
+ if (value !== undefined && runtime.propertyNames.includes(key)) {
+ body[key] = matchEnumValue(runtime.enums[key], value) ?? value;
+ }
+ }
+ if (runtime.propertyNames.includes("enable_sync_mode")) {
+ body.enable_sync_mode = false;
+ }
+ if (runtime.propertyNames.includes("enable_base64_output")) {
+ body.enable_base64_output = false;
+ }
+
+ const unsupportedRequiredFields = runtime.requiredFields.filter(
+ (key) => body[key] === undefined
+ );
+ if (unsupportedRequiredFields.length > 0) {
+ throw new Error(
+ `当前视频界面暂不支持该 Atlas Cloud 模型的必填参数:${unsupportedRequiredFields.join(", ")}`
+ );
+ }
+ return { body };
+}
diff --git a/lib/ai/providers/atlascloud.ts b/lib/ai/providers/atlascloud.ts
index 81a0c79f2..6a7bbb623 100644
--- a/lib/ai/providers/atlascloud.ts
+++ b/lib/ai/providers/atlascloud.ts
@@ -4,11 +4,14 @@ import sharp from "sharp";
import { ATLASCLOUD_BASE_URL } from "@/lib/ai/atlascloud-models";
import {
createAtlasCloudRequestBody,
+ createAtlasCloudVideoRequestBody,
type AtlasCloudImageParams,
type AtlasCloudModelConfig,
+ type AtlasCloudVideoModelConfig,
} from "@/lib/ai/atlascloud-request";
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
import { uploadFile } from "@/lib/storage/service";
+import type { VideoGenerationOptions } from "@/types/ai";
interface AtlasCloudConfig {
apiKey: string;
@@ -82,6 +85,10 @@ function extractOutputUrls(payload: any) {
prediction?.outputs,
prediction?.output,
prediction?.images,
+ prediction?.video,
+ prediction?.video_url,
+ prediction?.videoUrl,
+ prediction?.url,
prediction?.urls?.outputs,
];
const urls: string[] = [];
@@ -91,8 +98,16 @@ function extractOutputUrls(payload: any) {
const url =
typeof value === "string"
? value
- : String(value?.url || value?.image_url || value?.src || "");
- if (/^(?:https?:\/\/|data:image\/)/i.test(url) && !urls.includes(url)) {
+ : String(
+ value?.url ||
+ value?.video_url ||
+ value?.videoUrl ||
+ value?.image_url ||
+ value?.file_url ||
+ value?.src ||
+ ""
+ );
+ if (/^(?:https?:\/\/|data:(?:image|video)\/)/i.test(url) && !urls.includes(url)) {
urls.push(url);
}
}
@@ -358,6 +373,64 @@ async function archiveOutputImage(imageUrl: string): Promise {
);
}
+export async function executeAtlasCloudVideoGeneration(params: {
+ apiKey: string;
+ baseURL?: string;
+ modelId: string;
+ modelConfig: AtlasCloudVideoModelConfig;
+ options: VideoGenerationOptions;
+}) {
+ const runtime = params.modelConfig.atlasCloud;
+ if (!runtime) {
+ throw new Error("Atlas Cloud 视频模型能力配置缺失,请重新同步模型");
+ }
+ const inputImages = [
+ ...(params.options.firstFrameImage ? [params.options.firstFrameImage] : []),
+ ...(params.options.lastFrameImage ? [params.options.lastFrameImage] : []),
+ ...(params.options.referenceImages || []),
+ ];
+ const uniqueImages = Array.from(new Set(inputImages)).slice(
+ 0,
+ Math.max(0, Math.min(14, runtime.maxReferenceImages || 0))
+ );
+ const referenceImages = await Promise.all(
+ uniqueImages.map((url) => uploadReferenceMedia(params.baseURL, params.apiKey, url))
+ );
+ const { body } = createAtlasCloudVideoRequestBody({
+ modelId: params.modelId,
+ options: params.options,
+ referenceImages,
+ modelConfig: params.modelConfig,
+ });
+ const submitted = await submitTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ endpoint: runtime.submitEndpoint,
+ body,
+ });
+ const outputUrls = submitted.outputs.length
+ ? submitted.outputs
+ : await waitForTask({
+ apiKey: params.apiKey,
+ baseURL: params.baseURL,
+ endpoint: runtime.pollEndpoint,
+ taskId: submitted.taskId,
+ });
+ const videoUrl = outputUrls[0];
+ if (!videoUrl) {
+ throw new Error("Atlas Cloud 视频任务完成但未返回视频地址");
+ }
+ return {
+ url: videoUrl,
+ metadata: {
+ provider: "atlascloud",
+ taskId: submitted.taskId || null,
+ temporaryVideoUrl: videoUrl,
+ request: body,
+ },
+ };
+}
+
function formatMarkdown(images: UploadedImage[], imageSize: string) {
return images
.map((image) => ``)
diff --git a/lib/ai/tools/generate-video.ts b/lib/ai/tools/generate-video.ts
index 150971222..0bae08c25 100644
--- a/lib/ai/tools/generate-video.ts
+++ b/lib/ai/tools/generate-video.ts
@@ -6,6 +6,7 @@
import { tool } from "ai";
import { z } from "zod";
import { doGenerateStream } from "@/lib/ai/providers/volcano-video";
+import { executeAtlasCloudVideoGeneration } from "@/lib/ai/providers/atlascloud";
import {
getEnabledAiModelsWithProvider,
getSubModelsByParentModelId
@@ -109,8 +110,22 @@ function isVolcanoVideoModel(item: any) {
return item?.provider?.type === "volcano-video" || item?.provider?.type === "doubao-video";
}
+function isAtlasCloudVideoModel(item: any) {
+ const capabilities = item?.model?.capabilities;
+ return (
+ item?.provider?.type === "atlascloud" &&
+ capabilities &&
+ typeof capabilities === "object" &&
+ String((capabilities as Record).mediaType || "") === "video"
+ );
+}
+
function isDirectExecutableVideoModel(item: any) {
- return isVolcanoVideoModel(item) || isAgnesVideoModel(item);
+ return (
+ isVolcanoVideoModel(item) ||
+ isAgnesVideoModel(item) ||
+ isAtlasCloudVideoModel(item)
+ );
}
/**
@@ -275,6 +290,7 @@ function validateParamsAgainstCapabilities(
// 检查分辨率
if (
params.resolution &&
+ capabilities.supportedResolutions.length > 0 &&
!capabilities.supportedResolutions.includes(params.resolution)
) {
throw new Error(
@@ -283,7 +299,11 @@ function validateParamsAgainstCapabilities(
}
// 检查宽高比
- if (params.ratio && !capabilities.supportedRatios.includes(params.ratio)) {
+ if (
+ params.ratio &&
+ capabilities.supportedRatios.length > 0 &&
+ !capabilities.supportedRatios.includes(params.ratio)
+ ) {
throw new Error(
`模型不支持 ${params.ratio} 宽高比,支持的宽高比:${capabilities.supportedRatios.join(", ")}`
);
@@ -621,6 +641,18 @@ export async function executeDirectVideoGeneration(
...(agnesResult.metadata || {}),
temporaryVideoUrl: agnesResult.url,
};
+ } else if (isAtlasCloudVideoModel(model)) {
+ const atlasResult = await executeAtlasCloudVideoGeneration({
+ apiKey: model.provider.apiKey,
+ baseURL: model.provider.baseUrl,
+ modelId: model.model.modelId,
+ modelConfig: model.model.imageGenConfig || {},
+ options,
+ });
+ videoMetadata = {
+ ...(atlasResult.metadata || {}),
+ temporaryVideoUrl: atlasResult.url,
+ };
} else {
for await (const chunk of doGenerateStream(model.provider.apiKey, model.provider.baseUrl, options)) {
await params.onProgress?.(chunk);
diff --git a/scripts/sync-atlascloud-models.ts b/scripts/sync-atlascloud-models.ts
index dabcf5309..55a57cbd7 100644
--- a/scripts/sync-atlascloud-models.ts
+++ b/scripts/sync-atlascloud-models.ts
@@ -1,7 +1,8 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq } from "drizzle-orm";
import {
- fetchAtlasCloudImagePresets,
+ fetchAtlasCloudModelPresets,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "../lib/ai/atlascloud-models";
import { db } from "../lib/db";
import { aiModel, aiProvider } from "../lib/db/schema";
@@ -15,7 +16,7 @@ async function main() {
throw new Error("没有找到已启用的 AtlasCloud 服务商");
}
- const catalog = await fetchAtlasCloudImagePresets();
+ const catalog = await fetchAtlasCloudModelPresets();
if (catalog.errors.length > 0) {
throw new Error(
`AtlasCloud 目录有 ${catalog.errors.length} 个 schema 加载失败:${catalog.errors
@@ -25,19 +26,34 @@ async function main() {
}
const summaries = [];
- const catalogModelIds = new Set(
- catalog.presets.map((preset) => preset.modelId)
- );
for (const provider of providers) {
let added = 0;
let updated = 0;
+ let preservedDeleted = 0;
+ const providerModels = await db
+ .select()
+ .from(aiModel)
+ .where(eq(aiModel.providerId, provider.id));
+ const existingByModelId = new Map(
+ providerModels.map((model) => [model.modelId, model])
+ );
+ const existingMediaKinds = new Set(
+ providerModels.map((model) => {
+ const capabilities = model.capabilities as Record | null;
+ return String(capabilities?.mediaType || "image");
+ })
+ );
for (const preset of catalog.presets) {
- const existing = await db.query.aiModel.findFirst({
- where: and(
- eq(aiModel.providerId, provider.id),
- eq(aiModel.modelId, preset.modelId)
- ),
- });
+ const existing = existingByModelId.get(preset.modelId);
+ const presetCapabilities = preset.capabilities as Record;
+ const mediaKind = String(presetCapabilities.mediaType || "image");
+ if (
+ !existing &&
+ !shouldInsertMissingAtlasCloudPreset(existingMediaKinds, mediaKind)
+ ) {
+ preservedDeleted += 1;
+ continue;
+ }
const values = {
displayName: resolveAtlasCloudDisplayName(
existing?.displayName,
@@ -48,9 +64,7 @@ async function main() {
description: preset.description,
supportsVision: preset.supportsVision,
supportsImageGen: preset.supportsImageGen,
- isEnabled: true,
- isHidden: false,
- sortOrder: preset.sortOrder,
+ sortOrder: existing?.sortOrder ?? preset.sortOrder,
capabilities: preset.capabilities,
defaultParams: preset.defaultParams,
imageGenConfig: preset.imageGenConfig,
@@ -68,6 +82,8 @@ async function main() {
providerId: provider.id,
modelId: preset.modelId,
...values,
+ isEnabled: true,
+ isHidden: false,
isDefault: false,
isSubModel: false,
parentModelId: null,
@@ -75,34 +91,12 @@ async function main() {
added += 1;
}
}
- const providerModels = await db
- .select({ modelId: aiModel.modelId })
- .from(aiModel)
- .where(eq(aiModel.providerId, provider.id));
- const staleModelIds = providerModels
- .map((model) => model.modelId)
- .filter((modelId) => !catalogModelIds.has(modelId));
- if (staleModelIds.length > 0) {
- await db
- .update(aiModel)
- .set({
- isEnabled: false,
- isHidden: true,
- updatedAt: new Date(),
- })
- .where(
- and(
- eq(aiModel.providerId, provider.id),
- inArray(aiModel.modelId, staleModelIds)
- )
- );
- }
summaries.push({
providerId: provider.id,
providerName: provider.name,
added,
updated,
- disabledStale: staleModelIds.length,
+ preservedDeleted,
});
}
@@ -111,6 +105,7 @@ async function main() {
{
catalogModels: catalog.catalogCount,
availableModels: catalog.presets.length,
+ skippedUnsupportedVideoModels: catalog.skipped.length,
providers: summaries,
},
null,
diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts
index f8b8a697a..d8039ed87 100644
--- a/tests/routes/model-classification.test.ts
+++ b/tests/routes/model-classification.test.ts
@@ -3,8 +3,12 @@ import {
createAtlasCloudImagePreset,
inferAtlasCloudMediaKind,
resolveAtlasCloudDisplayName,
+ shouldInsertMissingAtlasCloudPreset,
} from "@/lib/ai/atlascloud-models";
-import { createAtlasCloudRequestBody } from "@/lib/ai/atlascloud-request";
+import {
+ createAtlasCloudRequestBody,
+ createAtlasCloudVideoRequestBody,
+} from "@/lib/ai/atlascloud-request";
import {
is3DModelDescriptor,
isImageGenerationModelDescriptor,
@@ -37,6 +41,13 @@ test.describe("AI model media classification", () => {
).toBe("Flux Kontext Pro");
});
+ test("adds a missing Atlas media kind without restoring deleted models in an existing kind", () => {
+ const existingKinds = new Set(["image", "3d"]);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "video")).toBe(true);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "image")).toBe(false);
+ expect(shouldInsertMissingAtlasCloudPreset(existingKinds, "3d")).toBe(false);
+ });
+
test("keeps multi-reference BananaImage models classified as image models", () => {
const descriptor = {
providerType: "bananaimage",
@@ -206,4 +217,92 @@ test.describe("AI model media classification", () => {
},
]);
});
+
+ test("maps Atlas video schema capabilities and frontend parameters", () => {
+ const preset = createAtlasCloudImagePreset(
+ {
+ model: "bytedance/seedance-2.0-mini/image-to-video",
+ type: "Video",
+ displayName: "Seedance 2.0 Mini Image to Video",
+ categories: ["IMAGE-TO-VIDEO"],
+ },
+ {
+ paths: {
+ "/api/v1/model/generateVideo": { post: {} },
+ "/api/v1/model/prediction/{request_id}": { get: {} },
+ },
+ components: {
+ schemas: {
+ Input: {
+ type: "object",
+ properties: {
+ model: { type: "string" },
+ prompt: { type: "string" },
+ image: { type: "string", "x-format": "image" },
+ last_image: { type: "string", "x-format": "image" },
+ resolution: {
+ type: "string",
+ enum: ["480p", "720p", "1080p"],
+ default: "720p",
+ },
+ ratio: {
+ type: "string",
+ enum: ["16:9", "9:16", "adaptive"],
+ default: "adaptive",
+ },
+ duration: {
+ type: "integer",
+ enum: [5, 10],
+ default: 5,
+ },
+ seed: { type: "integer" },
+ watermark: { type: "boolean", default: false },
+ },
+ required: ["model", "prompt", "image"],
+ },
+ },
+ },
+ }
+ );
+
+ expect(preset.supportsImageGen).toBe(false);
+ expect(preset.capabilities).toMatchObject({
+ kind: "video",
+ mediaType: "video",
+ supportedModes: ["first-frame", "first-last-frame"],
+ supportedResolutions: ["480p", "720p", "1080p"],
+ supportedRatios: ["16:9", "9:16", "adaptive"],
+ durationOptions: [5, 10],
+ });
+
+ const { body } = createAtlasCloudVideoRequestBody({
+ modelId: preset.modelId,
+ options: {
+ model: preset.modelId,
+ prompt: "让商品缓慢旋转",
+ mode: "first-last-frame",
+ firstFrameImage: "https://example.com/first.png",
+ lastFrameImage: "https://example.com/last.png",
+ resolution: "1080p",
+ ratio: "9:16",
+ duration: 10,
+ watermark: true,
+ },
+ referenceImages: [
+ "https://example.com/first.png",
+ "https://example.com/last.png",
+ ],
+ modelConfig: preset.imageGenConfig,
+ });
+ expect(body).toMatchObject({
+ model: preset.modelId,
+ prompt: "让商品缓慢旋转",
+ image: "https://example.com/first.png",
+ last_image: "https://example.com/last.png",
+ resolution: "1080p",
+ ratio: "9:16",
+ duration: 10,
+ watermark: true,
+ });
+ });
});
From 62c686a26bc0b117fec019592ddbd9211f4163bd Mon Sep 17 00:00:00 2001
From: 134
Date: Fri, 24 Jul 2026 13:51:06 +0800
Subject: [PATCH 09/83] fix: fail over on upstream image provider errors
---
lib/ai/providers/gemini-image.ts | 81 +++++++++++++++++++++++++++-----
lib/ai/runtime/model-health.ts | 49 +++++++++++++++++--
2 files changed, 115 insertions(+), 15 deletions(-)
diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts
index b3c5c5fb1..b507a3940 100644
--- a/lib/ai/providers/gemini-image.ts
+++ b/lib/ai/providers/gemini-image.ts
@@ -716,12 +716,11 @@ async function sendGrsaiGptImageRequest(params: {
response.status,
responseText.substring(0, 1000)
);
+ const providerError = getGrsaiErrorMessage(data);
const errorMessage =
- data?.error?.message ||
- data?.message ||
- data?.error ||
- responseText ||
- `${response.status} ${response.statusText}`;
+ providerError !== "未知错误"
+ ? providerError
+ : responseText || `${response.status} ${response.statusText}`;
throw new Error(`图片生成失败: ${errorMessage}`);
}
@@ -754,6 +753,13 @@ async function sendGrsaiGptImageRequest(params: {
timeoutLabel: "Grsai GPT Image",
});
}
+
+ const providerError = getGrsaiErrorMessage(data);
+ throw new Error(
+ `图片生成失败: 未返回任务 ID 或可用图片${
+ providerError !== "未知错误" ? `: ${providerError}` : ""
+ }`
+ );
}
return data;
@@ -857,18 +863,46 @@ function getGrsaiProgress(payload: any): number | null {
function getGrsaiErrorMessage(payload: any): string {
const normalized = normalizeGrsaiPayload(payload);
+ const normalizePart = (part: unknown) => {
+ if (typeof part === "string" || typeof part === "number") {
+ return String(part).trim();
+ }
+ if (!isRecord(part)) return "";
+ return String(
+ part.message ||
+ part.msg ||
+ part.detail ||
+ part.reason ||
+ part.code ||
+ part.type ||
+ ""
+ ).trim();
+ };
const messages = [
normalized?.failure_reason,
+ normalized?.error?.message,
+ normalized?.error?.msg,
+ normalized?.error?.code,
normalized?.error,
normalized?.message,
+ normalized?.msg,
+ normalized?.detail,
+ normalized?.reason,
+ normalized?.code,
+ payload?.error?.message,
+ payload?.error?.msg,
+ payload?.error?.code,
payload?.msg,
payload?.message,
payload?.error,
+ payload?.detail,
+ payload?.reason,
+ payload?.code,
]
- .map((part) => String(part || "").trim())
+ .map(normalizePart)
.filter(Boolean);
- return messages.join(" / ") || "未知错误";
+ return Array.from(new Set(messages)).join(" / ") || "未知错误";
}
function hasGrsaiImages(payload: any): boolean {
@@ -899,18 +933,36 @@ async function fetchWithTimeout(
async function readJsonResponse(response: Response, label: string): Promise {
const text = await response.text();
+ const payload = parseGrsaiJsonOrStreamResponse(text);
if (!response.ok) {
logger.error(`${label} failed:`, response.status, text.substring(0, 500));
- throw new Error(`${label} 接口错误:${response.status} ${response.statusText}`);
+ const providerError = getGrsaiErrorMessage(payload);
+ const detail =
+ providerError !== "未知错误"
+ ? providerError
+ : String(payload?.raw || text || "").trim().slice(0, 300);
+ throw new Error(
+ `${label} 接口错误:${response.status} ${response.statusText}${
+ detail ? `: ${detail}` : ""
+ }`
+ );
}
- try {
- return JSON.parse(text);
- } catch (error) {
+ if (isRecord(payload) && !("raw" in payload)) {
+ return payload;
+ }
+
+ if (!isRecord(payload)) {
+ return payload;
+ }
+
+ if ("raw" in payload) {
logger.error(`${label} returned non-JSON response:`, text.substring(0, 500));
throw new Error(`${label} 返回格式异常,未能解析任务结果`);
}
+
+ return payload;
}
async function pollGrsaiNanoBananaResult(params: {
@@ -1047,11 +1099,16 @@ async function submitAndPollGrsaiNanoBanana(params: {
const taskId = getGrsaiTaskId(submitPayload);
if (!taskId) {
+ const providerError = getGrsaiErrorMessage(submitPayload);
logger.error(
`${params.apiLogPrefix} Async submit response has no task id:`,
JSON.stringify(submitPayload).substring(0, 500)
);
- throw new Error("GRSAI Nano Banana 异步任务提交失败:未返回任务 ID");
+ throw new Error(
+ `GRSAI Nano Banana 异步任务提交失败:未返回任务 ID${
+ providerError !== "未知错误" ? `: ${providerError}` : ""
+ }`
+ );
}
logger.debug(`${params.apiLogPrefix} Async task submitted:`, { taskId });
diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts
index 765cf910c..8ab443831 100644
--- a/lib/ai/runtime/model-health.ts
+++ b/lib/ai/runtime/model-health.ts
@@ -151,7 +151,10 @@ export function classifyAiModelHealthFailure(error: unknown): {
if (
normalized.includes("insufficient balance") ||
+ normalized.includes("insufficient credit") ||
normalized.includes("credits insufficient") ||
+ normalized.includes("credit insufficient") ||
+ normalized.includes("not enough credit") ||
normalized.includes("quota") ||
normalized.includes("余额") ||
normalized.includes("额度") ||
@@ -209,10 +212,38 @@ export function classifyAiModelHealthFailure(error: unknown): {
};
}
+ if (
+ normalized.includes("未返回任务 id") ||
+ normalized.includes("未返回任务id") ||
+ normalized.includes("no task id") ||
+ normalized.includes("missing task id") ||
+ normalized.includes("provider error") ||
+ normalized.includes("provider_error") ||
+ normalized.includes("provider request failed") ||
+ normalized.includes("provider task failed") ||
+ normalized.includes("upstream error") ||
+ normalized.includes("upstream_error") ||
+ normalized.includes("upstream request failed") ||
+ normalized.includes("上游服务错误") ||
+ normalized.includes("上游接口错误")
+ ) {
+ return {
+ eventType: "unavailable",
+ severity: "critical",
+ counted: true,
+ reason: "provider_execution_failed",
+ };
+ }
+
if (
normalized.includes("download") ||
normalized.includes("no image url") ||
normalized.includes("no image") ||
+ normalized.includes("empty result") ||
+ normalized.includes("empty response") ||
+ normalized.includes("空结果") ||
+ normalized.includes("返回结果为空") ||
+ normalized.includes("响应为空") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||
@@ -245,14 +276,21 @@ export function shouldFailoverAiModelError(error: unknown) {
normalized.includes("snapgen 任务已完成但未返回图片地址") ||
normalized.includes("snapgen 未返回可用图片");
- if (imageProviderTaskFailed) {
- return true;
- }
+ const providerExecutionFailed =
+ classification.reason === "provider_execution_failed" ||
+ normalized.includes("未返回任务 id") ||
+ normalized.includes("未返回任务id") ||
+ normalized.includes("no task id") ||
+ normalized.includes("missing task id");
if (classification.reason === "user_cancelled" || classification.reason === "non_provider_failure") {
return false;
}
+ if (imageProviderTaskFailed || providerExecutionFailed) {
+ return true;
+ }
+
if (
classification.eventType === "timeout" ||
classification.reason === "transport_failure_no_provider_response"
@@ -263,6 +301,11 @@ export function shouldFailoverAiModelError(error: unknown) {
if (classification.eventType === "download_error") {
return (
normalized.includes("no image") ||
+ normalized.includes("empty result") ||
+ normalized.includes("empty response") ||
+ normalized.includes("空结果") ||
+ normalized.includes("返回结果为空") ||
+ normalized.includes("响应为空") ||
normalized.includes("未返回可用图片") ||
normalized.includes("未包含可用图片") ||
normalized.includes("未解析到图片") ||
From c889bfc381f999b862b817abc10c5a32d5bf0b97 Mon Sep 17 00:00:00 2001
From: 134
Date: Fri, 24 Jul 2026 14:10:13 +0800
Subject: [PATCH 10/83] feat: add product planning v2 claw skill
---
lib/canvas-skill-tags.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/lib/canvas-skill-tags.ts b/lib/canvas-skill-tags.ts
index 5e2932df3..f80312712 100644
--- a/lib/canvas-skill-tags.ts
+++ b/lib/canvas-skill-tags.ts
@@ -1,6 +1,6 @@
export const CANVAS_SKILL_TAGS_SETTING_KEY = "canvas_skill_tags";
export const CANVAS_SKILL_TAG_ORDER_SETTING_KEY_PREFIX = "canvas_skill_tag_order:";
-export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 4;
+export const CANVAS_SKILL_TAGS_CONFIG_VERSION = 5;
export const DEFAULT_CANVAS_SKILL_TAGS_TITLE = "Claw 快捷技能";
export type CanvasSkillTagSettings = {
@@ -44,6 +44,14 @@ export const DEFAULT_CANVAS_SKILL_TAGS: CanvasSkillTag[] = [
prompt:
"【技能路由:product-asset-api】用途:查询商品详情页、主图、SKU 图、白底图下载链接。触发关键词:详情页、详情页面、详情素材、详情整套、任务详情、SKU图、主图、白底图、白底、SKU:、SPU:、货号 + 详情/图片/白底需求。必须要求用户提供 SKU 或 SPU;提到白底/白底图时查白底图,其他详情/主图/SKU图查任务详情素材。输出下载链接,不默认展开图片预览;命中多组候选时默认展示最新/最相关 1 组,并提示可展开全部详情链接;未命中时说明 SKU/SPU 可能不正确或素材未入库。",
},
+ {
+ id: "product-detail-planning-v2",
+ name: "商品策划案 V2",
+ iconName: "sparkles",
+ placeholder: "输入 SKU/SPU、产品名或上传产品图,开始商品策划案 V2",
+ prompt:
+ "【技能路由:product-detail-planning-v2】用途:通过可恢复的分步对话完成商品详情页全案策划,覆盖产品事实、竞品、真实评价与问大家、内容策划、视觉策划、详情页提示词、五张主图、主图视频、AI 买家秀,以及最终 HTML 和 17 子表 Excel 交付。触发关键词:商品策划案、详情页策划、完整详情策划、新品详情策划、竞品详情分析、详情页改版、主图策划、SKU图策划、主图视频策划、AI买家秀策划、继续详情策划、新版商品策划案、商品策划案V2。通用“商品策划案”默认进入 V2,只有用户明确要求旧版/V1时才使用兼容流程。公司已有商品可提供 SKU/SPU;新品可以提供产品名、现有资料并上传产品图。每轮只处理当前节点,完整展示当前节点数据、系统最优推荐和可选动作,但不得替用户确认或跨节点推进;图片只证明可见结构,不得自动证明材质、参数、检测或服务承诺。全部必需节点完成后生成 HTML 策划案和固定 17 子表 Excel,并返回查看或下载入口。",
+ },
{
id: "install-media-db",
name: "安装资料",
From 80d05e177a420173f5b116f3c3d56c00d80a7cb5 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 10:22:03 +0800
Subject: [PATCH 11/83] fix: keep sent Claw messages out of composer
---
app/api/canvas/clawd-chat/route.ts | 6 --
components/canvas/CanvasChatPanel.tsx | 59 ++++++--------------
lib/openclaw-chat/request-lifecycle.ts | 16 ++++++
playwright.helpers.config.ts | 1 +
tests/routes/clawd-request-lifecycle.test.ts | 51 +++++++++++++++++
5 files changed, 84 insertions(+), 49 deletions(-)
create mode 100644 lib/openclaw-chat/request-lifecycle.ts
create mode 100644 tests/routes/clawd-request-lifecycle.test.ts
diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts
index 01479493a..d360ece3e 100644
--- a/app/api/canvas/clawd-chat/route.ts
+++ b/app/api/canvas/clawd-chat/route.ts
@@ -15,7 +15,6 @@ import {
} from "@/lib/openclaw-chat/config";
import {
buildClawdCancelKey,
- cancelClawdRequest,
clearClawdRequestCancellation,
isClawdRequestCanceled,
registerActiveClawdRequest,
@@ -259,10 +258,6 @@ export async function POST(request: Request) {
const controller = new AbortController();
registerActiveClawdRequest(cancelKey, controller, config.timeoutMs);
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
- const handleRequestAbort = () => {
- cancelClawdRequest(cancelKey);
- };
- request.signal.addEventListener("abort", handleRequestAbort, { once: true });
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -302,7 +297,6 @@ export async function POST(request: Request) {
signal: controller.signal,
}).finally(() => {
clearTimeout(timeout);
- request.signal.removeEventListener("abort", handleRequestAbort);
unregisterActiveClawdRequest(cancelKey);
});
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index 8c35ab4a2..5f8f3529d 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -101,6 +101,10 @@ import {
normalizeCanvasSkillTags,
type CanvasSkillTag,
} from "@/lib/canvas-skill-tags";
+import {
+ isClawdTransportInterruption,
+ shouldRestoreClawdComposerAfterFailure,
+} from "@/lib/openclaw-chat/request-lifecycle";
import {
isCanvasImageReadyForAi,
isCanvasImageUploadPending,
@@ -11199,8 +11203,7 @@ export function CanvasChatPanel({
});
const responseData = await response.json().catch(() => null);
if (response.status === 499 || responseData?.canceled === true) {
- canceledClawdMessageIdsRef.current.add(userMessageId);
- throw new DOMException("Claw request canceled", "AbortError");
+ throw new Error("Claw request interrupted");
}
if (!response.ok) {
console.error("[CanvasChat] Claw API returned error:", {
@@ -11251,10 +11254,13 @@ export function CanvasChatPanel({
void mutateClawdHistory();
hasHydratedHistoryRef.current = true;
} catch (error) {
- const wasCanceled =
- canceledClawdMessageIdsRef.current.has(userMessageId) ||
- (error instanceof Error && error.name === "AbortError");
- if (wasCanceled) {
+ const explicitlyCanceled =
+ canceledClawdMessageIdsRef.current.has(userMessageId);
+ if (
+ shouldRestoreClawdComposerAfterFailure({
+ explicitlyCanceled,
+ })
+ ) {
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
pendingClawdRequestRestoreRef.current = null;
restorePendingClawdRequestToComposer(clawdRequestRestoreSnapshot);
@@ -11263,44 +11269,11 @@ export function CanvasChatPanel({
return;
}
console.error("[CanvasChat] Claw request failed:", error);
- pendingClawdSubmissionRef.current = null;
- if (recentClawdSubmissionRef.current === clawdSubmissionSnapshot) {
- recentClawdSubmissionRef.current = null;
- writeStoredClawdComposerSubmissionSnapshot(null);
- }
- setMessages((previousMessages) =>
- previousMessages.filter((message) => message.id !== userMessageId)
+ toast.error(
+ isClawdTransportInterruption(error)
+ ? "连接已中断,任务仍可能在后台处理,请稍后查看 Claw 历史"
+ : CLAW_UPGRADE_RETRY_MESSAGE
);
- toast.error(CLAW_UPGRADE_RETRY_MESSAGE);
- if (!isProgrammaticClawdQuickSend) {
- setClawdTableFiles(tableFilesSnapshot);
- setAttachments(attachmentsSnapshot);
- if (clawdSkillTagForRequest && hasPendingClawdSkillTagPrompt) {
- setSelectedSkillTagId(clawdSkillTagForRequest.id);
- pendingSelectedClawdSkillTagRef.current = clawdSkillTagForRequest;
- }
- const editableDiv = textareaRef.current as HTMLDivElement | null;
- const currentEditorText = (editableDiv?.textContent || "")
- .replace(/\u200b/g, " ")
- .replace(/\s+/g, " ")
- .trim();
- if (!currentEditorText) {
- setInput(textContent);
- if (editableDiv) {
- editableDiv.textContent = textContent;
- window.setTimeout(() => {
- attachmentsSnapshot.forEach((attachment, index) => {
- if (!attachment.url) return;
- insertImageTagInEditor(
- attachment.url,
- normalizeChatAttachmentName(attachment.name),
- index
- );
- });
- }, 0);
- }
- }
- }
} finally {
pendingClawdSubmissionRef.current = null;
if (pendingClawdRequestRestoreRef.current === clawdRequestRestoreSnapshot) {
diff --git a/lib/openclaw-chat/request-lifecycle.ts b/lib/openclaw-chat/request-lifecycle.ts
new file mode 100644
index 000000000..91bbc9f08
--- /dev/null
+++ b/lib/openclaw-chat/request-lifecycle.ts
@@ -0,0 +1,16 @@
+export function shouldRestoreClawdComposerAfterFailure({
+ explicitlyCanceled,
+}: {
+ explicitlyCanceled: boolean;
+}) {
+ return explicitlyCanceled;
+}
+
+export function isClawdTransportInterruption(error: unknown) {
+ if (!(error instanceof Error)) return false;
+
+ return (
+ error.name === "AbortError" ||
+ /aborted|failed to fetch|fetch failed|network|interrupted/i.test(error.message)
+ );
+}
diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts
index 6862b52e1..1c10d4ef4 100644
--- a/playwright.helpers.config.ts
+++ b/playwright.helpers.config.ts
@@ -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\/clawd-request-lifecycle\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,
diff --git a/tests/routes/clawd-request-lifecycle.test.ts b/tests/routes/clawd-request-lifecycle.test.ts
new file mode 100644
index 000000000..e501f02ae
--- /dev/null
+++ b/tests/routes/clawd-request-lifecycle.test.ts
@@ -0,0 +1,51 @@
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { expect, test } from "@playwright/test";
+import {
+ isClawdTransportInterruption,
+ shouldRestoreClawdComposerAfterFailure,
+} from "@/lib/openclaw-chat/request-lifecycle";
+
+function readSource(path: string) {
+ return readFileSync(resolve(process.cwd(), path), "utf8");
+}
+
+test.describe("Claw request lifecycle", () => {
+ test("restores the composer only after an explicit user cancellation", () => {
+ expect(
+ shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: true })
+ ).toBe(true);
+ expect(
+ shouldRestoreClawdComposerAfterFailure({ explicitlyCanceled: false })
+ ).toBe(false);
+ });
+
+ test("recognizes browser and network interruptions without treating them as edits", () => {
+ expect(
+ isClawdTransportInterruption(
+ new DOMException("The operation was aborted", "AbortError")
+ )
+ ).toBe(true);
+ expect(isClawdTransportInterruption(new Error("fetch failed"))).toBe(true);
+ expect(isClawdTransportInterruption(new Error("provider unavailable"))).toBe(
+ false
+ );
+ });
+
+ test("does not cancel the upstream request when the browser connection closes", () => {
+ const source = readSource("app/api/canvas/clawd-chat/route.ts");
+
+ expect(source).not.toContain('request.signal.addEventListener("abort"');
+ expect(source).not.toContain("cancelClawdRequest(cancelKey)");
+ expect(source).toContain("registerActiveClawdRequest(cancelKey");
+ });
+
+ test("does not classify every AbortError as an explicit stop action", () => {
+ const source = readSource("components/canvas/CanvasChatPanel.tsx");
+
+ expect(source).toContain("shouldRestoreClawdComposerAfterFailure");
+ expect(source).not.toContain(
+ '(error instanceof Error && error.name === "AbortError")'
+ );
+ });
+});
From de8b7670b222330bc2c1f76806c6f978f885c95f Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 17:30:01 +0800
Subject: [PATCH 12/83] fix: support Claw AVIF and TIFF uploads
---
app/(chat)/api/files/upload/route.ts | 79 ++++++++++++++++++++++++++-
components/canvas/CanvasChatPanel.tsx | 46 +++++++++++++---
lib/storage/service.ts | 5 ++
3 files changed, 119 insertions(+), 11 deletions(-)
diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts
index 0a5297cb1..831484ff4 100644
--- a/app/(chat)/api/files/upload/route.ts
+++ b/app/(chat)/api/files/upload/route.ts
@@ -14,6 +14,9 @@ const ALLOWED_UPLOAD_IMAGE_TYPES = new Set([
"image/jpg",
"image/png",
"image/webp",
+ "image/avif",
+ "image/tiff",
+ "image/x-tiff",
"image/svg+xml",
"image/heic",
"image/heif",
@@ -25,6 +28,11 @@ const ALLOWED_ATLASCLOUD_VIDEO_TYPES = new Set([
]);
const HEIC_UPLOAD_TYPES = new Set(["image/heic", "image/heif"]);
+const SHARP_COMPATIBILITY_UPLOAD_TYPES = new Set([
+ "image/avif",
+ "image/tiff",
+ "image/x-tiff",
+]);
function normalizeUploadContentType(contentType: string, filename: string) {
const normalized = contentType.trim().toLowerCase();
@@ -40,8 +48,16 @@ function createUnsupportedImageTypeMessage(contentType: string, filename: string
if (contentType === "image/heic" || contentType === "image/heif" || extension === "heic" || extension === "heif") {
return "HEIC/HEIF 图片转码失败,请在手机相册中导出为 JPG/PNG 后再上传。";
}
+ if (
+ SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
+ extension === "avif" ||
+ extension === "tif" ||
+ extension === "tiff"
+ ) {
+ return "AVIF/TIFF 图片转码失败,请导出为 JPG/PNG 后再上传。";
+ }
- return "File type should be JPEG, PNG, WebP or SVG";
+ return "图片格式不支持,请上传 JPG、PNG、WebP、HEIC、AVIF 或 TIFF 图片。";
}
function isHeicUpload(contentType: string, filename: string) {
@@ -53,10 +69,20 @@ function isHeicUpload(contentType: string, filename: string) {
);
}
+function isSharpCompatibilityUpload(contentType: string, filename: string) {
+ const extension = filename.toLowerCase().split(".").pop() || "";
+ return (
+ SHARP_COMPATIBILITY_UPLOAD_TYPES.has(contentType) ||
+ extension === "avif" ||
+ extension === "tif" ||
+ extension === "tiff"
+ );
+}
+
function toJpegFilename(filename: string) {
const trimmed = filename.trim() || `upload-${Date.now()}`;
- if (/\.(heic|heif)$/i.test(trimmed)) {
- return trimmed.replace(/\.(heic|heif)$/i, ".jpg");
+ if (/\.(avif|heic|heif|tif|tiff)$/i.test(trimmed)) {
+ return trimmed.replace(/\.(avif|heic|heif|tif|tiff)$/i, ".jpg");
}
if (/\.[a-z0-9]+$/i.test(trimmed)) {
return trimmed.replace(/\.[a-z0-9]+$/i, ".jpg");
@@ -76,6 +102,21 @@ async function convertHeicToJpeg(buffer: Buffer): Promise {
}
return Buffer.from(output);
}
+
+async function convertSharpCompatibilityImageToJpeg(
+ buffer: Buffer
+): Promise {
+ const sharp = (await import("sharp")).default;
+ return sharp(buffer, { animated: false, failOn: "none" })
+ .rotate()
+ .flatten({ background: "#ffffff" })
+ .jpeg({
+ quality: 92,
+ mozjpeg: true,
+ progressive: true,
+ })
+ .toBuffer();
+}
/**
* 使用Sharp智能优化图片(保持原格式)
* 策略:避免双重压缩,只在需要时优化
@@ -288,6 +329,38 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
+ } else if (isSharpCompatibilityUpload(contentType, filename)) {
+ try {
+ const convertedBuffer =
+ await convertSharpCompatibilityImageToJpeg(uploadBuffer);
+ logger.info("[Upload API] Compatibility image converted to JPEG", {
+ clientUploadId,
+ originalContentType: contentType,
+ originalSize: uploadBuffer.length,
+ convertedSize: convertedBuffer.length,
+ });
+ uploadBuffer = convertedBuffer;
+ filename = toJpegFilename(filename);
+ convertedFrom = contentType;
+ contentType = "image/jpeg";
+ } catch (conversionError) {
+ logger.warn("[Upload API] Compatibility image conversion failed", {
+ clientUploadId,
+ contentType,
+ size: uploadBuffer.length,
+ error:
+ conversionError instanceof Error
+ ? conversionError.message
+ : String(conversionError),
+ });
+ return NextResponse.json(
+ {
+ error: createUnsupportedImageTypeMessage(contentType, filename),
+ clientUploadId,
+ },
+ { status: 400 }
+ );
+ }
}
if (uploadPurpose === "agent-workflow-preview" && contentType !== "image/svg+xml") {
diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx
index 5f8f3529d..acb17211f 100644
--- a/components/canvas/CanvasChatPanel.tsx
+++ b/components/canvas/CanvasChatPanel.tsx
@@ -415,6 +415,19 @@ function isClawdChatMessage(message: unknown) {
const CLAWD_TABLE_ACCEPT =
".xlsx,.csv,.tsv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,text/tab-separated-values";
const CLAWD_TABLE_ALLOWED_EXTENSIONS = new Set(["xlsx", "csv", "tsv"]);
+const CLAWD_IMAGE_ACCEPT =
+ ".jpg,.jpeg,.png,.webp,.heic,.heif,.avif,.tif,.tiff,image/jpeg,image/png,image/webp,image/heic,image/heif,image/avif,image/tiff";
+const CLAWD_IMAGE_ALLOWED_EXTENSIONS = new Set([
+ "jpg",
+ "jpeg",
+ "png",
+ "webp",
+ "heic",
+ "heif",
+ "avif",
+ "tif",
+ "tiff",
+]);
const CLAWD_TABLE_MAX_FILES = 3;
const CLAWD_TABLE_MAX_BYTES = 10 * 1024 * 1024;
const CLAWD_PROCESSING_HINT =
@@ -444,6 +457,13 @@ function isClawdTableFile(file: File) {
);
}
+function isClawdImageFile(file: File) {
+ return (
+ file.type.startsWith("image/") ||
+ CLAWD_IMAGE_ALLOWED_EXTENSIONS.has(getClawdTableFileExtension(file.name))
+ );
+}
+
function normalizeClawdComposerText(value: string) {
return String(value || "")
.replace(/\u200b/g, " ")
@@ -11906,7 +11926,7 @@ export function CanvasChatPanel({
width?: number;
height?: number;
} | null> => {
- if (!file.type.startsWith("image/")) {
+ if (!isClawdImageFile(file)) {
toast.error("请选择图片文件");
return null;
}
@@ -11936,7 +11956,15 @@ export function CanvasChatPanel({
}
: undefined);
if (!response.ok) {
- throw new Error("上传失败");
+ const payload = (await response.json().catch(() => null)) as {
+ error?: string;
+ details?: string;
+ } | null;
+ throw new Error(
+ payload?.error?.trim() ||
+ payload?.details?.trim() ||
+ "图片上传失败"
+ );
}
const data = await response.json();
@@ -11954,7 +11982,11 @@ export function CanvasChatPanel({
assetId,
};
} catch (_error) {
- toast.error("图片上传失败");
+ toast.error(
+ _error instanceof Error && _error.message
+ ? _error.message
+ : "图片上传失败"
+ );
return null;
}
}, [clearComposerUploadProgress, updateComposerUploadProgress]);
@@ -12289,11 +12321,9 @@ export function CanvasChatPanel({
if (isClawdMode) {
const tableFiles = selectedFiles.filter((file) => isClawdTableFile(file));
- const imageFiles = selectedFiles.filter((file) =>
- file.type.startsWith("image/")
- );
+ const imageFiles = selectedFiles.filter((file) => isClawdImageFile(file));
const unsupportedFiles = selectedFiles.filter(
- (file) => !isClawdTableFile(file) && !file.type.startsWith("image/")
+ (file) => !isClawdTableFile(file) && !isClawdImageFile(file)
);
unsupportedFiles.forEach((file) => {
@@ -16095,7 +16125,7 @@ export function CanvasChatPanel({
type="file"
accept={
isClawdMode
- ? `image/*,${CLAWD_TABLE_ACCEPT}`
+ ? `${CLAWD_IMAGE_ACCEPT},${CLAWD_TABLE_ACCEPT}`
: supportsAtlasVideoReference
? "image/*,video/mp4,video/quicktime,video/webm"
: "image/*"
diff --git a/lib/storage/service.ts b/lib/storage/service.ts
index 8027d503c..7e6da8547 100644
--- a/lib/storage/service.ts
+++ b/lib/storage/service.ts
@@ -109,6 +109,11 @@ export function getContentType(filename: string): string {
png: "image/png",
gif: "image/gif",
webp: "image/webp",
+ avif: "image/avif",
+ tif: "image/tiff",
+ tiff: "image/tiff",
+ heic: "image/heic",
+ heif: "image/heif",
svg: "image/svg+xml",
psd: "image/vnd.adobe.photoshop",
psb: "application/octet-stream",
From 11deb3bba668003fc27f1aa611377ca18c8e1f60 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 18:24:20 +0800
Subject: [PATCH 13/83] perf: streamline HiCAD startup
---
deploy/nginx-hicad-zoo-ws.conf.template | 1 +
playwright.helpers.config.ts | 1 +
scripts/vendor-zoo-modeling-app.mjs | 31 ++++++++++++-
.../routes/hicad-embedded-performance.test.ts | 43 +++++++++++++++++++
vendor/zoo-modeling-app/localization.ts | 1 +
5 files changed, 76 insertions(+), 1 deletion(-)
create mode 100644 tests/routes/hicad-embedded-performance.test.ts
diff --git a/deploy/nginx-hicad-zoo-ws.conf.template b/deploy/nginx-hicad-zoo-ws.conf.template
index 3106772db..a68500f8d 100644
--- a/deploy/nginx-hicad-zoo-ws.conf.template
+++ b/deploy/nginx-hicad-zoo-ws.conf.template
@@ -17,6 +17,7 @@ location ^~ /vendor/zoo-modeling-app/fonts/ {
location = /vendor/zoo-modeling-app/kcl_wasm_lib_bg.wasm {
root /var/www/ai-chatbot/public;
try_files $uri =404;
+ gzip_static on;
add_header Cache-Control "public, max-age=3600" always;
}
diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts
index 1c10d4ef4..f30d2518d 100644
--- a/playwright.helpers.config.ts
+++ b/playwright.helpers.config.ts
@@ -14,6 +14,7 @@ export default defineConfig({
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
/routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
+ /routes\/hicad-embedded-performance\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
index 871816cfe..391df5af4 100644
--- a/scripts/vendor-zoo-modeling-app.mjs
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -140,6 +140,12 @@ function patchOfficialSource() {
" Zoo Design Studio",
" HiCAD"
);
+ replaceSource(
+ "index.html",
+ ' ',
+ `
+ `
+ );
replaceSource(
"src/Router.tsx",
" createBrowserRouter,\n createHashRouter,",
@@ -225,6 +231,19 @@ function patchOfficialSource() {
` className="cursor-pointer"\n id="freeze-frame"`,
` className="absolute inset-0 h-full w-full cursor-pointer"\n id="freeze-frame"`
);
+ replaceSource(
+ "src/components/OpenedProject.tsx",
+ "import { getMlEphantProjectReloadBehavior } from '@src/components/openedProjectUtils'\n",
+ "import { getMlEphantProjectReloadBehavior } from '@src/components/openedProjectUtils'\nimport env from '@src/env'\n"
+ );
+ replaceSource(
+ "src/components/OpenedProject.tsx",
+ ` const needsOnboarded =
+ !window.electron &&`,
+ ` const needsOnboarded =
+ env().VITE_ZOO_API_TOKEN !== '${proxyToken}' &&
+ !window.electron &&`
+ );
replaceSource(
"src/index.tsx",
"import { markOnce } from '@src/lib/performance'\n",
@@ -357,7 +376,7 @@ function ensureWasm() {
function precompressVersionedAssets(assetsDir) {
for (const entry of readdirSync(assetsDir, { withFileTypes: true })) {
- if (!entry.isFile() || !/\.(?:css|js)$/.test(entry.name)) {
+ if (!entry.isFile() || !/\.(?:css|js|wasm)$/.test(entry.name)) {
continue;
}
const assetPath = path.join(assetsDir, entry.name);
@@ -365,6 +384,15 @@ function precompressVersionedAssets(assetsDir) {
}
}
+function precompressRootWasm(buildDir) {
+ const wasmPath = path.join(buildDir, "kcl_wasm_lib_bg.wasm");
+ if (!existsSync(wasmPath)) return;
+ writeFileSync(
+ `${wasmPath}.gz`,
+ gzipSync(readFileSync(wasmPath), { level: 9 })
+ );
+}
+
async function main() {
ensureOfficialSource();
ensureDependencies();
@@ -393,6 +421,7 @@ async function main() {
throw new Error("Zoo Design Studio build did not produce index.html");
}
precompressVersionedAssets(path.join(buildDir, "assets"));
+ precompressRootWasm(buildDir);
cacheBustZooModelingAppAssets(buildDir);
rmSync(outputDir, { force: true, recursive: true });
diff --git a/tests/routes/hicad-embedded-performance.test.ts b/tests/routes/hicad-embedded-performance.test.ts
new file mode 100644
index 000000000..40e936a5f
--- /dev/null
+++ b/tests/routes/hicad-embedded-performance.test.ts
@@ -0,0 +1,43 @@
+import { expect, test } from "@playwright/test";
+import fs from "node:fs";
+import path from "node:path";
+
+const projectRoot = process.cwd();
+
+test.describe("HiCAD embedded startup", () => {
+ test("suppresses the upstream onboarding chooser in embedded mode", () => {
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
+ "utf8"
+ );
+ const localization = fs.readFileSync(
+ path.join(projectRoot, "vendor/zoo-modeling-app/localization.ts"),
+ "utf8"
+ );
+
+ expect(vendorScript).toMatch(
+ /env\(\)\.VITE_ZOO_API_TOKEN !== '\$\{proxyToken\}'/
+ );
+ expect(localization).toContain("[data-testid='onboarding-toast']");
+ });
+
+ test("preloads and serves the CAD wasm using a precompressed asset", () => {
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
+ "utf8"
+ );
+ const nginxTemplate = fs.readFileSync(
+ path.join(projectRoot, "deploy/nginx-hicad-zoo-ws.conf.template"),
+ "utf8"
+ );
+
+ expect(vendorScript).toContain(
+ 'rel="preload" href="./kcl_wasm_lib_bg.wasm"'
+ );
+ expect(vendorScript).toContain("/\\.(?:css|js|wasm)$/");
+ expect(vendorScript).toContain("precompressRootWasm(buildDir)");
+ expect(nginxTemplate).toMatch(
+ /location = \/vendor\/zoo-modeling-app\/kcl_wasm_lib_bg\.wasm \{[\s\S]*?gzip_static on;/
+ );
+ });
+});
diff --git a/vendor/zoo-modeling-app/localization.ts b/vendor/zoo-modeling-app/localization.ts
index 7d8782b22..37f02b274 100644
--- a/vendor/zoo-modeling-app/localization.ts
+++ b/vendor/zoo-modeling-app/localization.ts
@@ -199,6 +199,7 @@ export function installHiCadLocalization() {
}
html[data-hicad-embedded='true'] [data-testid='download-desktop-app'],
html[data-hicad-embedded='true'] [data-testid='billing-remaining-bar'],
+ html[data-hicad-embedded='true'] [data-testid='onboarding-toast'],
html[data-hicad-embedded='true'] [data-hicad-official-account-link] {
display: none !important;
}
From f65b9e047261840c9650b97a5d59b3c8439daeb6 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 19:20:20 +0800
Subject: [PATCH 14/83] fix: frame complete HiCAD models after generation
---
scripts/vendor-zoo-modeling-app.mjs | 19 +++++++++++++++++++
.../routes/hicad-embedded-performance.test.ts | 14 ++++++++++++++
2 files changed, 33 insertions(+)
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
index 391df5af4..bc596e1f6 100644
--- a/scripts/vendor-zoo-modeling-app.mjs
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -269,6 +269,25 @@ function patchOfficialSource() {
` // We need a padding of 0.1 for zoom_to_fit for all E2E tests since they were originally\n // written with zoom_to_fit with padding 0.1\n const padding = 0.1\n if (isPlaywright()) {`,
` // Keep the official E2E framing stable while giving HiCAD users enough\n // breathing room to see the complete model after generation and file loads.\n const padding = isPlaywright() ? 0.1 : 0.35\n if (isPlaywright()) {`
);
+ replaceSource(
+ "src/lib/resetCameraPosition.ts",
+ ` await engineViewIsometric({
+ engineCommandManager,
+ padding,
+ })`,
+ ` await engineViewIsometric({
+ engineCommandManager,
+ padding,
+ })
+
+ // view_isometric changes the direction but can preserve the previous
+ // close-up distance. Refit against every generated entity so newly
+ // completed models open fully framed instead of showing a local detail.
+ await engineStreamZoomToFit({
+ engineCommandManager,
+ padding,
+ })`
+ );
replaceSource(
"src/lib/commandBarConfigs/standardViewsConfig.ts",
"engineStreamZoomToFit({ engineCommandManager, padding: 0.1 })",
diff --git a/tests/routes/hicad-embedded-performance.test.ts b/tests/routes/hicad-embedded-performance.test.ts
index 40e936a5f..32f56b122 100644
--- a/tests/routes/hicad-embedded-performance.test.ts
+++ b/tests/routes/hicad-embedded-performance.test.ts
@@ -40,4 +40,18 @@ test.describe("HiCAD embedded startup", () => {
/location = \/vendor\/zoo-modeling-app\/kcl_wasm_lib_bg\.wasm \{[\s\S]*?gzip_static on;/
);
});
+
+ test("fits the complete generated model after selecting the isometric view", () => {
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
+ "utf8"
+ );
+
+ expect(vendorScript).toMatch(
+ /await engineViewIsometric\(\{[\s\S]*?await engineStreamZoomToFit\(\{\s*engineCommandManager,\s*padding,/
+ );
+ expect(vendorScript).toContain(
+ "Refit against every generated entity so newly"
+ );
+ });
});
From 240352362ae016d6d7a20d8174ee55159b1d6053 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 19:33:41 +0800
Subject: [PATCH 15/83] feat: upgrade embedded HiCAD to latest official source
---
scripts/vendor-zoo-modeling-app.mjs | 4 ++--
tests/routes/hicad-embedded-performance.test.ts | 11 +++++++++++
2 files changed, 13 insertions(+), 2 deletions(-)
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
index bc596e1f6..ebd939eb8 100644
--- a/scripts/vendor-zoo-modeling-app.mjs
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -18,7 +18,7 @@ import { cacheBustZooModelingAppAssets } from "./cache-bust-zoo-modeling-app-ass
const repoUrl = "https://github.com/KittyCAD/modeling-app.git";
const repoRef =
process.env.ZOO_MODELING_APP_REF ||
- "6f87f4e66c07c67f09d8f7db284496a3ae3332e7";
+ "f2b345dbb56a851e10d8a742732ab73ee139f025";
const repoTarballUrls = [
`https://ghfast.top/https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
`https://github.com/KittyCAD/modeling-app/archive/${repoRef}.tar.gz`,
@@ -167,7 +167,7 @@ function patchOfficialSource() {
` ws.addEventListener('open', () => {\n if (token !== '${proxyToken}') {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n }\n resolve(ws)\n })`
);
replaceSource(
- "src/network/websocketConnection.ts",
+ "src/lib/engineConnection/websocketConnection.ts",
" if (token) {",
` if (token && token !== '${proxyToken}') {`
);
diff --git a/tests/routes/hicad-embedded-performance.test.ts b/tests/routes/hicad-embedded-performance.test.ts
index 32f56b122..184850ad8 100644
--- a/tests/routes/hicad-embedded-performance.test.ts
+++ b/tests/routes/hicad-embedded-performance.test.ts
@@ -3,8 +3,19 @@ import fs from "node:fs";
import path from "node:path";
const projectRoot = process.cwd();
+const latestReviewedOfficialCommit =
+ "f2b345dbb56a851e10d8a742732ab73ee139f025";
test.describe("HiCAD embedded startup", () => {
+ test("pins the latest reviewed official Zoo Design Studio source", () => {
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
+ "utf8"
+ );
+
+ expect(vendorScript).toContain(latestReviewedOfficialCommit);
+ });
+
test("suppresses the upstream onboarding chooser in embedded mode", () => {
const vendorScript = fs.readFileSync(
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
From 08d2503e11fd5a46eef96f8f48dd857113092781 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 19:41:51 +0800
Subject: [PATCH 16/83] fix: use site favicon in HiCAD
---
scripts/vendor-zoo-modeling-app.mjs | 28 +++++++++++++++++++
.../routes/hicad-embedded-performance.test.ts | 20 +++++++++++++
2 files changed, 48 insertions(+)
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
index ebd939eb8..56cc5a91d 100644
--- a/scripts/vendor-zoo-modeling-app.mjs
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -30,6 +30,7 @@ const sourceRefFile = path.join(sourceDir, ".hicad-source-ref");
const outputDir = path.join(root, "public/vendor/zoo-modeling-app");
const customizationDir = path.join(root, "vendor/zoo-modeling-app");
const proxyToken = "hicad-session";
+const brandedFaviconUrl = "/api/favicon?surface=hicad";
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -112,6 +113,22 @@ function replaceSource(relativePath, before, after) {
writeFileSync(filePath, source.replace(before, after));
}
+function patchOfficialManifest() {
+ const manifestPath = path.join(sourceDir, "public/manifest.json");
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
+
+ manifest.short_name = "HiCAD";
+ manifest.name = "HiCAD";
+ manifest.icons = [
+ {
+ src: brandedFaviconUrl,
+ sizes: "any",
+ },
+ ];
+
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+}
+
function patchOfficialSource() {
copyFileSync(
path.join(customizationDir, "Logo.tsx"),
@@ -130,6 +147,17 @@ function patchOfficialSource() {
' ',
' '
);
+ replaceSource(
+ "index.html",
+ ' ',
+ ` `
+ );
+ replaceSource(
+ "index.html",
+ ' ',
+ ` `
+ );
+ patchOfficialManifest();
replaceSource(
"index.html",
' \n',
diff --git a/tests/routes/hicad-embedded-performance.test.ts b/tests/routes/hicad-embedded-performance.test.ts
index 184850ad8..3f6ac4df0 100644
--- a/tests/routes/hicad-embedded-performance.test.ts
+++ b/tests/routes/hicad-embedded-performance.test.ts
@@ -16,6 +16,26 @@ test.describe("HiCAD embedded startup", () => {
expect(vendorScript).toContain(latestReviewedOfficialCommit);
});
+ test("uses the website favicon across embedded browser surfaces", () => {
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
+ "utf8"
+ );
+
+ expect(vendorScript).toContain(
+ 'const brandedFaviconUrl = "/api/favicon?surface=hicad"'
+ );
+ expect(vendorScript).toContain(
+ '` `'
+ );
+ expect(vendorScript).toContain(
+ '` `'
+ );
+ expect(vendorScript).toMatch(
+ /manifest\.icons = \[\s*\{\s*src: brandedFaviconUrl,\s*sizes: "any",/
+ );
+ });
+
test("suppresses the upstream onboarding chooser in embedded mode", () => {
const vendorScript = fs.readFileSync(
path.join(projectRoot, "scripts/vendor-zoo-modeling-app.mjs"),
From 7435e7b0f0a4d3f0d1f95183ba5a5ee6d74c5db1 Mon Sep 17 00:00:00 2001
From: 134
Date: Mon, 27 Jul 2026 19:55:14 +0800
Subject: [PATCH 17/83] feat: add HiCAD manufacturing drawings
---
playwright.helpers.config.ts | 1 +
scripts/vendor-zoo-modeling-app.mjs | 18 +
.../hicad-manufacturing-extension.test.ts | 138 ++++
.../HicadManufacturingPanel.tsx | 593 ++++++++++++++++++
vendor/zoo-modeling-app/README.md | 31 +
vendor/zoo-modeling-app/manufacturing.ts | 401 ++++++++++++
6 files changed, 1182 insertions(+)
create mode 100644 tests/routes/hicad-manufacturing-extension.test.ts
create mode 100644 vendor/zoo-modeling-app/HicadManufacturingPanel.tsx
create mode 100644 vendor/zoo-modeling-app/README.md
create mode 100644 vendor/zoo-modeling-app/manufacturing.ts
diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts
index f30d2518d..b0d63cfa4 100644
--- a/playwright.helpers.config.ts
+++ b/playwright.helpers.config.ts
@@ -15,6 +15,7 @@ export default defineConfig({
/routes\/model-classification\.test\.ts/,
/routes\/clawd-request-lifecycle\.test\.ts/,
/routes\/hicad-embedded-performance\.test\.ts/,
+ /routes\/hicad-manufacturing-extension\.test\.ts/,
],
fullyParallel: true,
forbidOnly: !!process.env.CI,
diff --git a/scripts/vendor-zoo-modeling-app.mjs b/scripts/vendor-zoo-modeling-app.mjs
index 56cc5a91d..3389f0eb2 100644
--- a/scripts/vendor-zoo-modeling-app.mjs
+++ b/scripts/vendor-zoo-modeling-app.mjs
@@ -134,6 +134,14 @@ function patchOfficialSource() {
path.join(customizationDir, "Logo.tsx"),
path.join(sourceDir, "src/components/Logo.tsx")
);
+ copyFileSync(
+ path.join(customizationDir, "manufacturing.ts"),
+ path.join(sourceDir, "src/components/manufacturing.ts")
+ );
+ copyFileSync(
+ path.join(customizationDir, "HicadManufacturingPanel.tsx"),
+ path.join(sourceDir, "src/components/HicadManufacturingPanel.tsx")
+ );
copyFileSync(
path.join(customizationDir, "localization.ts"),
path.join(sourceDir, "src/lib/hicadLocalization.ts")
@@ -189,6 +197,16 @@ function patchOfficialSource() {
"import { isDesktop } from '@src/lib/isDesktop'\n",
""
);
+ replaceSource(
+ "src/components/OpenedProject.tsx",
+ "import { AppHeader } from '@src/components/AppHeader'\n",
+ "import { AppHeader } from '@src/components/AppHeader'\nimport { HicadManufacturingPanel } from '@src/components/HicadManufacturingPanel'\n"
+ );
+ replaceSource(
+ "src/components/OpenedProject.tsx",
+ " \n {\n ws.send(\n JSON.stringify({\n type: 'headers',\n headers: {\n Authorization: 'Bearer ' + token,\n },\n })\n )\n resolve(ws)\n })`,
diff --git a/tests/routes/hicad-manufacturing-extension.test.ts b/tests/routes/hicad-manufacturing-extension.test.ts
new file mode 100644
index 000000000..f86ab885d
--- /dev/null
+++ b/tests/routes/hicad-manufacturing-extension.test.ts
@@ -0,0 +1,138 @@
+import { expect, test } from '@playwright/test'
+import fs from 'node:fs'
+import path from 'node:path'
+import {
+ buildExplodeOffsets,
+ createBomCsv,
+ createEngineeringSheetHtml,
+ groupBodiesIntoBom,
+ type BodyMeasurement,
+} from '../../vendor/zoo-modeling-app/manufacturing'
+
+const bodies: BodyMeasurement[] = [
+ {
+ id: 'left-leg',
+ name: 'leg001',
+ center: { x: -50, y: 0, z: 20 },
+ dimensions: { x: 10, y: 10, z: 80 },
+ volume: 8_000,
+ surfaceArea: 3_400,
+ },
+ {
+ id: 'right-leg',
+ name: 'leg002',
+ center: { x: 50, y: 0, z: 20 },
+ dimensions: { x: 10, y: 10, z: 80 },
+ volume: 8_000,
+ surfaceArea: 3_400,
+ },
+ {
+ id: 'top',
+ name: 'tableTop',
+ center: { x: 0, y: 0, z: 65 },
+ dimensions: { x: 120, y: 60, z: 12 },
+ volume: 86_400,
+ surfaceArea: 18_720,
+ },
+]
+
+test.describe('HiCAD manufacturing extension', () => {
+ test('keeps the extension isolated behind one official registration point', () => {
+ const projectRoot = process.cwd()
+ const vendorScript = fs.readFileSync(
+ path.join(projectRoot, 'scripts/vendor-zoo-modeling-app.mjs'),
+ 'utf8'
+ )
+
+ expect(vendorScript).toContain(
+ 'vendor/zoo-modeling-app/HicadManufacturingPanel.tsx'
+ .split('/')
+ .at(-1)
+ )
+ expect(vendorScript).toContain(
+ "import { HicadManufacturingPanel } from '@src/components/HicadManufacturingPanel'"
+ )
+ expect(vendorScript).toContain(
+ ""
+ )
+ })
+
+ test('creates non-zero reversible offsets for every independent body', () => {
+ const offsets = buildExplodeOffsets(bodies)
+
+ expect(offsets).toHaveLength(3)
+ expect(new Set(offsets.map((offset) => offset.id)).size).toBe(3)
+ expect(
+ offsets.every(
+ ({ offset }) => Math.hypot(offset.x, offset.y, offset.z) > 0
+ )
+ ).toBe(true)
+ expect(buildExplodeOffsets([bodies[0]])).toEqual([])
+ })
+
+ test('groups matching parts and exports a UTF-8 BOM', () => {
+ const rows = groupBodiesIntoBom(bodies)
+ const legRow = rows.find((row) => row.name === 'leg')
+
+ expect(rows).toHaveLength(2)
+ expect(legRow?.quantity).toBe(2)
+ expect(legRow?.bodyIds).toEqual(['left-leg', 'right-leg'])
+
+ const csv = createBomCsv('工作台', rows)
+ expect(csv.startsWith('\uFEFF')).toBe(true)
+ expect(csv).toContain('"工作台"')
+ expect(csv).toContain('"生产工艺"')
+ })
+
+ test('builds a printable dimensioned engineering sheet safely', () => {
+ const rows = groupBodiesIntoBom(bodies)
+ const html = createEngineeringSheetHtml({
+ projectName: '<工作台>',
+ generatedAt: '2026-07-27 20:00',
+ overallDimensions: { x: 120, y: 60, z: 92 },
+ snapshotDataUrl: 'data:image/png;base64,abc',
+ bomRows: rows,
+ })
+
+ expect(html).toContain('<工作台>')
+ expect(html).not.toContain('<工作台>')
+ expect(html).toContain('正视图')
+ expect(html).toContain('俯视图')
+ expect(html).toContain('右视图')
+ expect(html).toContain('120 mm')
+ expect(html).toContain('配件明细表(BOM)')
+ expect(html).toContain('@page { size: A3 landscape;')
+ })
+
+ test('renders the engineering sheet without horizontal clipping', async ({
+ page,
+ }, testInfo) => {
+ const rows = groupBodiesIntoBom(bodies)
+ const html = createEngineeringSheetHtml({
+ projectName: '工作台生产图',
+ generatedAt: '2026-07-27 20:00',
+ overallDimensions: { x: 120, y: 60, z: 92 },
+ bomRows: rows,
+ notes:
+ '材料、表面处理、公差、连接方式和检验标准须在投产前由工程人员复核。',
+ })
+
+ await page.setViewportSize({ width: 1600, height: 900 })
+ await page.setContent(html)
+
+ const layout = await page.evaluate(() => ({
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth,
+ tableCount: document.querySelectorAll('table').length,
+ dimensionViewCount: document.querySelectorAll('.dimension-view').length,
+ }))
+
+ expect(layout.scrollWidth).toBeLessThanOrEqual(layout.clientWidth)
+ expect(layout.tableCount).toBe(1)
+ expect(layout.dimensionViewCount).toBe(3)
+ await page.screenshot({
+ path: testInfo.outputPath('engineering-sheet.png'),
+ fullPage: true,
+ })
+ })
+})
diff --git a/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx
new file mode 100644
index 000000000..84bc0b044
--- /dev/null
+++ b/vendor/zoo-modeling-app/HicadManufacturingPanel.tsx
@@ -0,0 +1,593 @@
+import type { BoundingBox, ModelingCmd } from '@kittycad/lib'
+import { useSignals } from '@preact/signals-react/runtime'
+import { getBodiesFromArtifactGraph } from '@src/lang/std/artifactGraph'
+import { useSingletons } from '@src/lib/boot'
+import { isModelingResponse } from '@src/lib/kcSdkGuards'
+import { engineStreamZoomToFit, engineViewIsometric } from '@src/lib/utils'
+import { uuidv4 } from '@src/lib/utils'
+import { useMemo, useRef, useState } from 'react'
+import toast from 'react-hot-toast'
+import {
+ buildExplodeOffsets,
+ createBomCsv,
+ createEngineeringSheetHtml,
+ getOverallBounds,
+ groupBodiesIntoBom,
+ type BodyMeasurement,
+ type BomRow,
+ type ExplodeOffset,
+} from './manufacturing'
+
+type ModelingResponse = {
+ type: string
+ data?: unknown
+}
+
+type BomOverrides = Record<
+ string,
+ {
+ material?: string
+ process?: string
+ }
+>
+
+function downloadText(filename: string, text: string, type: string) {
+ const blob = new Blob([text], { type })
+ const url = URL.createObjectURL(blob)
+ const anchor = document.createElement('a')
+ anchor.href = url
+ anchor.download = filename
+ anchor.click()
+ URL.revokeObjectURL(url)
+}
+
+function collectArtifactIds(
+ value: unknown,
+ ids: Set,
+ seen = new WeakSet