From d1582f33e83e8521f7e60cdf6bfc2ad7129ee8e8 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 22:12:33 +0800 Subject: [PATCH 01/17] fix(bbyy): isolate production branding from E2E defaults --- .../settings/admin-settings-client.tsx | 22 ++++++------ app/api/site-settings/route.ts | 34 +++++++++---------- components/canvas/CanvasChatPanel.tsx | 2 +- components/home/lovart-home.tsx | 2 +- components/home/lovart/utils.ts | 2 +- components/layout/brand-top-bar.tsx | 2 +- components/style-pro/StyleProWorkspace.tsx | 2 +- lib/db/queries.ts | 4 +-- lib/db/schema.ts | 6 ++-- scripts/deploy-production-atomic.sh | 5 +++ .../routes/production-local-fallbacks.test.ts | 12 +++++++ 11 files changed, 55 insertions(+), 38 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 5f516b036..2cc67412e 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -2182,7 +2182,7 @@ export function AdminSettingsClient() { usageTimeEnabled: false, usageTimeStart: "08:00", usageTimeEnd: "20:00", - usageTimeMessage: "由壹点累了也是需要休息的", + usageTimeMessage: "BuYi 累了也是需要休息的", defaultTheme: "system", bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", @@ -2224,7 +2224,7 @@ export function AdminSettingsClient() { agentWorkflowDefaultModelId: null, canvasDetailDefaultModelId: null, canvasDetailAgentModelId: null, - lovartComposerPrefix: "让「由壹点AI」", + lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」", lovartComposerPreviewTexts: [ "打造引人注目的社交媒体视觉", "帮我把这张产品图做成高级电商海报", @@ -2240,8 +2240,8 @@ export function AdminSettingsClient() { lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: "立即升级", - lovartHeroTitle: "由壹点AI 让设计更简单", - lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: "下达指令,即刻呈现", surpriseCategories: ["全部", "精选"], membershipConfig: defaultMembershipConfig, membershipUserOverrides: defaultMembershipUserOverrides, @@ -2302,7 +2302,7 @@ export function AdminSettingsClient() { usageTimeStart: siteSettingsData.usageTimeStart || "08:00", usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00", usageTimeMessage: - siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的", + siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的", defaultTheme: siteSettingsData.defaultTheme || "system", bannerEnabled: siteSettingsData.bannerEnabled ?? true, bannerText: @@ -2398,7 +2398,7 @@ export function AdminSettingsClient() { (siteSettingsData as any).canvasDetailAgentModelId || null, lovartComposerPrefix: String((siteSettingsData as any).lovartComposerPrefix || "").trim() || - "让「由壹点AI」", + "让「𝗕𝘂𝗬𝗶」", lovartComposerPreviewTexts: Array.isArray( siteSettingsData.lovartComposerPreviewTexts ) @@ -2500,9 +2500,9 @@ export function AdminSettingsClient() { lovartPromoActionText: siteSettingsData.lovartPromoActionText || "立即升级", lovartHeroTitle: - siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单", + siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及", lovartHeroSubtitle: - siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", + siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现", surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories) ? siteSettingsData.surpriseCategories .map((item: any) => String(item).trim()) @@ -10524,7 +10524,7 @@ export function AdminSettingsClient() { lovartComposerPrefix: e.target.value.slice(0, 40), }) } - placeholder="让「由壹点AI」" + placeholder="让「𝗕𝘂𝗬𝗶」" value={siteSettings.lovartComposerPrefix} />
@@ -10726,7 +10726,7 @@ export function AdminSettingsClient() { lovartHeroTitle: e.target.value, }) } - placeholder="由壹点AI 让设计更简单" + placeholder="𝗕𝘂𝗬𝗶 让设计触手可及" value={siteSettings.lovartHeroTitle} />
@@ -10740,7 +10740,7 @@ export function AdminSettingsClient() { lovartHeroSubtitle: e.target.value, }) } - placeholder="懂你的设计代理,帮你搞定一切" + placeholder="下达指令,即刻呈现" value={siteSettings.lovartHeroSubtitle} /> diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 36a5256f0..adfa919cb 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] { function normalizeLovartComposerPrefix(value: unknown): string { const normalized = String(value || "").trim(); if (!normalized) { - return "让「由壹点AI」"; + return "让「𝗕𝘂𝗬𝗶」"; } return normalized.slice(0, 40); } @@ -573,7 +573,7 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set([ ]); const HOME_SITE_SETTINGS_FALLBACK: Record = { - siteName: "AI Chatbot", + siteName: "𝗕𝘂𝗬𝗶", siteLogo: null, siteLogoDark: null, favicon: null, @@ -583,11 +583,11 @@ const HOME_SITE_SETTINGS_FALLBACK: Record = { bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, bannerActionText: "立即查看", - lovartHeroTitle: "由壹点AI 让设计更简单", - lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: "下达指令,即刻呈现", surpriseCategories: ["全部", "精选"], canvasDefaultModelId: null, - lovartComposerPrefix: "让「由壹点AI」", + lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」", lovartComposerPreviewTexts: [], lovartPromptTagVisibility: {}, contactConfig: normalizeContactConfig(undefined), @@ -664,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) { function createBrandingSettings(settings: Record | undefined) { return { - siteName: String(settings?.siteName || "AI Chatbot"), + siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"), siteLogo: settings?.siteLogo || null, siteLogoDark: settings?.siteLogoDark || null, favicon: settings?.favicon || null, @@ -822,7 +822,7 @@ async function getScopedPublicSiteSettings( usageTimeStart: typedSettings?.usageTimeStart || "08:00", usageTimeEnd: typedSettings?.usageTimeEnd || "20:00", usageTimeMessage: - typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的", + typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的", }); } @@ -953,9 +953,9 @@ async function getScopedPublicSiteSettings( typedSettings?.lovartPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级", - lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单", + lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及", lovartHeroSubtitle: - typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", + typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现", surpriseCategories: normalizeSurpriseCategories( valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY) ), @@ -1476,7 +1476,7 @@ export async function GET(request: NextRequest) { usageTimeEnabled: false, usageTimeStart: "08:00", usageTimeEnd: "20:00", - usageTimeMessage: "由壹点累了也是需要休息的", + usageTimeMessage: "BuYi 累了也是需要休息的", bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, @@ -1525,8 +1525,8 @@ export async function GET(request: NextRequest) { canvasDetailAgentModelId: null, lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: "立即升级", - lovartHeroTitle: "由壹点AI 让设计更简单", - lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: "下达指令,即刻呈现", surpriseCategories, aiDetailGenerationEnabled, lovartComposerPrefix, @@ -1546,8 +1546,8 @@ export async function GET(request: NextRequest) { baseSettings.lovartPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级", - lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单", - lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现", notification, surpriseCategories, aiDetailGenerationEnabled, @@ -3119,7 +3119,7 @@ export async function PUT(request: NextRequest) { usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false, usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00", usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00", - usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的", + usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的", bannerEnabled: siteSettingsPayload.bannerEnabled ?? true, bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!", bannerLink: siteSettingsPayload.bannerLink, @@ -3144,8 +3144,8 @@ export async function PUT(request: NextRequest) { siteSettingsPayload.lovartPromoText ?? "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级", - lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单", - lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现", updatedBy: session.user.id, }) .returning(); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a475ab9bb..a57566f80 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1334,7 +1334,7 @@ type VideoStoryboardStreamData = // 建议卡片数据 - 已移除 const SUGGESTIONS: { title: string; desc: string; image: string }[] = []; -const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」"; +const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」"; const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [ "打造引人注目的社交媒体视觉", "帮我把这张产品图做成高级电商海报", diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index afa9f43fc..b29e55d83 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -345,7 +345,7 @@ type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"]; type LovartPromptTagVisibility = Record; const FEATURED_PAGE_SIZE = 24; -const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」"; +const COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」"; function SidebarSvgIcon({ iconUrl, diff --git a/components/home/lovart/utils.ts b/components/home/lovart/utils.ts index 0fada024e..73e67c042 100644 --- a/components/home/lovart/utils.ts +++ b/components/home/lovart/utils.ts @@ -101,7 +101,7 @@ export type LovartPromptTagVisibility = Record; export const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320]; export const FEATURED_PAGE_SIZE = 24; -export const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」"; +export const COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」"; export function normalizeComposerPreviewTail(text: string, fixedPrefix: string) { const trimmed = text.trim(); diff --git a/components/layout/brand-top-bar.tsx b/components/layout/brand-top-bar.tsx index 1518f87e3..c91b4d35d 100644 --- a/components/layout/brand-top-bar.tsx +++ b/components/layout/brand-top-bar.tsx @@ -29,7 +29,7 @@ export function BrandTopBar({ showBrand = true, siteName, }: BrandTopBarProps) { - const resolvedSiteName = String(siteName || "").trim() || "由壹点AI"; + const resolvedSiteName = String(siteName || "").trim() || "𝗕𝘂𝗬𝗶"; const positionClass = position === "fixed" ? "fixed left-0 top-0 z-40 w-full" diff --git a/components/style-pro/StyleProWorkspace.tsx b/components/style-pro/StyleProWorkspace.tsx index 42ce88349..c8071ac5e 100644 --- a/components/style-pro/StyleProWorkspace.tsx +++ b/components/style-pro/StyleProWorkspace.tsx @@ -1118,7 +1118,7 @@ export function StyleProWorkspace({ persistedState?.sourceImageUrl, ]); - const siteName = brandingData?.siteName || "由壹点AI"; + const siteName = brandingData?.siteName || "𝗕𝘂𝗬𝗶"; const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || ""; const selectedOptionLabels = useMemo(() => { diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 778e81e49..aa6e0bc37 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4725,8 +4725,8 @@ export async function getSiteSettings() { canvasDetailAgentModelId: null, lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: "立即升级", - lovartHeroTitle: "由壹点AI 让设计更简单", - lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", + lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及", + lovartHeroSubtitle: "下达指令,即刻呈现", }; try { const [settings, videoCanvasEnabledSetting, videoCanvasSidebarEnabledSetting] = diff --git a/lib/db/schema.ts b/lib/db/schema.ts index be4af92b5..708d377b4 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -475,7 +475,7 @@ export const siteSettings = pgTable("SiteSettings", { usageTimeEnabled: boolean("usageTimeEnabled").notNull().default(false), // 是否启用时间限制 usageTimeStart: varchar("usageTimeStart", { length: 5 }).default("08:00"), // 开始时间(HH:mm) usageTimeEnd: varchar("usageTimeEnd", { length: 5 }).default("20:00"), // 结束时间(HH:mm) - usageTimeMessage: text("usageTimeMessage").default("由壹点累了也是需要休息的"), // 超时提示词 + usageTimeMessage: text("usageTimeMessage").default("BuYi 累了也是需要休息的"), // 超时提示词 // 广告横幅设置 bannerEnabled: boolean("bannerEnabled").notNull().default(true), // 是否显示广告横幅 bannerText: text("bannerText").default("限时特惠:升级会员最高立享 45% OFF!"), // 广告横幅文字 @@ -505,8 +505,8 @@ export const siteSettings = pgTable("SiteSettings", { "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠" ), lovartPromoActionText: varchar("lovartPromoActionText", { length: 32 }).default("立即升级"), - lovartHeroTitle: text("lovartHeroTitle").default("由壹点AI 让设计更简单"), - lovartHeroSubtitle: text("lovartHeroSubtitle").default("懂你的设计代理,帮你搞定一切"), + lovartHeroTitle: text("lovartHeroTitle").default("𝗕𝘂𝗬𝗶 让设计触手可及"), + lovartHeroSubtitle: text("lovartHeroSubtitle").default("下达指令,即刻呈现"), updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedBy: uuid("updatedBy").references(() => user.id), }); diff --git a/scripts/deploy-production-atomic.sh b/scripts/deploy-production-atomic.sh index e200f40f5..e11e3c90b 100755 --- a/scripts/deploy-production-atomic.sh +++ b/scripts/deploy-production-atomic.sh @@ -7,6 +7,11 @@ candidate_dir="$(mktemp -d "${app_dir}/.next-candidate.XXXXXX")" previous_dir="${app_dir}/.next-previous-$(date +%Y%m%dT%H%M%S)" build_container="ai-chatbot-build-$(date +%s)" +if grep -Eq '^[[:space:]]*CANVAS_E2E_LOCAL[[:space:]]*=[[:space:]]*"?true"?[[:space:]]*$' "${app_dir}/.env.local"; then + echo "Refusing production deployment while CANVAS_E2E_LOCAL=true." >&2 + exit 1 +fi + cleanup_candidate() { if [ -d "$candidate_dir" ]; then rm -rf -- "$candidate_dir" diff --git a/tests/routes/production-local-fallbacks.test.ts b/tests/routes/production-local-fallbacks.test.ts index 6deffcecc..a9dce008f 100644 --- a/tests/routes/production-local-fallbacks.test.ts +++ b/tests/routes/production-local-fallbacks.test.ts @@ -41,3 +41,15 @@ test("production deploy stages the main build and retains active static chunks", ); expect(deployScript).toContain('curl -fsS "$MAIN_INTERNAL_URL/ping"'); }); + +test("atomic production deploy rejects the local E2E fallback switch", () => { + const deployScript = readFileSync( + resolve(process.cwd(), "scripts/deploy-production-atomic.sh"), + "utf8" + ); + + expect(deployScript).toContain("CANVAS_E2E_LOCAL"); + expect(deployScript).toContain( + "Refusing production deployment while CANVAS_E2E_LOCAL=true." + ); +}); From afac47add26dd852330c64f787d57fdb2333d69a Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 20 Aug 2026 22:41:01 +0800 Subject: [PATCH 02/17] fix(home): harden Qiniu featured previews --- .../home/lovart/lovart-featured-gallery.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/components/home/lovart/lovart-featured-gallery.tsx b/components/home/lovart/lovart-featured-gallery.tsx index 05a147964..16e7a8c0c 100644 --- a/components/home/lovart/lovart-featured-gallery.tsx +++ b/components/home/lovart/lovart-featured-gallery.tsx @@ -13,6 +13,16 @@ type LovartFeaturedGalleryProps = { setActiveCategory: (category: string) => void; }; +function getFeaturedPreviewUrl(item: FeaturedImage) { + const source = item.thumbnailUrl || item.originalUrl; + + if (!source || !/^https:\/\/yybb\.top\//i.test(source) || source.includes("?")) { + return source; + } + + return `${source}?imageView2/2/w/900/q/78`; +} + export function LovartFeaturedGallery({ activeCategory, categories, @@ -60,10 +70,21 @@ export function LovartFeaturedGallery({ className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left" > {item.prompt { + const image = event.currentTarget; + const fallbackUrl = item.originalUrl || item.thumbnailUrl; + + if (!fallbackUrl || image.dataset.fallbackApplied === "true") { + return; + } + + image.dataset.fallbackApplied = "true"; + image.src = fallbackUrl; + }} className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]" />
From 42e1175830280b14f521a9090b88d871f5edcf14 Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 22 Aug 2026 17:25:34 +0800 Subject: [PATCH 03/17] fix: persist generated images before gallery save --- app/api/gallery/route.ts | 26 ++++++++-- lib/storage/image-archive.ts | 99 ++++++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/app/api/gallery/route.ts b/app/api/gallery/route.ts index 88ccadb02..4b6711d8e 100644 --- a/app/api/gallery/route.ts +++ b/app/api/gallery/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { generatedImage } from "@/lib/db/schema"; -import { eq, desc, sql } from "drizzle-orm"; +import { generatedImage, imageArchiveTask } from "@/lib/db/schema"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; export async function GET(request: NextRequest) { @@ -19,12 +19,23 @@ export async function GET(request: NextRequest) { const page = Number.parseInt(searchParams.get("page") || "1"); const pageSize = Number.parseInt(searchParams.get("pageSize") || "24"); const offset = (page - 1) * pageSize; + const visibleImageFilter = and( + eq(generatedImage.userId, userId), + isNull(imageArchiveTask.id) + ); // 查询总数 const countResult = await db .select({ count: sql`count(*)::int` }) .from(generatedImage) - .where(eq(generatedImage.userId, userId)); + .leftJoin( + imageArchiveTask, + and( + eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl), + eq(imageArchiveTask.status, "abandoned") + ) + ) + .where(visibleImageFilter); const total = countResult[0]?.count || 0; @@ -42,7 +53,14 @@ export async function GET(request: NextRequest) { modelId: generatedImage.modelId, // 添加 modelId 字段 }) .from(generatedImage) - .where(eq(generatedImage.userId, userId)) + .leftJoin( + imageArchiveTask, + and( + eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl), + eq(imageArchiveTask.status, "abandoned") + ) + ) + .where(visibleImageFilter) .orderBy(desc(generatedImage.createdAt)) .limit(pageSize) .offset(offset); diff --git a/lib/storage/image-archive.ts b/lib/storage/image-archive.ts index 0c5ef4c84..b0ea3416f 100644 --- a/lib/storage/image-archive.ts +++ b/lib/storage/image-archive.ts @@ -1,5 +1,7 @@ import "server-only"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; import { createLogger } from "@/lib/observability/logger"; import { enqueueImageArchiveTasks, @@ -31,6 +33,10 @@ export function isDeferredImageArchiveEnabled() { return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false"; } +export function isImmediateImageArchiveEnabled() { + return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false"; +} + export async function registerGeneratedImageTemporaryUrls(params: { urls: string[]; metadata?: DeferredImageArchiveMetadata | null; @@ -80,18 +86,19 @@ export async function formatDeferredGeneratedImageMarkdown(params: { throw new Error("AI 未返回可用图片地址"); } - await registerGeneratedImageTemporaryUrls({ - urls, - metadata: { - providerType: params.providerType, - modelId: params.modelId, - imageSize: params.imageSize, - ...(params.metadata || {}), - }, - }); + const metadata = { + providerType: params.providerType, + modelId: params.modelId, + imageSize: params.imageSize, + ...(params.metadata || {}), + }; + const persisted = await persistGeneratedImageUrls({ urls, metadata }); - return urls - .map((url) => `![thumb|||${url}|||${params.imageSize}](${url})`) + return persisted + .map( + ({ originalUrl, thumbnailUrl }) => + `![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})` + ) .join("\n\n"); } @@ -102,17 +109,61 @@ export async function createDeferredUploadedImage( const url = String(sourceUrl || "").trim(); if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null; - await registerGeneratedImageTemporaryUrls({ + const [persisted] = await persistGeneratedImageUrls({ urls: [url], metadata: metadata || null, }); return { - thumbnail: url, - original: url, + thumbnail: persisted?.thumbnailUrl || url, + original: persisted?.originalUrl || url, }; } +async function persistGeneratedImageUrls(params: { + urls: string[]; + metadata?: DeferredImageArchiveMetadata | null; +}) { + if (!isImmediateImageArchiveEnabled()) { + await registerGeneratedImageTemporaryUrls(params); + return params.urls.map((url) => ({ + originalUrl: url, + thumbnailUrl: url, + })); + } + + return await Promise.all( + params.urls.map(async (url) => { + try { + const archived = await archiveTemporaryImage(url); + logger.info("generated image archived before response", { + providerType: params.metadata?.providerType, + modelId: params.metadata?.modelId, + bytes: archived.bytes, + }); + return { + originalUrl: archived.url, + thumbnailUrl: archived.thumbnailUrl || archived.url, + }; + } catch (error) { + logger.error("immediate generated image archive failed; queued for retry", { + providerType: params.metadata?.providerType, + modelId: params.metadata?.modelId, + error: error instanceof Error ? error.message : String(error), + }); + await registerGeneratedImageTemporaryUrls({ + urls: [url], + metadata: params.metadata || null, + }); + return { + originalUrl: url, + thumbnailUrl: url, + }; + } + }) + ); +} + function getImageExtension(contentType: string | null, sourceUrl: string) { const normalized = String(contentType || "").split(";")[0].trim().toLowerCase(); const byType: Record = { @@ -187,10 +238,28 @@ export async function downloadTemporaryImage(params: { export async function archiveTemporaryImage(sourceUrl: string) { const downloaded = await downloadTemporaryImage({ sourceUrl }); - const filename = `ai-generated-archive-${Date.now()}.${downloaded.extension}`; + const objectSuffix = `${Date.now()}-${randomUUID()}`; + const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`; const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer)); + const thumbnailBuffer = await sharp(downloaded.buffer, { failOn: "none" }) + .resize(640, 640, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer() + .catch(() => null); + const thumbnailUploaded = thumbnailBuffer + ? await uploadFile( + `ai-generated-archive-thumb-${objectSuffix}.webp`, + toArrayBuffer(thumbnailBuffer) + ).catch((error) => { + logger.warn("generated image thumbnail upload failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + }) + : null; return { url: uploaded.url, + thumbnailUrl: thumbnailUploaded?.url || uploaded.url, pathname: uploaded.pathname, contentType: uploaded.contentType, bytes: downloaded.buffer.length, From e87724499bcc717386564beb40b5d23d5fa8fc8c Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 12:59:53 +0800 Subject: [PATCH 04/17] fix(ai): update vip98k image task contract --- lib/ai/providers/vip98k.ts | 38 +------------------------------------- lib/ai/vip98k-models.ts | 4 ---- 2 files changed, 1 insertion(+), 41 deletions(-) diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 30462234d..f78a7b915 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -2,7 +2,6 @@ import "server-only"; import { createOpenAI } from "@ai-sdk/openai"; import sharp from "sharp"; -import { createOpenAIImage } from "@/lib/ai/providers/openai-image"; import { resolveVip98kTaskInvocation, type Vip98kTaskInvocation, @@ -134,25 +133,6 @@ function inferKind(modelId: string, config: Vip98kModelRuntimeConfig) { return "chat"; } -const VIP98K_OPENAI_IMAGE_ALIASES = new Set([ - "nano-banana-2", - "nano-banana-pro", - "gpt-image-2-vip", -]); - -function resolveVip98kReferenceModelId(modelId: string) { - switch (modelId.trim().toLowerCase()) { - case "nano-banana-2": - return "google/nano-banana-2/reference-to-image"; - case "nano-banana-pro": - return "google/nano-banana-pro/edit"; - case "gpt-image-2-vip": - return "openai/gpt-image-2/edit"; - default: - return modelId; - } -} - function delay(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -838,11 +818,6 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) { apiKey, baseURL: openAIBaseURL, }); - const openAIImageProvider = createOpenAIImage({ - apiKey, - baseURL: openAIBaseURL, - }); - return { provider: "vip98k", chat: (modelId: string, defaultModelConfig?: unknown): any => { @@ -873,25 +848,14 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) { if (!activeModelId) throw new Error("VIP98K model ID is not configured"); const activeKind = inferKind(activeModelId, runtimeConfig); const { prompt, imageUrls, videoUrls } = extractPromptAndMedia(options.messages); - if ( - activeKind === "image" && - imageUrls.length === 0 && - VIP98K_OPENAI_IMAGE_ALIASES.has(activeModelId.toLowerCase()) - ) { - return openAIImageProvider.chat(activeModelId).doGenerate(options as any); - } const [publicImageUrls, publicVideoUrls] = await Promise.all([ Promise.all(imageUrls.map(ensurePublicMediaUrl)), Promise.all(videoUrls.map(ensurePublicMediaUrl)), ]); - const taskModelId = - activeKind === "image" && publicImageUrls.length > 0 - ? resolveVip98kReferenceModelId(activeModelId) - : activeModelId; const text = await runTask({ apiKey, baseURL: rootBaseURL, - modelId: taskModelId, + modelId: activeModelId, kind: activeKind, prompt, imageUrls: publicImageUrls, diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 33e54ce23..11ca3794a 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -1014,10 +1014,6 @@ export async function fetchVip98kCatalog(params: { if (authorizedModels.length === 0) { throw new Error("VIP98K model catalog is empty or has an unsupported response shape"); } - if (publicModels.length === 0) { - throw new Error("VIP98K public invocation catalog is empty"); - } - const publicById = new Map( publicModels.map((model) => [getModelId(model).toLowerCase(), model]), ); From c21a86beea9aa6748513d04b4fe03cb91db0b3b7 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 13:07:58 +0800 Subject: [PATCH 05/17] fix(ai): mark vip98k images as async tasks --- lib/ai/vip98k-models.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ai/vip98k-models.ts b/lib/ai/vip98k-models.ts index 11ca3794a..6c0755ddb 100644 --- a/lib/ai/vip98k-models.ts +++ b/lib/ai/vip98k-models.ts @@ -608,8 +608,8 @@ function createImagePreset( provider: "vip98k", kind: "image", endpoint: modelId, - apiFormat: "openai-images-compatible", - taskApi: "openai-images", + apiFormat: "vip98k-external-task", + taskApi: "external-task", supportedModes, imageSize: defaultImageSize, aspectRatio: "auto", From d120e0ddb6a9f01fd78535e66cf58a1ce7dc263e Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 13:27:31 +0800 Subject: [PATCH 06/17] fix(ai): route vip98k image tools correctly --- lib/ai/providers/vip98k.ts | 9 ++++++ lib/ai/vip98k-task-invocation.ts | 32 +++++++++++++++++++ tests/routes/vip98k-reference-routing.test.ts | 24 ++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index f78a7b915..a7d81a1b6 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -650,6 +650,15 @@ function buildTaskBody(params: Vip98kTaskParams, invocation: Vip98kInvocation | if ("negativePrompt" in body || !invocation) body.negativePrompt = negativePrompt; if ("negative_prompt" in body || !invocation) body.negative_prompt = negativePrompt; } + const imageToolParamAliases = [ + ["scaleFactor", "scale_factor"], + ["xScale", "x_scale"], + ["yScale", "y_scale"], + ] as const; + for (const [sourceKey, targetKey] of imageToolParamAliases) { + const value = imageParams[sourceKey]; + if (value !== undefined && targetKey in body) body[targetKey] = value; + } if (typeof videoParams.generateAudio === "boolean") { if ("generateAudio" in body || !invocation) body.generateAudio = videoParams.generateAudio; if ("generate_audio" in body || !invocation) body.generate_audio = videoParams.generateAudio; diff --git a/lib/ai/vip98k-task-invocation.ts b/lib/ai/vip98k-task-invocation.ts index 83ebfdfe1..5e5e27aaa 100644 --- a/lib/ai/vip98k-task-invocation.ts +++ b/lib/ai/vip98k-task-invocation.ts @@ -16,6 +16,14 @@ const KNOWN_IMAGE_TOOL_INPUTS: Record = { "openai/gpt-image-2/edit": "images", }; +const KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS: Record< + string, + Record +> = { + "remove-background": { image_url: "" }, + enhance: { image_url: "", scale_factor: 4 }, +}; + function getInvocationToolId(invocation: Vip98kTaskInvocation | null) { if (!invocation || invocation.kind !== "tool") return null; const query = invocation.path.split("?", 2)[1] || ""; @@ -46,6 +54,22 @@ function createKnownImageToolInvocation( }; } +function createKnownProcessingToolInvocation( + modelId: string, + requestExample: Record +): Vip98kTaskInvocation { + const encodedModelId = encodeURIComponent(modelId); + return { + kind: "tool", + method: "POST", + path: `/api/external/tools/generate?tool_id=${encodedModelId}`, + contentType: "application/json", + requestExample: { ...requestExample }, + resultMethod: "GET", + resultPath: `/api/external/tools/result?tool_id=${encodedModelId}&taskId={task_id}`, + }; +} + /** * A frontend alias may resolve to a different VIP98K tool at runtime. In that * case the selected model's invocation cannot be reused because its endpoint @@ -56,6 +80,14 @@ export function resolveVip98kTaskInvocation( configuredInvocation: Vip98kTaskInvocation | null ) { const normalizedModelId = modelId.trim().toLowerCase(); + const processingRequest = KNOWN_IMAGE_PROCESSING_TOOL_REQUESTS[normalizedModelId]; + if (processingRequest) { + if (getInvocationToolId(configuredInvocation)?.toLowerCase() === normalizedModelId) { + return configuredInvocation; + } + return createKnownProcessingToolInvocation(modelId, processingRequest); + } + const knownImageInput = KNOWN_IMAGE_TOOL_INPUTS[normalizedModelId]; if (!knownImageInput) return configuredInvocation; diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 1cf877f38..818d62706 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -59,4 +59,28 @@ test.describe("VIP98K reference-image routing", () => { resolveVip98kTaskInvocation("other/image-model", textToImageInvocation) ).toBe(textToImageInvocation); }); + + test("routes remove-background through the dedicated external tool", () => { + const invocation = resolveVip98kTaskInvocation("remove-background", null); + + expect(invocation?.path).toBe( + "/api/external/tools/generate?tool_id=remove-background" + ); + expect(invocation?.resultPath).toBe( + "/api/external/tools/result?tool_id=remove-background&taskId={task_id}" + ); + expect(invocation?.requestExample).toEqual({ image_url: "" }); + }); + + test("routes enhance through the dedicated external tool", () => { + const invocation = resolveVip98kTaskInvocation("enhance", null); + + expect(invocation?.path).toBe( + "/api/external/tools/generate?tool_id=enhance" + ); + expect(invocation?.requestExample).toEqual({ + image_url: "", + scale_factor: 4, + }); + }); }); From a0999bfcde26e0987ab616abcedd136b79a6845b Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 14:03:25 +0800 Subject: [PATCH 07/17] fix(canvas): decouple text detection from image provider --- lib/ai/qwen25-vl.ts | 14 ++++++++------ lib/ai/qwen3-vl.ts | 14 ++++++++------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/ai/qwen25-vl.ts b/lib/ai/qwen25-vl.ts index fe65193aa..215a2aa81 100644 --- a/lib/ai/qwen25-vl.ts +++ b/lib/ai/qwen25-vl.ts @@ -27,14 +27,16 @@ function parseAssistantText(payload: any): string { return ""; } -async function getQwenImageProviderApiKey() { +async function getQwenVisionProviderApiKey() { const providers = await getEnabledAiProviders(); - const provider = providers.find( - (item) => item.type === "alibaba-image" && typeof item.apiKey === "string" && item.apiKey.trim(), - ); + const hasApiKey = (item: (typeof providers)[number]) => + typeof item.apiKey === "string" && Boolean(item.apiKey.trim()); + const provider = + providers.find((item) => item.type === "alibaba" && hasApiKey(item)) || + providers.find((item) => item.type === "alibaba-image" && hasApiKey(item)); if (!provider?.apiKey) { - throw new Error("未配置可用的千问生图 Provider API Key"); + throw new Error("未配置可用的阿里云多模态 Provider API Key"); } return provider.apiKey.trim(); @@ -46,7 +48,7 @@ export async function callQwen25Vl(params: { temperature?: number; maxTokens?: number; }) { - const apiKey = await getQwenImageProviderApiKey(); + const apiKey = await getQwenVisionProviderApiKey(); const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`; let lastError: Error | null = null; diff --git a/lib/ai/qwen3-vl.ts b/lib/ai/qwen3-vl.ts index 2196d77d7..5f1034c14 100644 --- a/lib/ai/qwen3-vl.ts +++ b/lib/ai/qwen3-vl.ts @@ -27,14 +27,16 @@ function parseAssistantText(payload: any): string { return ""; } -async function getQwenImageProviderApiKey() { +async function getQwenVisionProviderApiKey() { const providers = await getEnabledAiProviders(); - const provider = providers.find( - (item) => item.type === "alibaba-image" && typeof item.apiKey === "string" && item.apiKey.trim(), - ); + const hasApiKey = (item: (typeof providers)[number]) => + typeof item.apiKey === "string" && Boolean(item.apiKey.trim()); + const provider = + providers.find((item) => item.type === "alibaba" && hasApiKey(item)) || + providers.find((item) => item.type === "alibaba-image" && hasApiKey(item)); if (!provider?.apiKey) { - throw new Error("未配置可用的千问生图 Provider API Key"); + throw new Error("未配置可用的阿里云多模态 Provider API Key"); } return provider.apiKey.trim(); @@ -50,7 +52,7 @@ export async function callQwen3Vl(params: { timeoutMs?: number; retries?: number; }) { - const apiKey = await getQwenImageProviderApiKey(); + const apiKey = await getQwenVisionProviderApiKey(); const endpoint = `${QWEN_COMPAT_BASE_URL}/chat/completions`; const timeoutMs = Math.max(10000, Math.min(params.timeoutMs ?? 45000, 120000)); const retries = Math.max(0, Math.min(params.retries ?? 1, 2)); From 3a88fb0df0b66b18f51d6d9169891c2ec8167494 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 14:44:50 +0800 Subject: [PATCH 08/17] fix(canvas): restore reliable text editing --- app/api/canvas/text-edit/route.ts | 20 +++++++++++++++++++- lib/ai/providers/fal-image.ts | 5 ++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index a014043db..941255f4d 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -29,8 +29,9 @@ export const runtime = "nodejs"; export const maxDuration = 1200; const logger = createLogger("canvas-text-edit"); -const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000; +const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000; const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3; +const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000; const textItemSchema = z.object({ id: z.string().min(1), @@ -68,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([ type TextEditModelCandidate = Awaited>[number]; +function isExplicitTextEditModel(candidate: TextEditModelCandidate) { + const capabilities = candidate.model.capabilities as Record | null; + const kind = String(capabilities?.kind || "").toLowerCase(); + const modelId = String(candidate.model.modelId || "").toLowerCase(); + return kind === "image-edit" || /(?:^|[/_-])edit(?:$|[/_-])/.test(modelId); +} + function getTextEditModelCandidates( models: Awaited> ) { @@ -87,11 +95,13 @@ function getTextEditModelCandidates( .sort((a, b) => { const aScore = (a.model.isDefaultTextEditModel ? 100 : 0) + + (isExplicitTextEditModel(a) ? 30 : 0) + (a.model.isDefault ? 10 : 0) + (a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (a.model.displayName?.includes("火山") ? 3 : 0); const bScore = (b.model.isDefaultTextEditModel ? 100 : 0) + + (isExplicitTextEditModel(b) ? 30 : 0) + (b.model.isDefault ? 10 : 0) + (b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (b.model.displayName?.includes("火山") ? 3 : 0); @@ -220,6 +230,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) { } catch (error) { logger.warn("source_image_archive_failed", { error: error instanceof Error ? error.message : String(error), + sourceHost: (() => { + try { + return new URL(imageUrl).host; + } catch { + return "invalid-url"; + } + })(), }); return imageUrl; } @@ -495,6 +512,7 @@ async function generateTextEditImageWithFailover(params: { ], imageParams: { imageCount: 1, + timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS, }, }; if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") { diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index c2449235a..78779dd46 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -56,6 +56,7 @@ const DEFAULT_SAFETY_TOLERANCE = "4"; const DEFAULT_TIMEOUT_MS = 600000; const MIN_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 600_000; +const FAL_INPUT_IMAGE_DOWNLOAD_TIMEOUT_MS = 120_000; const FAL_GENERIC_MAX_EDGE = 4096; const FAL_GENERIC_MAX_PIXELS = 4096 * 4096; const GPT_IMAGE_2_MAX_EDGE = 3840; @@ -679,7 +680,9 @@ async function uploadImageUrlToFalStorage(params: { const { fal, url } = params; if (/fal\.(?:media|run)\//i.test(url) || /fal-ai/i.test(url)) return url; - const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const response = await fetch(url, { + signal: AbortSignal.timeout(FAL_INPUT_IMAGE_DOWNLOAD_TIMEOUT_MS), + }); if (!response.ok) { throw new Error(`FAL input image download failed before storage upload: ${response.status}`); } From 4a30cad91f82df8f34026eaaee6d66452a3c3079 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 15:43:50 +0800 Subject: [PATCH 09/17] fix(canvas): honor text edit failover exclusions --- app/api/canvas/text-edit/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index 941255f4d..1154546db 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -90,6 +90,7 @@ function getTextEditModelCandidates( }) && item.model.isEnabled && item.provider.isEnabled && + (!item.model.excludeFromFailover || item.model.isDefaultTextEditModel) && SUPPORTED_TEXT_EDIT_PROVIDER_TYPES.has(item.provider.type), ) .sort((a, b) => { From e5dc650443e1a4372b0893156d298fcd16e2e6cb Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 25 Aug 2026 17:00:38 +0800 Subject: [PATCH 10/17] fix(ai): fail over failed VIP98K image tasks --- app/(chat)/api/chat/_lib/image-failover.ts | 18 ++++- lib/ai/providers/vip98k.ts | 85 +++++++++++++++++++--- lib/ai/runtime/model-health.ts | 53 ++++++++++++++ 3 files changed, 143 insertions(+), 13 deletions(-) diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts index 8d1dc9e92..81d7a7cfc 100644 --- a/app/(chat)/api/chat/_lib/image-failover.ts +++ b/app/(chat)/api/chat/_lib/image-failover.ts @@ -57,7 +57,21 @@ export function resolveImageModelRuntimeId( export function normalizeFailoverErrorMessage(error: unknown): string { if (!error) return ""; if (typeof error === "string") return error; - if (error instanceof Error) return error.message || String(error); + if (error instanceof Error) { + const record = error as Error & { + status?: unknown; + statusCode?: unknown; + httpStatus?: unknown; + response?: { status?: unknown }; + }; + const status = + record.status ?? + record.statusCode ?? + record.httpStatus ?? + record.response?.status; + const message = error.message || String(error); + return status ? `[HTTP ${String(status)}] ${message}` : message; + } try { return JSON.stringify(error); } catch { @@ -76,6 +90,8 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean msg.includes("api key is missing") || msg.includes("invalid api key") || msg.includes("unauthorized") || + /(?:^|\D)401(?:\D|$)/.test(msg) || + msg.includes("vip98k task failed") || msg.includes("cannot convert argument to a bytestring") || msg.includes("model maintenance") || msg.includes("maintenance") || diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index a7d81a1b6..1f091b83e 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -147,20 +147,68 @@ async function parseJsonResponse(response: Response) { } } +const ERROR_MESSAGE_FIELDS = [ + "failureReason", + "failure_reason", + "errorMessage", + "error_message", + "errorDescription", + "error_description", + "reason", + "detail", + "description", + "message", + "error", +] as const; + +const ERROR_CONTAINER_FIELDS = [ + "data", + "result", + "task", + "output", + "response", +] as const; + +function findNestedErrorMessage(value: unknown, depth = 0): string { + if (depth > 4 || value == null) return ""; + if (typeof value === "string") return value.trim(); + if (Array.isArray(value)) { + for (const item of value) { + const message = findNestedErrorMessage(item, depth + 1); + if (message) return message; + } + return ""; + } + + const record = toRecord(value); + if (!record) return ""; + + for (const key of ERROR_MESSAGE_FIELDS) { + if (!(key in record)) continue; + const message = findNestedErrorMessage(record[key], depth + 1); + if (message) return message; + } + for (const key of ERROR_CONTAINER_FIELDS) { + if (!(key in record)) continue; + const message = findNestedErrorMessage(record[key], depth + 1); + if (message) return message; + } + return ""; +} + function extractErrorMessage(payload: unknown, fallback = "VIP98K request failed") { - if (typeof payload === "string" && payload.trim()) return payload.trim(); + return findNestedErrorMessage(payload) || fallback; +} + +function extractPayloadShape(payload: unknown) { const record = toRecord(payload); const data = toRecord(record?.data); - const error = toRecord(record?.error); - const value = - data?.message || - data?.errorMessage || - data?.error || - error?.message || - record?.message || - record?.error || - fallback; - return typeof value === "string" && value.trim() ? value.trim() : fallback; + const task = toRecord(data?.task) || toRecord(record?.task); + return { + payloadKeys: record ? Object.keys(record).sort() : [], + dataKeys: data ? Object.keys(data).sort() : [], + taskKeys: task ? Object.keys(task).sort() : [], + }; } function unwrapData(payload: unknown) { @@ -555,6 +603,7 @@ async function waitForTask(params: { apiKey: string; baseURL?: string | null; taskId: string; + modelId: string; resultPath?: string; modelConfig: Vip98kModelRuntimeConfig; }) { @@ -597,7 +646,18 @@ async function waitForTask(params: { return { payload, outputs }; } if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) { - throw new Error(extractErrorMessage(payload, "VIP98K task failed")); + const providerMessage = extractErrorMessage(payload, ""); + logger.error("task failed", { + taskId: params.taskId, + modelId: params.modelId, + status, + providerMessage: providerMessage || null, + ...extractPayloadShape(payload), + }); + const detail = providerMessage ? `: ${providerMessage}` : ""; + throw new Error( + `VIP98K task failed${detail} (task_id: ${params.taskId}, model: ${params.modelId})`, + ); } } @@ -771,6 +831,7 @@ export async function executeVip98kExternalTask(params: { apiKey: params.apiKey, baseURL: params.baseURL, taskId: submitted.taskId, + modelId: params.modelId, resultPath: submitted.resultPath, modelConfig: params.modelConfig || {}, }); diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index f5c4a7087..809983673 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -50,6 +50,36 @@ function getErrorMessage(error: unknown) { return normalizeText(error); } +function parseHttpStatus(value: unknown) { + if (typeof value === "number" && Number.isInteger(value)) { + return value >= 100 && value <= 599 ? value : null; + } + + if (typeof value === "string" && /^\d{3}$/.test(value.trim())) { + const parsed = Number(value.trim()); + return parsed >= 100 && parsed <= 599 ? parsed : null; + } + + return null; +} + +function getErrorHttpStatus(error: unknown, depth = 0): number | null { + if (!error || typeof error !== "object" || depth > 2) return null; + + const record = error as Record; + for (const key of ["status", "statusCode", "httpStatus"]) { + const status = parseHttpStatus(record[key]); + if (status !== null) return status; + } + + for (const key of ["response", "cause"]) { + const nestedStatus = getErrorHttpStatus(record[key], depth + 1); + if (nestedStatus !== null) return nestedStatus; + } + + return null; +} + function isExplicitUserCancellation(normalized: string) { return ( normalized.includes("user abort") || @@ -81,6 +111,7 @@ export function classifyAiModelHealthFailure(error: unknown): { } { const message = getErrorMessage(error); const normalized = message.toLowerCase(); + const httpStatus = getErrorHttpStatus(error); if (!message) { return { @@ -100,6 +131,23 @@ export function classifyAiModelHealthFailure(error: unknown): { }; } + if ( + httpStatus === 401 || + /(?:^|\D)401(?:\D|$)/.test(normalized) || + normalized.includes("unauthorized") || + normalized.includes("invalid api key") || + normalized.includes("invalid_api_key") || + normalized.includes("authentication failed") || + normalized.includes("authentication_error") + ) { + return { + eventType: "unavailable", + severity: "critical", + counted: true, + reason: "provider_authentication_failed", + }; + } + if ( normalized.includes("生成结果未通过替换质检") || normalized.includes("output_moderation") || @@ -221,6 +269,7 @@ export function classifyAiModelHealthFailure(error: unknown): { normalized.includes("provider_error") || normalized.includes("provider request failed") || normalized.includes("provider task failed") || + normalized.includes("vip98k task failed") || normalized.includes("upstream error") || normalized.includes("upstream_error") || normalized.includes("upstream request failed") || @@ -283,6 +332,10 @@ export function shouldFailoverAiModelError(error: unknown) { normalized.includes("no task id") || normalized.includes("missing task id"); + if (classification.reason === "provider_authentication_failed") { + return true; + } + if (classification.reason === "user_cancelled" || classification.reason === "non_provider_failure") { return false; } From d005b3477a391c11d71a6fd0c3192a995bc05e44 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 26 Aug 2026 13:43:58 +0800 Subject: [PATCH 11/17] fix: preserve manually disabled image models --- app/api/admin/bananaimage-models/route.ts | 4 ++-- app/api/admin/snapgen-image-models/route.ts | 2 -- lib/ai/runtime/model-health.ts | 20 +------------------- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/app/api/admin/bananaimage-models/route.ts b/app/api/admin/bananaimage-models/route.ts index adcfdb079..0fb75d0fd 100644 --- a/app/api/admin/bananaimage-models/route.ts +++ b/app/api/admin/bananaimage-models/route.ts @@ -65,8 +65,6 @@ export async function POST(request: Request) { description: preset.description, supportsVision: preset.supportsVision, supportsImageGen: preset.supportsImageGen, - isEnabled: true, - isHidden: false, sortOrder: preset.sortOrder, capabilities: preset.capabilities || null, defaultParams: preset.defaultParams || null, @@ -94,6 +92,8 @@ export async function POST(request: Request) { providerId, modelId: preset.modelId, ...values, + isEnabled: true, + isHidden: false, isDefault: false, isSubModel: false, parentModelId: null, diff --git a/app/api/admin/snapgen-image-models/route.ts b/app/api/admin/snapgen-image-models/route.ts index 7658324ae..b0cd744bc 100644 --- a/app/api/admin/snapgen-image-models/route.ts +++ b/app/api/admin/snapgen-image-models/route.ts @@ -76,8 +76,6 @@ export async function POST(request: Request) { description: preset.description, supportsVision: preset.supportsVision, supportsImageGen: preset.supportsImageGen, - isEnabled: true, - isHidden: false, sortOrder: preset.sortOrder, capabilities: preset.capabilities || null, defaultParams: preset.defaultParams || null, diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index 809983673..e5c6b62ec 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -23,15 +23,6 @@ const AUTO_DISABLE_FAILURE_THRESHOLD = 3; const AUTO_DISABLE_COOLDOWN_MS = 2 * 60 * 60 * 1000; const OBSERVATION_RECOVERY_SUCCESS_THRESHOLD = 3; const RECENT_EVENT_LIMIT = 80; -const RECOVERABLE_FAILURE_EVENT_TYPES = new Set([ - "timeout", - "quota", - "unavailable", - "invalid_model", - "download_error", - "error", -]); - function normalizeText(value: unknown) { return String(value || "").trim(); } @@ -458,16 +449,7 @@ export async function reviveAiModelsFromCooldown(params?: { modelId?: string }) return false; } - if (model.healthStatus === "auto_disabled") { - return true; - } - - return ( - model.healthStatus === "degraded" && - Boolean(model.healthLastFailureAt) && - (model.healthConsecutiveFailures || 0) > 0 && - RECOVERABLE_FAILURE_EVENT_TYPES.has(model.healthLastEventType as AiModelHealthEventType) - ); + return model.healthStatus === "auto_disabled"; }); if (dueModels.length === 0) { From ba232f3385db99648def408991312791ea85376b Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 26 Aug 2026 16:10:00 +0800 Subject: [PATCH 12/17] feat(ai): support Qwen Image 3.0 --- lib/ai/model-classification.ts | 7 ++- lib/ai/providers/alibaba-image.ts | 66 +++++++++++++++-------- tests/routes/model-classification.test.ts | 21 ++++++++ 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/lib/ai/model-classification.ts b/lib/ai/model-classification.ts index eb201ab89..24fee08c2 100644 --- a/lib/ai/model-classification.ts +++ b/lib/ai/model-classification.ts @@ -125,6 +125,11 @@ function normalizeText(value?: string | null) { return String(value || "").trim().toLowerCase(); } +export function isAlibabaImageTextToImageModelId(modelId?: string | null) { + const normalized = normalizeText(modelId); + return /^qwen-image-3(?:\.0)?(?:$|[-/])/.test(normalized); +} + function toRecord(value: unknown) { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; @@ -327,7 +332,7 @@ export function modelSupportsTextToImageDescriptor(params: { } if (providerType === "alibaba-image") { - return false; + return isAlibabaImageTextToImageModelId(modelId); } return true; diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index 9314e4acf..ce1d0e4db 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -10,6 +10,7 @@ import sharp from "sharp"; import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; +import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification"; import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; @@ -535,14 +536,20 @@ async function processResponse( */ function formatImageResponse( images: Array<{ thumbnail: string; original: string }>, - size?: string + size?: string, + requestedSizeLabel?: string, ): string { if (images.length === 0) { return "图片生成失败,请重试。"; } // 将分辨率转换为简短格式(如 "1024*1024" -> "1K") - const sizeLabel = size ? getSizeLabel(size) : "2K"; + const normalizedRequestedSize = normalizeImageSizeLabel(requestedSizeLabel); + const sizeLabel = requestedSizeLabel + ? normalizedRequestedSize + : size + ? getSizeLabel(size) + : "2K"; return images .map((img) => { @@ -663,8 +670,11 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { preparedImageUrls.push(await prepareAlibabaInputImageUrl(imageUrl, activeModelId)); } - // 验证:qwen-image-edit 系列模型必须有输入图片 - if (preparedImageUrls.length === 0) { + // qwen-image-3.0 同时支持文生图与图生图;旧编辑模型仍必须上传参考图。 + if ( + preparedImageUrls.length === 0 && + !isAlibabaImageTextToImageModelId(activeModelId) + ) { const modelName = options.modelDisplayName || modelId; throw new Error(`「${modelName}」是图片编辑模型,需要至少上传一张图片才能进行编辑。如需纯文生图,请使用其他生图模型。`); } @@ -683,9 +693,10 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize); // 根据分辨率等级确定基准像素 + const isQwenImage3 = isAlibabaImageTextToImageModelId(activeModelId); const baseSizeMap: Record = { "1K": 1024, - "2K": 1536, + "2K": isQwenImage3 ? 2048 : 1536, "4K": 2048, }; const baseSize = baseSizeMap[imageSize] || 1536; @@ -725,27 +736,34 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { // 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。 // 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。 const referenceImageUrl = preparedImageUrls[0]; - const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl); - if (inferredDimensions) { - params.size = calculateAlibabaSizeFromDimensions( - inferredDimensions.width, - inferredDimensions.height, - baseSize, - ); + if (!referenceImageUrl) { + params.size = `${baseSize}*${baseSize}`; console.log( - `[Alibaba Image] Auto mode resolved size: imageSize=${imageSize}, reference=${inferredDimensions.width}x${inferredDimensions.height}, inferredAspect=${inferredDimensions.aspectRatio} => size=${params.size}` + `[Alibaba Image] Text-to-image auto mode resolved size: imageSize=${imageSize} => size=${params.size}` ); } else { - const inferredAspectRatio = await inferAspectRatioFromImageUrl(referenceImageUrl); - if (inferredAspectRatio) { - params.size = calculateAlibabaSizeByAspect(inferredAspectRatio, baseSize); + const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl); + if (inferredDimensions) { + params.size = calculateAlibabaSizeFromDimensions( + inferredDimensions.width, + inferredDimensions.height, + baseSize, + ); console.log( - `[Alibaba Image] Auto mode resolved size via aspect fallback: imageSize=${imageSize}, inferredAspect=${inferredAspectRatio} => size=${params.size}` + `[Alibaba Image] Auto mode resolved size: imageSize=${imageSize}, reference=${inferredDimensions.width}x${inferredDimensions.height}, inferredAspect=${inferredDimensions.aspectRatio} => size=${params.size}` ); } else { - console.log( - `[Alibaba Image] Auto mode keeps provider default size: imageSize=${imageSize}, inferredAspect=unavailable` - ); + const inferredAspectRatio = await inferAspectRatioFromImageUrl(referenceImageUrl); + if (inferredAspectRatio) { + params.size = calculateAlibabaSizeByAspect(inferredAspectRatio, baseSize); + console.log( + `[Alibaba Image] Auto mode resolved size via aspect fallback: imageSize=${imageSize}, inferredAspect=${inferredAspectRatio} => size=${params.size}` + ); + } else { + console.log( + `[Alibaba Image] Auto mode keeps provider default size: imageSize=${imageSize}, inferredAspect=unavailable` + ); + } } } } else { @@ -763,7 +781,11 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { } // 根据图片数量确定编辑模式 - const editMode = preparedImageUrls.length === 1 ? "单图编辑" : `多图融合 (${preparedImageUrls.length}张)`; + const editMode = preparedImageUrls.length === 0 + ? "文生图" + : preparedImageUrls.length === 1 + ? "单图编辑" + : `多图融合 (${preparedImageUrls.length}张)`; const promptForRequest = buildReferenceImageOrderPrompt( prompt, preparedImageUrls @@ -785,7 +807,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) { // 处理响应 const images = await processResponse(response); - const markdownText = formatImageResponse(images, params.size); + const markdownText = formatImageResponse(images, params.size, imageSize); console.log(`[Alibaba Image] Generated ${images.length} images successfully`); diff --git a/tests/routes/model-classification.test.ts b/tests/routes/model-classification.test.ts index 2c5297a50..624729fc4 100644 --- a/tests/routes/model-classification.test.ts +++ b/tests/routes/model-classification.test.ts @@ -13,6 +13,8 @@ import { is3DModelDescriptor, isImageGenerationModelDescriptor, isVideoModelDescriptor, + isAlibabaImageTextToImageModelId, + modelSupportsTextToImageDescriptor, } from "@/lib/ai/model-classification"; import { buildVip98kModelPresets, @@ -29,6 +31,25 @@ const atlasHunyuan3D = { }; test.describe("AI model media classification", () => { + test("classifies Qwen Image 3.0 as Alibaba text-to-image and image editing", () => { + expect(isAlibabaImageTextToImageModelId("qwen-image-3.0")).toBe(true); + expect(isAlibabaImageTextToImageModelId("qwen-image-edit-plus")).toBe(false); + expect( + modelSupportsTextToImageDescriptor({ + providerType: "alibaba-image", + modelId: "qwen-image-3.0", + supportsImageGen: true, + }) + ).toBe(true); + expect( + modelSupportsTextToImageDescriptor({ + providerType: "alibaba-image", + modelId: "qwen-image-edit-plus", + supportsImageGen: true, + }) + ).toBe(false); + }); + test("preserves administrator-defined AtlasCloud display names", () => { expect( resolveAtlasCloudDisplayName( From e1c155be9ebae0f43fb97b56074c9342314cbd54 Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 27 Aug 2026 16:31:27 +0800 Subject: [PATCH 13/17] fix(ai): fail over forbidden image requests --- app/(chat)/api/chat/_lib/image-failover.ts | 5 ++ app/(chat)/api/chat/route.ts | 77 +++++++++++++++++++++- lib/ai/providers/snapgen-image.ts | 2 +- lib/ai/runtime/model-health.ts | 21 +++++- 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/app/(chat)/api/chat/_lib/image-failover.ts b/app/(chat)/api/chat/_lib/image-failover.ts index 81d7a7cfc..98b36d687 100644 --- a/app/(chat)/api/chat/_lib/image-failover.ts +++ b/app/(chat)/api/chat/_lib/image-failover.ts @@ -91,6 +91,11 @@ export function shouldCooldownImageFallbackModel(errorMessage: string): boolean msg.includes("invalid api key") || msg.includes("unauthorized") || /(?:^|\D)401(?:\D|$)/.test(msg) || + msg === "forbidden" || + msg.includes("access forbidden") || + msg.includes("access denied") || + msg.includes("permission denied") || + /(?:^|\D)403(?:\D|$)/.test(msg) || msg.includes("vip98k task failed") || msg.includes("cannot convert argument to a bytestring") || msg.includes("model maintenance") || diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 71286129d..0f06fac7b 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -9635,6 +9635,11 @@ export async function POST(request: Request) { alibabaModelConfig?.fallbackModelId2, ].filter(Boolean) as string[]; const allModels = await getEnabledAiModelsWithProvider(); + const currentModelEntry = resolveModelEntryByRuntimeModelId( + allModels, + actualModelId, + finalModelId, + ); const fallbackCandidates = buildImageFallbackCandidates(allModels, { currentProviderId: alibabaModelConfig?.providerId, currentModelId: alibabaModelConfig?.id, @@ -9880,6 +9885,7 @@ export async function POST(request: Request) { let imageMarkdown = ""; let usedModelId = actualModelId; + const generationStartedAt = Date.now(); // 🆕 图片生成真正开始时发出标记(用于前端显示占位图) writeAssistantTextChunk( @@ -9906,14 +9912,37 @@ export async function POST(request: Request) { } catch (primaryError) { chatApiLogger.error("[Chat API] Alibaba primary model failed:", primaryError); + await recordAiModelHealthFailure({ + model: currentModelEntry?.model || alibabaModelConfig, + provider: currentModelEntry?.provider, + error: primaryError, + context: "chat_image_primary", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + runtimeModelId: actualModelId, + finalModelId, + providerType: (languageModel as any).provider, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + imageCount: + effectiveImageGenerationParams.imageCount || + defaultImageQualityParams.imageCount || + 1, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image primary health failure failed:", healthError); + }); + if (!shouldFailoverAiModelError(primaryError)) { chatApiLogger.info("[Chat API] Alibaba primary error is not eligible for image failover; returning original error."); throw primaryError; } let fallbackSuccess = false; + const triedFallbackModelIds = new Set(); for (const candidate of fallbackCandidates) { try { + triedFallbackModelIds.add(String(candidate.model.id || "")); chatApiLogger.info(`[Chat API] 🔄 Alibaba fallback model: ${candidate.model.displayName} (${candidate.provider.type})`); const fallbackResult = await generateImageWithFailoverCandidate({ candidate, @@ -9938,6 +9967,22 @@ export async function POST(request: Request) { if (shouldCooldownImageFallbackModel(fallbackErrorMessage)) { setImageFallbackModelCooldown(candidate.model.id, fallbackErrorMessage); } + await recordAiModelHealthFailure({ + model: candidate.model, + provider: candidate.provider, + error: fallbackError, + context: "chat_image_fallback", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + primaryModelId: actualModelId, + fallbackModelId: candidate.model.modelId, + providerType: candidate.provider.type, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image fallback health failure failed:", healthError); + }); chatApiLogger.error(`[Chat API] ❌ Alibaba fallback model ${candidate.model.displayName} (${candidate.provider.type}) failed:`, fallbackError); } } @@ -9966,10 +10011,11 @@ export async function POST(request: Request) { currentModelKey: actualModelId, currentProviderId: alibabaModelConfig?.providerId, providerPriority, - }); + }).filter((candidate) => !triedFallbackModelIds.has(String(candidate.model.id || ""))); for (const candidate of crossProviderCandidates) { try { + triedFallbackModelIds.add(String(candidate.model.id || "")); chatApiLogger.info(`[Chat API] 🔁 Cross-provider failover attempt: ${candidate.model.displayName} (${candidate.provider.type})`); if ( candidate.provider.type === "fal-image" && @@ -10053,6 +10099,22 @@ export async function POST(request: Request) { if (shouldCooldownImageFallbackModel(crossErrorMessage)) { setImageFallbackModelCooldown(candidate.model.id, crossErrorMessage); } + await recordAiModelHealthFailure({ + model: candidate.model, + provider: candidate.provider, + error: crossError, + context: "chat_image_cross_fallback", + elapsedMs: Date.now() - generationStartedAt, + metadata: { + primaryModelId: actualModelId, + fallbackModelId: candidate.model.modelId, + providerType: candidate.provider.type, + aspectRatio: effectiveImageGenerationParams.aspectRatio, + imageSize: effectiveImageGenerationParams.imageSize, + }, + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image cross-provider health failure failed:", healthError); + }); chatApiLogger.error(`[Chat API] ❌ Cross-provider failover failed: ${candidate.model.displayName} (${candidate.provider.type})`, crossError); } } @@ -10066,6 +10128,19 @@ export async function POST(request: Request) { chatApiLogger.info("[Chat API] Alibaba generated, length:", imageMarkdown.length); chatApiLogger.info("[Chat API] Alibaba used model:", usedModelId); + const usedModelEntry = resolveModelEntryByRuntimeModelId( + allModels, + usedModelId, + usedModelId === actualModelId ? finalModelId : undefined, + ); + await recordAiModelHealthSuccess({ + model: + usedModelEntry?.model || + (usedModelId === actualModelId ? alibabaModelConfig : null), + }).catch((healthError) => { + chatApiLogger.warn("[Chat API] Record image health success failed:", healthError); + }); + const usedModelDisplayName = resolveModelDisplayNameByModelId(allModels, usedModelId) || alibabaModelConfig?.displayName || diff --git a/lib/ai/providers/snapgen-image.ts b/lib/ai/providers/snapgen-image.ts index 3d0ff3b98..481f7ed3a 100644 --- a/lib/ai/providers/snapgen-image.ts +++ b/lib/ai/providers/snapgen-image.ts @@ -103,7 +103,7 @@ function normalizeResolution(imageSize?: string, apiFormat?: SnapgenApiFormat) { } function toSnapgenResolutionParam(resolution: string) { - return resolution.trim().toLowerCase(); + return resolution.trim().toUpperCase(); } function normalizeOutputFormat(outputFormat?: string): "png" | "jpeg" { diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index e5c6b62ec..6df4a9db4 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -139,6 +139,22 @@ export function classifyAiModelHealthFailure(error: unknown): { }; } + if ( + httpStatus === 403 || + /(?:^|\D)403(?:\D|$)/.test(normalized) || + normalized === "forbidden" || + normalized.includes("access forbidden") || + normalized.includes("access denied") || + normalized.includes("permission denied") + ) { + return { + eventType: "unavailable", + severity: "critical", + counted: true, + reason: "provider_access_forbidden", + }; + } + if ( normalized.includes("生成结果未通过替换质检") || normalized.includes("output_moderation") || @@ -323,7 +339,10 @@ export function shouldFailoverAiModelError(error: unknown) { normalized.includes("no task id") || normalized.includes("missing task id"); - if (classification.reason === "provider_authentication_failed") { + if ( + classification.reason === "provider_authentication_failed" || + classification.reason === "provider_access_forbidden" + ) { return true; } From 412fa85a8146038a8789364568263f5abe8be6dc Mon Sep 17 00:00:00 2001 From: 134 Date: Thu, 27 Aug 2026 17:09:33 +0800 Subject: [PATCH 14/17] fix(ai): restore models after successful circuit probe --- lib/ai/runtime/model-health.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/lib/ai/runtime/model-health.ts b/lib/ai/runtime/model-health.ts index 6df4a9db4..6205c6a35 100644 --- a/lib/ai/runtime/model-health.ts +++ b/lib/ai/runtime/model-health.ts @@ -537,7 +537,12 @@ export async function recordAiModelHealthSuccess(params: { if (!existing) return; const now = new Date(); - const isObservation = isObservationPoolModel(existing); + // A request that started before the circuit opened can still prove the + // provider is reachable. Treat that success as the circuit's half-open + // probe instead of leaving the model disabled until the full cooldown. + const recoveredFromAutoDisable = existing.healthStatus === "auto_disabled"; + const isObservation = + isObservationPoolModel(existing) || recoveredFromAutoDisable; const nextRecoverySuccessStreak = isObservation ? (existing.healthRecoverySuccessStreak || 0) + 1 : 0; @@ -547,9 +552,7 @@ export async function recordAiModelHealthSuccess(params: { const updateData: Partial = { healthStatus: - existing.healthStatus === "auto_disabled" - ? "auto_disabled" - : recoveredToHealthy + recoveredToHealthy ? "healthy" : isObservation ? "degraded" @@ -566,12 +569,16 @@ export async function recordAiModelHealthSuccess(params: { healthObservationStartedAt: recoveredToHealthy ? null : isObservation - ? existing.healthObservationStartedAt + ? existing.healthObservationStartedAt || now : null, healthUpdatedAt: now, updatedAt: now, }; + if (recoveredFromAutoDisable) { + updateData.isEnabled = true; + } + if (recoveredToHealthy) { updateData.healthAutoDisabledAt = null; updateData.healthAutoDisabledReason = null; From c39d7260fb2b9d00e7b6bb18546aa9fb543035b0 Mon Sep 17 00:00:00 2001 From: 134 Date: Fri, 28 Aug 2026 22:17:20 +0800 Subject: [PATCH 15/17] fix(model3d): restore archived world assets and previews --- app/api/canvas/world/tasks/[taskId]/route.ts | 107 +++-- app/canvas/[id]/model3d/page.tsx | 85 +++- components/canvas/world3d/World3DViewer.tsx | 63 +-- components/canvas/world3d/WorldSharePage.tsx | 1 + package.json | 1 + .../storage/repair-model3d-qiniu-assets.ts | 414 ++++++++++++++++++ 6 files changed, 568 insertions(+), 103 deletions(-) create mode 100644 scripts/storage/repair-model3d-qiniu-assets.ts diff --git a/app/api/canvas/world/tasks/[taskId]/route.ts b/app/api/canvas/world/tasks/[taskId]/route.ts index 978603df3..057c03d63 100644 --- a/app/api/canvas/world/tasks/[taskId]/route.ts +++ b/app/api/canvas/world/tasks/[taskId]/route.ts @@ -190,6 +190,48 @@ function assertArchivedAssetBuffer(params: { } } +async function verifyArchivedAssetUpload(params: { + url: string; + expectedBytes: number; + validateAs?: ArchiveValidationKind; +}) { + const headResponse = await fetch(params.url, { + method: "HEAD", + cache: "no-store", + }); + if (!headResponse.ok) { + throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`); + } + + const archivedLength = Number(headResponse.headers.get("content-length")); + if ( + Number.isFinite(archivedLength) && + archivedLength > 0 && + archivedLength !== params.expectedBytes + ) { + throw new Error( + `归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})` + ); + } + + if (!params.validateAs) return; + + const rangeResponse = await fetch(params.url, { + method: "GET", + headers: { Range: "bytes=0-63" }, + cache: "no-store", + }); + if (!rangeResponse.ok) { + throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`); + } + const headerBuffer = await rangeResponse.arrayBuffer(); + assertArchivedAssetBuffer({ + buffer: headerBuffer, + contentType: rangeResponse.headers.get("content-type"), + validateAs: params.validateAs, + }); +} + async function archiveRemoteAssetIfNeeded(params: { cacheKey: string; sourceUrl?: string | null; @@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: { ); const filename = `${params.filenamePrefix}.${ext}`; const uploaded = await uploadFile(filename, assetBuffer); + await verifyArchivedAssetUpload({ + url: uploaded.url, + expectedBytes: assetBuffer.byteLength, + validateAs: params.validateAs, + }); cache.set(params.cacheKey, { sourceUrl, archivedUrl: uploaded.url, @@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: { ) ).filter((entry): entry is readonly [string, string] => Boolean(entry)); - return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null; + if (archivedEntries.length !== entries.length) return null; + return Object.fromEntries(archivedEntries); } function getStringValue(...values: unknown[]) { @@ -837,6 +885,19 @@ export async function GET(request: Request, { params }: RouteParams) { fallbackExtension: "spz", validateAs: "spz", }); + const sourceSpzEntryCount = Object.keys( + extractedAssetPayload.spzUrls || {} + ).length; + const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length; + const assetsArchivingPending = Boolean( + (rawMeshUrl && !stableModelUrl) || + (rawThumbnailUrl && !stableThumbnailUrl) || + (rawPanoUrl && !stablePanoUrl) || + (sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount) + ); + const storageArchivedAt = assetsArchivingPending + ? null + : new Date().toISOString(); const assetPayload = { ...externalAssetPayloadBase, worldId: extractedAssetPayload.worldId, @@ -852,7 +913,9 @@ export async function GET(request: Request, { params }: RouteParams) { meshUrl: stableModelUrl, spzUrls: stableSpzUrls, semanticsMetadata: extractedAssetPayload.semanticsMetadata, - storageArchivedAt: new Date().toISOString(), + spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, + storageArchivedAt, + assetsArchivingPending, sourceWorldUrl: extractedAssetPayload.worldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl @@ -891,6 +954,7 @@ export async function GET(request: Request, { params }: RouteParams) { hasPano: Boolean(stablePanoUrl), hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), + assetsArchivingPending, }); return NextResponse.json({ @@ -1171,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) { : 0; const hasMissingSpzArchive = rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length; - const storageArchivedAt = - [ - [rawWorldUrl, stableWorldUrl], - [rawMeshUrl, stableModelUrl], - [rawThumbnailUrl, stableThumbnailUrl], - [rawPanoUrl, stablePanoUrl], - ].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) || - Boolean( - stableSpzUrls && - assetPayload.spzUrls && - Object.entries(stableSpzUrls).some( - ([key, value]) => - typeof assetPayload.spzUrls?.[key] === "string" && - assetPayload.spzUrls[key] !== value - ) - ) - ? new Date().toISOString() - : null; + const assetsArchivingPending = Boolean( + (rawMeshUrl && !stableModelUrl) || + (rawThumbnailUrl && !stableThumbnailUrl) || + (rawPanoUrl && !stablePanoUrl) || + hasMissingSpzArchive + ); + const storageArchivedAt = assetsArchivingPending + ? null + : new Date().toISOString(); logger.info("task success status", { elapsedMs: Date.now() - startedAt, @@ -1200,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) { hasThumbnail: Boolean(stableThumbnailUrl), hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0), storageArchivedAt, - assetsArchivingPending: Boolean( - (rawMeshUrl && !stableModelUrl) || - hasMissingSpzArchive - ), + assetsArchivingPending, }); const responseAssetPayload = { @@ -1213,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) { panoUrl: stablePanoUrl || assetPayload.panoUrl, meshUrl: stableModelUrl, spzUrls: stableSpzUrls, - spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null, + spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null, storageArchivedAt, - assetsArchivingPending: Boolean( - (rawMeshUrl && !stableModelUrl) || - hasMissingSpzArchive - ), + assetsArchivingPending, sourceWorldUrl: rawWorldUrl || null, sourceMeshUrl: rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 9d6acf874..0264d4adb 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) { ); } +function getTaskPreviewCandidates(task?: TaskRecord | null) { + if (!task) return []; + const candidates = [ + task.previewImageUrl, + task.assetPayload?.thumbnailUrl, + task.assetPayload?.panoUrl, + task.assetPayload?.sourcePreviewImageUrl, + task.assetPayload?.sourceThumbnailUrl, + task.assetPayload?.sourcePanoUrl, + ]; + const seen = new Set(); + return candidates + .map((value) => String(value || "").trim()) + .filter((value) => { + if (!value || seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +function isPersistableTaskPreviewUrl(value?: string | null) { + return /^https?:\/\//i.test(String(value || "").trim()); +} + +function TaskAssetPreview({ task }: { task: TaskRecord }) { + const candidates = getTaskPreviewCandidates(task); + const signature = candidates.join("|"); + const [candidateIndex, setCandidateIndex] = useState(0); + + useEffect(() => { + setCandidateIndex(0); + }, [signature]); + + const previewUrl = candidates[candidateIndex] || null; + if (!previewUrl) { + return ( +
+ {task.status === "queued" || task.status === "running" ? ( + + ) : ( + + )} +
+ ); + } + + return ( + 3D 资产预览 setCandidateIndex((index) => index + 1)} + src={previewUrl} + /> + ); +} + function canImportTaskToCanvas(task?: TaskRecord | null) { if (!task || task.status !== "success") return false; if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task)); @@ -1126,7 +1182,9 @@ export default function CanvasModel3DPage() { modelVersion: task.modelVersion, progress: task.progress ?? null, modelUrl: task.modelUrl ?? null, - previewImageUrl: task.previewImageUrl ?? null, + previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl) + ? task.previewImageUrl + : null, error: task.error ?? null, sourceTaskId: task.sourceTaskId ?? null, exportFormat: task.exportFormat ?? null, @@ -1693,6 +1751,7 @@ export default function CanvasModel3DPage() { sourceTaskId?: string | null; exportFormat?: string | null; assetPayload?: Model3DTaskAssetPayload | null; + previewImageUrl?: string | null; }) => { const normalizedPayload = params.provider === "tripo-3d" && @@ -1747,6 +1806,7 @@ export default function CanvasModel3DPage() { createdAt: Date.now(), sourceTaskId: params.sourceTaskId || null, exportFormat: params.exportFormat || null, + previewImageUrl: params.previewImageUrl || null, assetPayload: params.provider === "worldlabs-3d" ? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null @@ -1862,6 +1922,7 @@ export default function CanvasModel3DPage() { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, + previewImageUrl: referencePreviewUrl, }); toast.success("已提交图生3D任务"); return; @@ -1896,6 +1957,8 @@ export default function CanvasModel3DPage() { providerId: uploadResult.data.providerId || null, providerName: uploadResult.data.providerName || null, }, + previewImageUrl: + batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null, }); successCount += 1; } @@ -1951,6 +2014,7 @@ export default function CanvasModel3DPage() { providerId: boundTripoProviderId, providerName: boundTripoProviderName, }, + previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null, }); toast.success("已提交多视图3D任务"); } catch (error) { @@ -2033,6 +2097,7 @@ export default function CanvasModel3DPage() { mode: "world-image", promptText: normalizedPrompt || "图生世界", modelLabel: worldModel, + previewImageUrl: sourceUrl || worldImagePreviewUrl, }); toast.success("已提交图生世界任务"); return; @@ -2117,6 +2182,10 @@ export default function CanvasModel3DPage() { mode: "world-multi-image", promptText: normalizedPrompt || "多图世界", modelLabel: worldModel, + previewImageUrl: + mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl || + selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl || + null, }); toast.success("已提交多图世界任务"); } catch (error) { @@ -4218,8 +4287,6 @@ export default function CanvasModel3DPage() { const canImportWorld = task.provider === "worldlabs-3d" && canImportTaskToCanvas(task); const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId); - const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl; - return (
handleSelectTask(task)} type="button" > - {previewUrl ? ( - preview - ) : ( -
- -
- )} +
diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index a8759fbbc..91b1b27d3 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -1917,9 +1917,6 @@ function WorldSplatCaptureBridge({ function WorldSplatViewer({ splatUrl, splatUrls, - fallbackMeshUrl, - fallbackMeshSettings, - fallbackMeshLayerVisibility, worldUrl, previewImageUrl, firstPerson, @@ -1927,8 +1924,6 @@ function WorldSplatViewer({ interactionMode, onPick, onHotspotSelect, - onFallbackMeshLayersChange, - onFallbackMeshStatsChange, semanticsMetadata, resetViewToken, minimalFrame = false, @@ -1938,9 +1933,6 @@ function WorldSplatViewer({ }: { splatUrl?: string | null; splatUrls?: string[] | null; - fallbackMeshUrl?: string | null; - fallbackMeshSettings?: Model3DViewerSettings; - fallbackMeshLayerVisibility?: Record; worldUrl?: string | null; previewImageUrl?: string | null; minimalFrame?: boolean; @@ -1949,8 +1941,6 @@ function WorldSplatViewer({ interactionMode?: "navigate" | "hotspot"; onPick?: (pick: World3DPickResult) => void; onHotspotSelect?: (hotspotId: string) => void; - onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void; - onFallbackMeshStatsChange?: (stats: Model3DStats) => void; semanticsMetadata?: Record | null; resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | null) => void; @@ -1967,7 +1957,6 @@ function WorldSplatViewer({ const [prefetchedSplat, setPrefetchedSplat] = useState( null ); - const [useMeshFallback, setUseMeshFallback] = useState(false); const resolvedSplatUrls = useMemo(() => { const rawUrls = [ splatUrl, @@ -1985,16 +1974,13 @@ function WorldSplatViewer({ }, [splatUrl, splatUrls]); const resolvedSplatSignature = resolvedSplatUrls.join("|"); const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || ""; - const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null; - const hasMeshFallback = Boolean(resolvedFallbackMeshUrl); const loadStartedAtRef = useRef(0); const movementStateRef = useRef(createMovementState()); useEffect(() => { setActiveSplatIndex(0); setFailedSplatLoads([]); - setUseMeshFallback(false); - }, [resolvedFallbackMeshUrl, resolvedSplatSignature]); + }, [resolvedSplatSignature]); useEffect(() => { if (!resolvedSplatUrl) return; @@ -2047,10 +2033,6 @@ function WorldSplatViewer({ const failedUrl = resolvedSplatUrl; const nextIndex = activeSplatIndex + 1; const nextUrl = resolvedSplatUrls[nextIndex] || ""; - const shouldUseMeshFallback = - hasMeshFallback && - (message.includes("SPZ 加载超时") || !nextUrl); - if (failedUrl) { setFailedSplatLoads((items) => [ ...items, @@ -2058,20 +2040,6 @@ function WorldSplatViewer({ ]); } - if (shouldUseMeshFallback) { - console.warn("[WorldSplatViewer] Switching to mesh fallback", { - error: message, - failedUrl, - attemptedUrls: resolvedSplatUrls.slice(0, nextIndex), - fallbackMeshUrl: resolvedFallbackMeshUrl, - }); - setPrefetchedSplat(null); - setLoadProgress(null); - setOverlayPhase("hidden"); - setUseMeshFallback(true); - return; - } - if (nextUrl) { console.warn("[WorldSplatViewer] Falling back to next SPZ asset", { error: message, @@ -2096,15 +2064,13 @@ function WorldSplatViewer({ }, [ activeSplatIndex, - hasMeshFallback, - resolvedFallbackMeshUrl, resolvedSplatUrl, resolvedSplatUrls, ] ); useEffect(() => { - if (!resolvedSplatUrl || useMeshFallback) return; + if (!resolvedSplatUrl) return; const abortController = new AbortController(); let cancelled = false; @@ -2141,25 +2107,7 @@ function WorldSplatViewer({ globalThis.clearTimeout(timeoutId); abortController.abort(); }; - }, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]); - - if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) { - return ( - - ); - } + }, [handleSplatLoadStateChange, resolvedSplatUrl]); if (!resolvedSplatUrl) { return ( @@ -2375,13 +2323,8 @@ export function World3DViewer({ unknown; +}; +const originalLoad = moduleLoader._load; +moduleLoader._load = function patchedServerOnlyLoad( + this: typeof moduleLoader, + request, + parent, + isMain +) { + if (request === "server-only") return {}; + return originalLoad.call(this, request, parent, isMain); +}; + +type AssetKind = "glb" | "image" | "spz"; +type StringRecord = Record; +type JsonRecord = Record; + +const dryRun = process.argv.includes("--dry-run"); +const worldOnly = process.argv.includes("--world-only"); +const tripoOnly = process.argv.includes("--tripo-only"); +const limitArg = process.argv.find((arg) => arg.startsWith("--limit=")); +const limit = limitArg ? Math.max(1, Number(limitArg.split("=")[1]) || 1) : null; +const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000; + +function normalizeUrl(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function isHttpUrl(value: string) { + return /^https?:\/\//i.test(value); +} + +function hasGlbMagic(bytes: Uint8Array) { + return ( + bytes.length >= 12 && + bytes[0] === 0x67 && + bytes[1] === 0x6c && + bytes[2] === 0x54 && + bytes[3] === 0x46 + ); +} + +function hasSpzMagic(bytes: Uint8Array) { + return ( + (bytes.length >= 4 && + bytes[0] === 0x4e && + bytes[1] === 0x47 && + bytes[2] === 0x53 && + bytes[3] === 0x50) || + (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) + ); +} + +function looksLikeErrorPayload(bytes: Uint8Array) { + const head = new TextDecoder("utf-8", { fatal: false }) + .decode(bytes.slice(0, Math.min(bytes.length, 64))) + .trimStart(); + return head.startsWith("<") || head.startsWith("{") || head.startsWith("["); +} + +function validateBytes(bytes: Uint8Array, kind: AssetKind) { + if (bytes.byteLength <= 0 || looksLikeErrorPayload(bytes)) return false; + if (kind === "glb") return hasGlbMagic(bytes); + if (kind === "spz") return hasSpzMagic(bytes); + return true; +} + +async function probeAsset(url: string, kind: AssetKind) { + if (!isHttpUrl(url)) return false; + try { + const response = await fetch(url, { + method: "GET", + headers: { Range: "bytes=0-63" }, + cache: "no-store", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) return false; + const bytes = new Uint8Array(await response.arrayBuffer()); + return validateBytes(bytes, kind); + } catch { + return false; + } +} + +async function downloadAsset(url: string, kind: AssetKind) { + const response = await fetch(url, { + method: "GET", + headers: { "User-Agent": "AI-Chatbot-3D-Qiniu-Repair/1.0" }, + cache: "no-store", + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`下载失败(HTTP ${response.status})`); + } + const buffer = await response.arrayBuffer(); + if (!validateBytes(new Uint8Array(buffer), kind)) { + throw new Error(`${kind.toUpperCase()} 文件内容无效`); + } + return { + buffer, + contentType: response.headers.get("content-type"), + }; +} + +function resolveExtension(url: string, kind: AssetKind, contentType?: string | null) { + if (kind === "glb") return "glb"; + if (kind === "spz") return "spz"; + const type = String(contentType || "").toLowerCase(); + if (type.includes("webp")) return "webp"; + if (type.includes("png")) return "png"; + if (type.includes("gif")) return "gif"; + try { + const ext = new URL(url).pathname.split(".").pop()?.toLowerCase(); + if (ext && ["jpg", "jpeg", "png", "webp", "gif"].includes(ext)) return ext; + } catch { + // ignore + } + return "jpg"; +} + +function replaceUrlsDeep(value: unknown, replacements: Map): unknown { + if (typeof value === "string") return replacements.get(value) || value; + if (Array.isArray(value)) { + return value.map((item) => replaceUrlsDeep(item, replacements)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as JsonRecord).map(([key, item]) => [ + key, + replaceUrlsDeep(item, replacements), + ]) + ); + } + return value; +} + +function toStringRecord(value: unknown): StringRecord { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries( + Object.entries(value as JsonRecord) + .map(([key, item]) => [key, normalizeUrl(item)] as const) + .filter(([, item]) => isHttpUrl(item)) + ); +} + +async function main() { + const [dbModule, schemaModule, storageModule, tripoClient, tripoServer] = + await Promise.all([ + import("../../lib/db"), + import("../../lib/db/schema"), + import("../../lib/storage/service"), + import("../../lib/tripo/client"), + import("../../lib/tripo/server"), + ]); + const { db } = dbModule; + const { canvasNode, model3dAsset, storageConfig } = schemaModule; + const { toDbS3UploadConfig, uploadFileWithS3Config } = storageModule; + + const qiniuRows = await db + .select() + .from(storageConfig) + .where(eq(storageConfig.type, "qiniu-kodo")) + .orderBy(desc(storageConfig.isDefault), desc(storageConfig.updatedAt)); + const qiniuRow = qiniuRows.find((item) => item.isEnabled); + const qiniuConfig = toDbS3UploadConfig(qiniuRow); + if (!qiniuConfig || qiniuConfig.provider !== "qiniu-kodo") { + throw new Error("未找到已启用的七牛云 Kodo 配置,已拒绝使用其他存储"); + } + + let assets = await db + .select() + .from(model3dAsset) + .where(eq(model3dAsset.status, "success")) + .orderBy(desc(model3dAsset.createdAt)); + if (worldOnly) assets = assets.filter((asset) => asset.provider === "worldlabs-3d"); + if (tripoOnly) assets = assets.filter((asset) => asset.provider === "tripo-3d"); + if (limit) assets = assets.slice(0, limit); + + const backupPath = path.join( + process.cwd(), + "backups", + `model3d-assets-before-qiniu-repair-${new Date() + .toISOString() + .replace(/[:.]/g, "-")}.json` + ); + if (!dryRun) { + await mkdir(path.dirname(backupPath), { recursive: true }); + await writeFile(backupPath, JSON.stringify(assets, null, 2)); + console.log(`[repair-3d] backup=${backupPath}`); + } + + const summary = { + scanned: 0, + repairedAssets: 0, + repairedFiles: 0, + failedFiles: 0, + updatedNodes: 0, + }; + + for (const asset of assets) { + summary.scanned += 1; + const payload = + asset.assetPayload && typeof asset.assetPayload === "object" + ? ({ ...asset.assetPayload } as JsonRecord) + : {}; + const replacements = new Map(); + let nextModelUrl = normalizeUrl(asset.modelUrl) || null; + let nextPreviewUrl = normalizeUrl(asset.previewImageUrl) || null; + let freshTripoOutput: Record | null = null; + + const uploadRecoveredAsset = async (params: { + currentUrl?: string | null; + sourceUrl?: string | null; + kind: AssetKind; + label: string; + }) => { + const currentUrl = normalizeUrl(params.currentUrl); + if (currentUrl && (await probeAsset(currentUrl, params.kind))) return currentUrl; + + let sourceUrl = normalizeUrl(params.sourceUrl); + if ( + asset.provider === "tripo-3d" && + (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) + ) { + if (!freshTripoOutput) { + const preferredProviderId = normalizeUrl(payload.providerId); + const provider = await tripoServer.resolveTripoProviderConfigForTaskId( + asset.taskId, + preferredProviderId + ); + const task = await tripoClient.tripoRequest<{ + output?: Record; + }>(provider, { + method: "GET", + path: `/task/${encodeURIComponent(asset.taskId)}`, + }); + freshTripoOutput = task.data.output || {}; + } + sourceUrl = + params.kind === "glb" + ? normalizeUrl(tripoClient.pickTaskModelUrl(freshTripoOutput)) + : normalizeUrl(tripoClient.pickTaskPreviewImageUrl(freshTripoOutput)); + } + + if (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) { + throw new Error(`${params.label} 没有可恢复的源文件`); + } + if (dryRun) return sourceUrl; + + const downloaded = await downloadAsset(sourceUrl, params.kind); + const extension = resolveExtension( + sourceUrl, + params.kind, + downloaded.contentType + ); + const uploaded = await uploadFileWithS3Config( + `repair-${asset.taskId}-${params.label}.${extension}`, + downloaded.buffer, + qiniuConfig + ); + if (!(await probeAsset(uploaded.url, params.kind))) { + throw new Error(`${params.label} 上传七牛云后校验失败`); + } + if (currentUrl) replacements.set(currentUrl, uploaded.url); + if (sourceUrl) replacements.set(sourceUrl, uploaded.url); + summary.repairedFiles += 1; + console.log( + `[repair-3d] repaired task=${asset.taskId} file=${params.label} bytes=${downloaded.buffer.byteLength}` + ); + return uploaded.url; + }; + + try { + if (asset.provider === "worldlabs-3d") { + nextModelUrl = await uploadRecoveredAsset({ + currentUrl: nextModelUrl, + sourceUrl: normalizeUrl(payload.sourceMeshUrl), + kind: "glb", + label: "mesh", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} mesh: ${String(error)}`); + return nextModelUrl; + }); + + const nextThumbnailUrl = await uploadRecoveredAsset({ + currentUrl: normalizeUrl(payload.thumbnailUrl), + sourceUrl: normalizeUrl(payload.sourceThumbnailUrl), + kind: "image", + label: "thumbnail", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} thumbnail: ${String(error)}`); + return normalizeUrl(payload.thumbnailUrl) || null; + }); + const nextPanoUrl = await uploadRecoveredAsset({ + currentUrl: normalizeUrl(payload.panoUrl), + sourceUrl: normalizeUrl(payload.sourcePanoUrl), + kind: "image", + label: "pano", + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} pano: ${String(error)}`); + return normalizeUrl(payload.panoUrl) || null; + }); + + const currentSpzUrls = toStringRecord(payload.spzUrls); + const sourceSpzUrls = toStringRecord(payload.sourceSpzUrls); + const nextSpzUrls: StringRecord = {}; + for (const [key, sourceUrl] of Object.entries(sourceSpzUrls)) { + const repairedUrl = await uploadRecoveredAsset({ + currentUrl: currentSpzUrls[key], + sourceUrl, + kind: "spz", + label: `spz-${key}`, + }).catch((error) => { + summary.failedFiles += 1; + console.warn(`[repair-3d] ${asset.taskId} spz-${key}: ${String(error)}`); + return currentSpzUrls[key] || null; + }); + if (repairedUrl) nextSpzUrls[key] = repairedUrl; + } + for (const [key, currentUrl] of Object.entries(currentSpzUrls)) { + if (!nextSpzUrls[key] && (await probeAsset(currentUrl, "spz"))) { + nextSpzUrls[key] = currentUrl; + } + } + + payload.meshUrl = nextModelUrl; + payload.thumbnailUrl = nextThumbnailUrl; + payload.panoUrl = nextPanoUrl; + payload.spzUrls = nextSpzUrls; + nextPreviewUrl = nextThumbnailUrl || nextPanoUrl || nextPreviewUrl; + const allSpzRecovered = + Object.keys(sourceSpzUrls).length > 0 && + Object.keys(nextSpzUrls).length === Object.keys(sourceSpzUrls).length; + payload.assetsArchivingPending = !allSpzRecovered; + payload.storageArchivedAt = allSpzRecovered ? new Date().toISOString() : null; + payload.spzArchiveValidatedAt = allSpzRecovered + ? new Date().toISOString() + : null; + } else if (asset.provider === "tripo-3d") { + nextModelUrl = await uploadRecoveredAsset({ + currentUrl: nextModelUrl, + sourceUrl: normalizeUrl(payload.sourceModelUrl), + kind: "glb", + label: "model", + }); + nextPreviewUrl = await uploadRecoveredAsset({ + currentUrl: nextPreviewUrl, + sourceUrl: normalizeUrl(payload.sourcePreviewImageUrl), + kind: "image", + label: "preview", + }); + payload.storageArchivedAt = new Date().toISOString(); + payload.assetsArchivingPending = false; + } + } catch (error) { + summary.failedFiles += 1; + console.warn(`[repair-3d] task=${asset.taskId} skipped: ${String(error)}`); + } + + if (replacements.size === 0 || dryRun) continue; + + await db.transaction(async (tx) => { + await tx + .update(model3dAsset) + .set({ + modelUrl: nextModelUrl, + previewImageUrl: nextPreviewUrl, + assetPayload: replaceUrlsDeep(payload, replacements) as JsonRecord, + updatedAt: new Date(), + }) + .where(eq(model3dAsset.id, asset.id)); + + const nodes = await tx + .select({ id: canvasNode.id, data: canvasNode.data }) + .from(canvasNode) + .where(eq(canvasNode.projectId, asset.projectId)); + for (const node of nodes) { + const serialized = JSON.stringify(node.data || {}); + if (!serialized.includes(asset.taskId) && ![...replacements.keys()].some((url) => serialized.includes(url))) { + continue; + } + await tx + .update(canvasNode) + .set({ + data: replaceUrlsDeep(node.data, replacements), + updatedAt: new Date(), + }) + .where(eq(canvasNode.id, node.id)); + summary.updatedNodes += 1; + } + }); + summary.repairedAssets += 1; + } + + console.log(JSON.stringify({ dryRun, summary }, null, 2)); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From 4d8c984d0863530cb5788ad07394c31d54d1c683 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 2 Sep 2026 12:42:02 +0800 Subject: [PATCH 16/17] feat(claw): preview 3D artifacts in chat --- app/api/canvas/clawd-chat/artifact/route.ts | 266 ++++++++++++++++++ app/api/canvas/clawd-chat/route.ts | 5 + app/api/canvas/clawd-chat/status/route.ts | 1 + app/api/messages/route.ts | 29 +- components/canvas/CanvasChatPanel.tsx | 32 +++ .../chat-panel/OpenClawModel3DPreview.tsx | 179 ++++++++++++ components/canvas/model3d/Model3DViewer.tsx | 68 ++++- lib/openclaw-chat/artifacts.ts | 218 ++++++++++++++ lib/openclaw-chat/task-state.ts | 7 + lib/openclaw-chat/task-store.ts | 8 + lib/openclaw-chat/upstream-task-client.ts | 8 + playwright.helpers.config.ts | 1 + tests/routes/openclaw-3d-artifacts.test.ts | 120 ++++++++ 13 files changed, 932 insertions(+), 10 deletions(-) create mode 100644 app/api/canvas/clawd-chat/artifact/route.ts create mode 100644 components/canvas/chat-panel/OpenClawModel3DPreview.tsx create mode 100644 lib/openclaw-chat/artifacts.ts create mode 100644 tests/routes/openclaw-3d-artifacts.test.ts diff --git a/app/api/canvas/clawd-chat/artifact/route.ts b/app/api/canvas/clawd-chat/artifact/route.ts new file mode 100644 index 000000000..2f4e76be8 --- /dev/null +++ b/app/api/canvas/clawd-chat/artifact/route.ts @@ -0,0 +1,266 @@ +import { lookup } from "node:dns/promises"; +import { isIP } from "node:net"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/app/(auth)/auth"; +import { getChatById, getMessageById, getSystemSetting } from "@/lib/db/queries"; +import { + normalizeOpenClawChatConfig, + OPENCLAW_CHAT_CONFIG_KEY, +} from "@/lib/openclaw-chat/config"; +import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 120; + +const querySchema = z.object({ + chatId: z.string().uuid(), + messageId: z.string().uuid(), + index: z.coerce.number().int().min(0).max(127), + download: z.enum(["0", "1"]).optional().default("0"), +}); + +const MAX_REDIRECTS = 4; +const RESPONSE_HEADERS = [ + "accept-ranges", + "content-length", + "content-range", + "etag", + "last-modified", +] as const; + +function isPrivateIpv4(address: string) { + const octets = address.split(".").map(Number); + if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value))) { + return true; + } + const [a, b] = octets; + return ( + a === 0 || + a === 10 || + a === 127 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + a >= 224 + ); +} + +function isPrivateIpAddress(address: string) { + if (isIP(address) === 4) return isPrivateIpv4(address); + if (isIP(address) !== 6) return true; + const normalized = address.toLowerCase(); + if (normalized.startsWith("::ffff:")) { + return isPrivateIpv4(normalized.slice("::ffff:".length)); + } + return ( + normalized === "::" || + normalized === "::1" || + normalized.startsWith("fc") || + normalized.startsWith("fd") || + normalized.startsWith("fe8") || + normalized.startsWith("fe9") || + normalized.startsWith("fea") || + normalized.startsWith("feb") || + normalized.startsWith("ff") + ); +} + +async function validateTargetUrl(target: URL, trustedOrigin: string) { + if (target.username || target.password) return false; + if (target.origin === trustedOrigin) { + return target.protocol === "http:" || target.protocol === "https:"; + } + if (target.protocol !== "https:") return false; + const hostname = target.hostname.toLowerCase().replace(/^\[|\]$/g, ""); + if ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") + ) { + return false; + } + if (isIP(hostname)) return !isPrivateIpAddress(hostname); + + const addresses = await lookup(hostname, { all: true, verbatim: true }).catch( + () => [] + ); + return ( + addresses.length > 0 && + addresses.every((entry) => !isPrivateIpAddress(entry.address)) + ); +} + +function normalizeRangeHeader(value: string | null) { + const range = String(value || "").trim(); + if (!range) return null; + const match = range.match(/^bytes=(\d*)-(\d*)$/); + if (!match || (!match[1] && !match[2])) return ""; + if (match[1] && match[2] && Number(match[1]) > Number(match[2])) return ""; + return range; +} + +function inferContentType(filename: string, declaredType: string) { + if (declaredType) return declaredType; + const lower = filename.toLowerCase(); + if (lower.endsWith(".glb")) return "model/gltf-binary"; + if (lower.endsWith(".gltf")) return "model/gltf+json"; + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".webp")) return "image/webp"; + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; + return "application/octet-stream"; +} + +function safeDownloadName(value: string) { + const normalized = value.replace(/[\r\n"\\/]/g, "_").trim() || "artifact"; + return normalized.slice(0, 180); +} + +async function fetchArtifact(params: { + request: Request; + initialUrl: URL; + method: "GET" | "HEAD"; + range: string | null; + token: string; + trustedOrigin: string; +}) { + let target = params.initialUrl; + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + if (!(await validateTargetUrl(target, params.trustedOrigin))) { + throw new Error("不允许访问该文件地址"); + } + const headers = new Headers({ Accept: "*/*" }); + if (params.range) headers.set("Range", params.range); + if (target.origin === params.trustedOrigin && params.token) { + headers.set("Authorization", `Bearer ${params.token}`); + } + const response = await fetch(target, { + method: params.method, + headers, + cache: "no-store", + redirect: "manual", + signal: params.request.signal, + }); + if (![301, 302, 303, 307, 308].includes(response.status)) { + return { response, target }; + } + if (redirects === MAX_REDIRECTS) throw new Error("文件重定向次数过多"); + const location = response.headers.get("location"); + if (!location) throw new Error("文件重定向地址无效"); + target = new URL(location, target); + } + throw new Error("文件地址无效"); +} + +async function handleRequest(request: Request, method: "GET" | "HEAD") { + const session = await auth().catch(() => null); + if (!session?.user?.id) { + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const url = new URL(request.url); + const parsed = querySchema.safeParse({ + chatId: url.searchParams.get("chatId"), + messageId: url.searchParams.get("messageId"), + index: url.searchParams.get("index"), + download: url.searchParams.get("download") || undefined, + }); + if (!parsed.success) { + return NextResponse.json({ error: "请求参数不正确" }, { status: 400 }); + } + + const chat = await getChatById({ id: parsed.data.chatId }).catch(() => null); + if (!chat || chat.userId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const rows = await getMessageById({ id: parsed.data.messageId }).catch( + () => [] + ); + const message = rows.find((row) => row.chatId === parsed.data.chatId); + const metadata = normalizeClawdTaskMetadata(message?.videoMetadata); + const artifact = metadata?.artifacts?.[parsed.data.index]; + if (!message || !artifact) { + return NextResponse.json({ error: "文件不存在" }, { status: 404 }); + } + + const setting = await getSystemSetting({ key: OPENCLAW_CHAT_CONFIG_KEY }).catch( + () => null + ); + const config = normalizeOpenClawChatConfig(setting?.value); + let target: URL; + let trustedOrigin: string; + try { + target = new URL(artifact.downloadUrl); + trustedOrigin = new URL(config.baseUrl).origin; + } catch { + return NextResponse.json({ error: "文件地址无效" }, { status: 400 }); + } + + const range = normalizeRangeHeader(request.headers.get("range")); + if (range === "") { + return NextResponse.json({ error: "Range 请求格式无效" }, { status: 400 }); + } + + try { + const { response, target: finalTarget } = await fetchArtifact({ + request, + initialUrl: target, + method, + range, + token: config.token, + trustedOrigin, + }); + if (!response.ok && response.status !== 206) { + return NextResponse.json( + { error: `文件读取失败(HTTP ${response.status})` }, + { status: response.status >= 400 && response.status < 600 ? response.status : 502 } + ); + } + + const headers = new Headers(); + for (const name of RESPONSE_HEADERS) { + const value = response.headers.get(name); + if (value) headers.set(name, value); + } + headers.set( + "content-type", + response.headers.get("content-type") || + inferContentType(artifact.filename || finalTarget.pathname, artifact.mimeType) + ); + headers.set("cache-control", "private, max-age=3600, must-revalidate"); + headers.set("x-content-type-options", "nosniff"); + if (parsed.data.download === "1") { + const filename = safeDownloadName(artifact.filename || artifact.displayName); + headers.set( + "content-disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(filename)}` + ); + } + + return new Response(method === "HEAD" ? null : response.body, { + status: response.status, + headers, + }); + } catch (error) { + console.warn("[Clawd Artifact] Proxy failed:", { + chatId: parsed.data.chatId, + messageId: parsed.data.messageId, + artifactIndex: parsed.data.index, + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: error instanceof Error ? error.message : "文件读取失败" }, + { status: 502 } + ); + } +} + +export async function GET(request: Request) { + return handleRequest(request, "GET"); +} + +export async function HEAD(request: Request) { + return handleRequest(request, "HEAD"); +} diff --git a/app/api/canvas/clawd-chat/route.ts b/app/api/canvas/clawd-chat/route.ts index fe66d0cfe..d650daef1 100644 --- a/app/api/canvas/clawd-chat/route.ts +++ b/app/api/canvas/clawd-chat/route.ts @@ -29,6 +29,7 @@ import { OPENCLAW_CHAT_MEDIA_MAX_ATTACHMENTS, type OpenClawChatMediaAttachment, } from "@/lib/openclaw-chat/media-attachments"; +import { extractOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts"; import { buildOpenClawDisplayMessage, buildOpenClawMessageWithSpreadsheetMarkdown, @@ -337,6 +338,9 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { assistantMessageId: params.assistantMessageId, answer, requestId: getRequestId(payload), + artifacts: extractOpenClawArtifacts(payload, { + baseUrl: params.config.baseUrl, + }), }); clearClawdRequestCancellation(params.cancelKey); return; @@ -370,6 +374,7 @@ async function executeClawdTask(params: ExecuteClawdTaskParams) { ...target, answer: outcome.answer, requestId: outcome.requestId, + artifacts: outcome.artifacts, }); } else if (outcome.status === "canceled") { await markClawdTaskCanceled({ diff --git a/app/api/canvas/clawd-chat/status/route.ts b/app/api/canvas/clawd-chat/status/route.ts index 70c369393..423940dd4 100644 --- a/app/api/canvas/clawd-chat/status/route.ts +++ b/app/api/canvas/clawd-chat/status/route.ts @@ -75,6 +75,7 @@ async function reconcileDetachedOpenClawTask(params: { ...target, answer: outcome.answer, requestId: outcome.requestId, + artifacts: outcome.artifacts, }); } else if (outcome.status === "failed") { await markClawdTaskFailed({ ...target, error: outcome.error }); diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts index 65b4f9114..247f29bb3 100644 --- a/app/api/messages/route.ts +++ b/app/api/messages/route.ts @@ -14,6 +14,10 @@ import { updateMessageById, } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import { + buildOpenClawArtifactProxyUrl, + normalizeOpenClawArtifacts, +} from "@/lib/openclaw-chat/artifacts"; import { getFalImageTaskStatus, resolveFalImageTaskToMarkdown, @@ -32,12 +36,29 @@ const FAL_IMAGE_TASK_POLL_INTERVAL_MS = 2_000; const falTaskLastPolledAt = new Map(); const falTaskSyncInFlight = new Set(); -function toPublicVideoMetadata(value: DBMessage["videoMetadata"]) { +function toPublicVideoMetadata(params: { + message: DBMessage; + requestOrigin: string; +}) { + const value = params.message.videoMetadata; if (!value || typeof value !== "object" || Array.isArray(value)) return value; const metadata = value as Record; if (metadata.taskKind !== "clawd_chat") return value; const publicMetadata = { ...metadata }; delete publicMetadata.upstreamTaskKey; + publicMetadata.artifacts = normalizeOpenClawArtifacts(metadata.artifacts).map( + (artifact, artifactIndex) => ({ + ...artifact, + downloadUrl: new URL( + buildOpenClawArtifactProxyUrl({ + chatId: params.message.chatId, + messageId: params.message.id, + artifactIndex, + }), + params.requestOrigin + ).toString(), + }) + ); return publicMetadata; } @@ -286,7 +307,11 @@ export async function GET(request: NextRequest) { createdAt: msg.createdAt, parts: parts, attachments: msg.attachments || [], - videoMetadata: toPublicVideoMetadata(msg.videoMetadata) || null, + videoMetadata: + toPublicVideoMetadata({ + message: msg, + requestOrigin: request.nextUrl.origin, + }) || null, }; }); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index a57566f80..1e1901da1 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -118,6 +118,8 @@ import { OPENCLAW_AUTH_REQUIRED_MESSAGE, OPENCLAW_UPGRADE_RETRY_MESSAGE, } from "@/lib/openclaw-chat/errors"; +import { normalizeOpenClawArtifacts } from "@/lib/openclaw-chat/artifacts"; +import { OpenClawModel3DPreview } from "@/components/canvas/chat-panel/OpenClawModel3DPreview"; import { isCanvasImageReadyForAi, isCanvasImageUploadPending, @@ -495,6 +497,26 @@ function isClawdChatMessage(message: unknown) { return metadata.composerMode === "clawd" || videoMetadata.composerMode === "clawd"; } +function getOpenClawMessageArtifacts(message: unknown) { + if (!message || typeof message !== "object") return []; + const raw = message as Record; + const metadata = + raw.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata) + ? raw.metadata + : {}; + const videoMetadata = + raw.videoMetadata && + typeof raw.videoMetadata === "object" && + !Array.isArray(raw.videoMetadata) + ? raw.videoMetadata + : {}; + return normalizeOpenClawArtifacts( + Array.isArray(videoMetadata.artifacts) + ? videoMetadata.artifacts + : metadata.artifacts + ); +} + 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([ @@ -16132,6 +16154,9 @@ export function CanvasChatPanel({ const isClawAssistantMessage = message.role === "assistant" && (isClawdMode || isClawdChatMessage(message)); + const openClawArtifacts = isClawAssistantMessage + ? getOpenClawMessageArtifacts(message) + : []; const messageTimestamp = getMessageTimestamp(message); const prevMessageTimestamp = @@ -16710,6 +16735,13 @@ export function CanvasChatPanel({ } return null; })} + {openClawArtifacts.length > 0 && rawChatId ? ( + + ) : null}
diff --git a/components/canvas/chat-panel/OpenClawModel3DPreview.tsx b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx new file mode 100644 index 000000000..6a648c889 --- /dev/null +++ b/components/canvas/chat-panel/OpenClawModel3DPreview.tsx @@ -0,0 +1,179 @@ +"use client"; + +/* eslint-disable @next/next/no-img-element */ +import dynamic from "next/dynamic"; +import { Box, Download, Loader2, RotateCcw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + buildOpenClawArtifactProxyUrl, + isOpenClawInteractive3DArtifact, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; + +const Model3DViewer = dynamic( + () => + import("@/components/canvas/model3d/Model3DViewer").then( + (module) => module.Model3DViewer + ), + { + ssr: false, + loading: () => ( +
+ +
+ ), + } +); + +function supportsWebGl() { + try { + const canvas = document.createElement("canvas"); + return Boolean( + canvas.getContext("webgl2") || + canvas.getContext("webgl") || + canvas.getContext("experimental-webgl") + ); + } catch { + return false; + } +} + +export function OpenClawModel3DPreview({ + artifacts, + chatId, + messageId, +}: { + artifacts: OpenClawArtifact[]; + chatId: string; + messageId: string; +}) { + const modelIndex = useMemo( + () => artifacts.findIndex(isOpenClawInteractive3DArtifact), + [artifacts] + ); + const model = modelIndex >= 0 ? artifacts[modelIndex] : null; + const fallbackRole = model?.viewer?.fallbackArtifactRole || "cad_preview"; + const previewIndex = artifacts.findIndex( + (artifact) => artifact.artifactRole === fallbackRole + ); + const [isActive, setIsActive] = useState(false); + const [webGlUnavailable, setWebGlUnavailable] = useState(false); + const [resetViewToken, setResetViewToken] = useState(0); + + if (!model) return null; + + const modelUrl = buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: modelIndex, + }); + const downloadUrl = buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: modelIndex, + download: true, + }); + const previewUrl = + previewIndex >= 0 + ? buildOpenClawArtifactProxyUrl({ + chatId, + messageId, + artifactIndex: previewIndex, + }) + : null; + + const activateViewer = () => { + if (!supportsWebGl()) { + setWebGlUnavailable(true); + return; + } + setWebGlUnavailable(false); + setIsActive(true); + }; + + return ( +
+
+
+ + + {model.displayName || model.filename || "3D 模型"} + + + {model.viewer?.format || "glb"} + +
+
+ {isActive ? ( + + ) : null} + + + +
+
+ +
+ {isActive ? ( + + ) : ( +
+ {previewUrl ? ( + {`${model.displayName + ) : ( + + )} + +
+ )} +
+ {webGlUnavailable ? ( +

+ 当前浏览器无法启动3D预览,已保留静态预览和模型下载。 +

+ ) : null} +
+ ); +} diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index a58791365..ff459f2c2 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -53,6 +53,8 @@ export type Model3DViewerSettings = { type Model3DViewerProps = { modelUrl?: string | null; + modelFormat?: "glb" | "gltf" | null; + autoFitModel?: boolean; previewImageUrl?: string | null; emptyStateTitle?: string; settings: Model3DViewerSettings; @@ -323,9 +325,14 @@ function cloneSceneWithIndependentMaterials(scene: Object3D) { function resolveSceneGroundY( scene: Object3D, - modelRotation?: [number, number, number] + modelRotation?: [number, number, number], + fitTransform?: { position: [number, number, number]; scale: number } ) { const probeScene = scene.clone(true); + if (fitTransform) { + probeScene.position.set(...fitTransform.position); + probeScene.scale.setScalar(fitTransform.scale); + } const probeGroup = new THREE.Group(); if (modelRotation) { probeGroup.rotation.set(...modelRotation); @@ -338,6 +345,26 @@ function resolveSceneGroundY( return bounds.min.y; } +function getModelFitTransform(scene: Object3D) { + const bounds = new THREE.Box3().setFromObject(scene); + if (bounds.isEmpty()) return { position: [0, 0, 0] as [number, number, number], scale: 1 }; + const size = bounds.getSize(new THREE.Vector3()); + const center = bounds.getCenter(new THREE.Vector3()); + const largestDimension = Math.max(size.x, size.y, size.z); + if (!Number.isFinite(largestDimension) || largestDimension <= 0) { + return { position: [0, 0, 0] as [number, number, number], scale: 1 }; + } + const scale = 2 / largestDimension; + return { + position: [ + -center.x * scale, + -center.y * scale, + -center.z * scale, + ] as [number, number, number], + scale, + }; +} + function ModelScene({ url, settings, @@ -346,6 +373,7 @@ function ModelScene({ onLayersChange, onStatsChange, onGroundYChange, + autoFitModel, }: { url: string; settings: Model3DViewerSettings; @@ -354,6 +382,7 @@ function ModelScene({ onLayersChange?: (layers: Model3DLayer[]) => void; onStatsChange?: (stats: Model3DStats) => void; onGroundYChange?: (groundY: number) => void; + autoFitModel?: boolean; }) { const { gl } = useThree(); const ktx2Loader = useMemo(() => { @@ -382,11 +411,18 @@ function ModelScene({ [model.scene] ); const meta = useMemo(() => collectSceneMeta(scene), [scene]); + const fitTransform = useMemo( + () => + autoFitModel + ? getModelFitTransform(scene) + : { position: [0, 0, 0] as [number, number, number], scale: 1 }, + [autoFitModel, scene] + ); const modelRotationKey = modelRotation?.join("|") || ""; const groundY = useMemo( - () => resolveSceneGroundY(scene, modelRotation), + () => resolveSceneGroundY(scene, modelRotation, fitTransform), // eslint-disable-next-line react-hooks/exhaustive-deps - [scene, modelRotationKey] + [scene, modelRotationKey, fitTransform] ); useEffect(() => { @@ -437,7 +473,11 @@ function ModelScene({ return ( - + ); } @@ -679,6 +719,8 @@ function EmptyState({ export function Model3DViewer({ modelUrl, + modelFormat, + autoFitModel = false, previewImageUrl, emptyStateTitle = "准备生成 3D 资产", settings, @@ -715,7 +757,7 @@ export function Model3DViewer({ ); } - if (!isGlbOrGltf(modelUrl)) { + if (!isGlbOrGltf(modelUrl) && modelFormat !== "glb" && modelFormat !== "gltf") { return (
@@ -737,9 +779,17 @@ export function Model3DViewer({ return ( - -

3D 预览加载失败,请尝试在新窗口打开

+
+ {previewImageUrl ? ( + 3D 模型静态预览 + ) : null} +
+ +

3D 预览加载失败,已切换为静态预览

+
} > @@ -810,6 +861,7 @@ export function Model3DViewer({ > { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readText(value: unknown, maxLength = MAX_TEXT_LENGTH) { + return typeof value === "string" ? value.trim().slice(0, maxLength) : ""; +} + +function readBoolean(value: unknown, fallback: boolean) { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeArtifactUrl(value: unknown, baseUrl?: string) { + const raw = readText(value, MAX_URL_LENGTH); + if (!raw) return ""; + + try { + const url = baseUrl ? new URL(raw, baseUrl) : new URL(raw); + return url.protocol === "http:" || url.protocol === "https:" + ? url.toString() + : ""; + } catch { + return ""; + } +} + +function normalizeDimensions(value: unknown) { + const raw = readRecord(value); + const dimensions = ["x", "y", "z"].map((key) => Number(raw[key])); + if (dimensions.some((dimension) => !Number.isFinite(dimension) || dimension < 0)) { + return null; + } + return { x: dimensions[0], y: dimensions[1], z: dimensions[2] }; +} + +function inferViewerFormat(params: { + filename: string; + mimeType: string; + value: unknown; +}) { + const explicit = readText(params.value).toLowerCase(); + if (explicit === "gltf") return "gltf" as const; + if (explicit === "glb") return "glb" as const; + if ( + params.mimeType.toLowerCase() === "model/gltf+json" || + params.filename.toLowerCase().endsWith(".gltf") + ) { + return "gltf" as const; + } + return "glb" as const; +} + +function normalizeViewer( + value: unknown, + artifact: { filename: string; mimeType: string } +): OpenClawArtifactViewer | null { + const raw = readRecord(value); + if (Object.keys(raw).length === 0) return null; + const type = readText(raw.type).toLowerCase(); + if (type && type !== "model_3d") return null; + + const background = readText(raw.background, 32); + return { + type: "model_3d", + format: inferViewerFormat({ + filename: artifact.filename, + mimeType: artifact.mimeType, + value: raw.format, + }), + cameraControls: readBoolean(raw.camera_controls ?? raw.cameraControls, true), + autoRotate: readBoolean(raw.auto_rotate ?? raw.autoRotate, false), + background: background || null, + fallbackArtifactRole: + readText(raw.fallback_artifact_role ?? raw.fallbackArtifactRole) || null, + }; +} + +function normalizeArtifact( + value: unknown, + baseUrl?: string +): OpenClawArtifact | null { + const raw = readRecord(value); + if (Object.keys(raw).length === 0) return null; + + const downloadUrl = normalizeArtifactUrl( + raw.download_url ?? raw.downloadUrl ?? raw.url, + baseUrl + ); + if (!downloadUrl) return null; + + const filename = + readText(raw.filename) || + (() => { + try { + return decodeURIComponent(new URL(downloadUrl).pathname.split("/").at(-1) || ""); + } catch { + return ""; + } + })(); + const mimeType = readText(raw.mime_type ?? raw.mimeType, 160); + const artifactRole = readText(raw.artifact_role ?? raw.artifactRole, 160); + const displayMode = readText(raw.display_mode ?? raw.displayMode, 80); + const color = readText(raw.color, 32); + const artifactBase = { + type: readText(raw.type, 80) || "file", + filename: filename || "artifact", + displayName: + readText(raw.display_name ?? raw.displayName) || filename || "文件", + artifactRole, + displayMode, + mimeType, + downloadUrl, + partId: readText(raw.part_id ?? raw.partId) || null, + partName: readText(raw.part_name ?? raw.partName) || null, + dimensionsMm: normalizeDimensions(raw.dimensions_mm ?? raw.dimensionsMm), + color: color || null, + }; + return { + ...artifactBase, + viewer: normalizeViewer(raw.viewer, artifactBase), + } satisfies OpenClawArtifact; +} + +export function normalizeOpenClawArtifacts( + value: unknown, + options?: { baseUrl?: string } +): OpenClawArtifact[] { + if (!Array.isArray(value)) return []; + const artifacts: OpenClawArtifact[] = []; + const seen = new Set(); + + for (const item of value.slice(0, MAX_ARTIFACTS)) { + const artifact = normalizeArtifact(item, options?.baseUrl); + if (!artifact) continue; + const key = [artifact.downloadUrl, artifact.artifactRole, artifact.filename].join("|"); + if (seen.has(key)) continue; + seen.add(key); + artifacts.push(artifact); + } + return artifacts; +} + +export function extractOpenClawArtifacts( + payload: unknown, + options?: { baseUrl?: string } +) { + const combined: unknown[] = []; + const visit = (value: unknown, depth: number) => { + if (depth > 4) return; + const record = readRecord(value); + if (Array.isArray(record.files)) combined.push(...record.files); + for (const key of ["task", "output", "result", "response", "data"] as const) { + if (record[key]) visit(record[key], depth + 1); + } + }; + visit(payload, 0); + return normalizeOpenClawArtifacts(combined, options); +} + +export function isOpenClawInteractive3DArtifact( + artifact: OpenClawArtifact +) { + const role = artifact.artifactRole.toLowerCase(); + const mimeType = artifact.mimeType.toLowerCase(); + const filename = artifact.filename.toLowerCase(); + return ( + role === "interactive_3d" || + mimeType === "model/gltf-binary" || + mimeType === "model/gltf+json" || + filename.endsWith(".glb") || + filename.endsWith(".gltf") + ); +} + +export function buildOpenClawArtifactProxyUrl(params: { + chatId: string; + messageId: string; + artifactIndex: number; + download?: boolean; +}) { + const query = new URLSearchParams({ + chatId: params.chatId, + messageId: params.messageId, + index: String(params.artifactIndex), + }); + if (params.download) query.set("download", "1"); + return `/api/canvas/clawd-chat/artifact?${query.toString()}`; +} diff --git a/lib/openclaw-chat/task-state.ts b/lib/openclaw-chat/task-state.ts index e23292594..d86e5ae98 100644 --- a/lib/openclaw-chat/task-state.ts +++ b/lib/openclaw-chat/task-state.ts @@ -1,3 +1,8 @@ +import { + normalizeOpenClawArtifacts, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; + export const CLAWD_TASK_KIND = "clawd_chat"; export type ClawdTaskStatus = @@ -18,6 +23,7 @@ export type ClawdTaskMetadata = { assistantMessageId: string; processingHint: string; progressEvents: ClawdTaskProgressPayload[]; + artifacts?: OpenClawArtifact[]; skillTagId?: string | null; skillTagName?: string | null; requestId?: string | null; @@ -75,6 +81,7 @@ export function normalizeClawdTaskMetadata( Boolean(item) && typeof item === "object" && !Array.isArray(item) ) : [], + artifacts: normalizeOpenClawArtifacts(raw.artifacts), skillTagId: raw.skillTagId ? String(raw.skillTagId) : null, skillTagName: raw.skillTagName ? String(raw.skillTagName) : null, requestId: raw.requestId ? String(raw.requestId) : null, diff --git a/lib/openclaw-chat/task-store.ts b/lib/openclaw-chat/task-store.ts index 30f609e2f..6314e025b 100644 --- a/lib/openclaw-chat/task-store.ts +++ b/lib/openclaw-chat/task-store.ts @@ -8,6 +8,7 @@ import { updateMessageById, } from "@/lib/db/queries"; import type { DBMessage } from "@/lib/db/schema"; +import type { OpenClawArtifact } from "@/lib/openclaw-chat/artifacts"; import { decryptOpenClawTaskSecret, encryptOpenClawTaskSecret, @@ -74,6 +75,7 @@ function buildTaskMetadata(params: { skillTagId?: string | null; skillTagName?: string | null; progressEvents?: ClawdTaskProgressPayload[]; + artifacts?: OpenClawArtifact[]; requestId?: string | null; error?: string | null; upstreamTask?: ClawdUpstreamTaskState | null; @@ -90,6 +92,7 @@ function buildTaskMetadata(params: { assistantMessageId: params.assistantMessageId, processingHint: params.processingHint, progressEvents: params.progressEvents || [], + artifacts: params.artifacts || [], skillTagId: params.skillTagId || null, skillTagName: params.skillTagName || null, requestId: params.requestId || null, @@ -179,6 +182,7 @@ async function updateClawdAssistantTask(params: { requestId?: string | null; error?: string | null; upstreamTask?: ClawdUpstreamTaskState; + artifacts?: OpenClawArtifact[]; }) { const rows = await getMessageById({ id: params.assistantMessageId }); const message = rows.find((row) => row.chatId === params.chatId); @@ -196,6 +200,8 @@ async function updateClawdAssistantTask(params: { requestId: params.requestId !== undefined ? params.requestId : metadata.requestId, error: params.error !== undefined ? params.error : metadata.error, + artifacts: + params.artifacts !== undefined ? params.artifacts : metadata.artifacts, upstreamTaskId: params.upstreamTask?.taskId !== undefined ? params.upstreamTask.taskId @@ -262,6 +268,7 @@ export async function markClawdTaskCompleted(params: { assistantMessageId: string; answer: string; requestId?: string | null; + artifacts?: OpenClawArtifact[]; }) { return updateClawdAssistantTask({ chatId: params.chatId, @@ -269,6 +276,7 @@ export async function markClawdTaskCompleted(params: { status: "completed", parts: [{ type: "text", text: params.answer }], requestId: params.requestId, + artifacts: params.artifacts, error: null, }); } diff --git a/lib/openclaw-chat/upstream-task-client.ts b/lib/openclaw-chat/upstream-task-client.ts index fc8c4a77d..abbd3e9b6 100644 --- a/lib/openclaw-chat/upstream-task-client.ts +++ b/lib/openclaw-chat/upstream-task-client.ts @@ -6,6 +6,10 @@ import { type ClawdUpstreamTaskState, persistClawdUpstreamTaskState, } from "@/lib/openclaw-chat/task-store"; +import { + extractOpenClawArtifacts, + type OpenClawArtifact, +} from "@/lib/openclaw-chat/artifacts"; import type { ClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; import { decryptOpenClawTaskSecret } from "@/lib/openclaw-chat/task-secret"; @@ -30,6 +34,7 @@ export type OpenClawTaskOutcome = upstreamTask: ClawdUpstreamTaskState; answer: string; requestId: string | null; + artifacts: OpenClawArtifact[]; } | { status: "failed" | "canceled"; @@ -445,6 +450,9 @@ export async function refreshOpenClawDurableTask(params: { upstreamTask, answer, requestId: getRequestId(terminalPayload), + artifacts: extractOpenClawArtifacts(terminalPayload, { + baseUrl: params.config.baseUrl, + }), }; } if (terminalStatus === "cancelled" || terminalStatus === "canceled") { diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index f2cf47ad9..db07b0e54 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ /routes\/openclaw-request-state\.test\.ts/, /routes\/openclaw-folder-attachments\.test\.ts/, /routes\/openclaw-video-attachments\.test\.ts/, + /routes\/openclaw-3d-artifacts\.test\.ts/, /routes\/hicad-embedded-performance\.test\.ts/, /routes\/hicad-manufacturing-extension\.test\.ts/, /routes\/openapi-access-key\.test\.ts/, diff --git a/tests/routes/openclaw-3d-artifacts.test.ts b/tests/routes/openclaw-3d-artifacts.test.ts new file mode 100644 index 000000000..7818584d9 --- /dev/null +++ b/tests/routes/openclaw-3d-artifacts.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; +import { + buildOpenClawArtifactProxyUrl, + extractOpenClawArtifacts, + isOpenClawInteractive3DArtifact, + normalizeOpenClawArtifacts, +} from "@/lib/openclaw-chat/artifacts"; +import { normalizeClawdTaskMetadata } from "@/lib/openclaw-chat/task-state"; + +const modelFile = { + type: "file", + filename: "wood-demo-interactive.glb", + display_name: "可旋转3D拆件模型", + artifact_role: "interactive_3d", + display_mode: "interactive", + mime_type: "model/gltf-binary", + download_url: "/files/wood-demo-interactive.glb", + viewer: { + type: "model_3d", + format: "glb", + camera_controls: true, + auto_rotate: false, + background: "#F7F9FC", + fallback_artifact_role: "cad_preview", + }, +}; + +const previewFile = { + type: "image", + filename: "wood-demo-preview.png", + artifact_role: "cad_preview", + display_mode: "inline", + mime_type: "image/png", + download_url: "https://files.example.com/wood-demo-preview.png", +}; + +test.describe("OpenClaw 3D artifacts", () => { + test("normalizes the documented GLB viewer contract", () => { + const artifacts = normalizeOpenClawArtifacts([modelFile, previewFile], { + baseUrl: "https://claw.example.com", + }); + + expect(artifacts).toHaveLength(2); + expect(artifacts[0]).toMatchObject({ + artifactRole: "interactive_3d", + displayMode: "interactive", + displayName: "可旋转3D拆件模型", + downloadUrl: "https://claw.example.com/files/wood-demo-interactive.glb", + mimeType: "model/gltf-binary", + viewer: { + type: "model_3d", + format: "glb", + cameraControls: true, + autoRotate: false, + background: "#F7F9FC", + fallbackArtifactRole: "cad_preview", + }, + }); + expect(isOpenClawInteractive3DArtifact(artifacts[0])).toBe(true); + expect(isOpenClawInteractive3DArtifact(artifacts[1])).toBe(false); + }); + + test("extracts files from durable task results without duplicating them", () => { + const artifacts = extractOpenClawArtifacts( + { + output: { files: [modelFile, previewFile] }, + task: { result: { files: [modelFile] } }, + }, + { baseUrl: "https://claw.example.com" } + ); + + expect(artifacts).toHaveLength(2); + expect(artifacts.map((artifact) => artifact.artifactRole)).toEqual([ + "interactive_3d", + "cad_preview", + ]); + }); + + test("rejects private schemes and keeps the original URL out of proxy links", () => { + expect( + normalizeOpenClawArtifacts([ + { ...modelFile, download_url: "file:///tmp/private.glb" }, + { ...modelFile, download_url: "javascript:alert(1)" }, + ]) + ).toEqual([]); + + const proxyUrl = buildOpenClawArtifactProxyUrl({ + chatId: "11111111-1111-4111-8111-111111111111", + messageId: "22222222-2222-4222-8222-222222222222", + artifactIndex: 0, + download: true, + }); + expect(proxyUrl).toContain("download=1"); + expect(proxyUrl).not.toContain("files.example.com"); + }); + + test("restores persisted artifacts with Claw task metadata", () => { + const task = normalizeClawdTaskMetadata({ + composerMode: "clawd", + source: "openclaw", + taskKind: "clawd_chat", + status: "completed", + userMessageId: "user-message-id", + assistantMessageId: "assistant-message-id", + processingHint: "", + progressEvents: [], + artifacts: [ + { + ...modelFile, + download_url: "https://files.example.com/model.glb", + }, + ], + createdAt: 100, + updatedAt: 200, + }); + + expect(task?.artifacts).toHaveLength(1); + expect(task?.artifacts?.[0].viewer?.format).toBe("glb"); + }); +}); From 02017a25314126118796da542f7a771e21b4ced6 Mon Sep 17 00:00:00 2001 From: 134 Date: Wed, 2 Sep 2026 18:06:46 +0800 Subject: [PATCH 17/17] feat(ui): add official Bloub assistant states --- app/(chat)/layout.tsx | 5 +- components/bloub-avatar.tsx | 259 +++++++++ components/canvas/CanvasChatPanel.tsx | 8 +- components/canvas/canvas-logo-loading.tsx | 48 +- components/chat-shell.tsx | 3 +- components/image-processing-loading.tsx | 18 +- components/message.tsx | 41 +- lib/bloub-official.LICENSE | 21 + lib/bloub-official/UPSTREAM.md | 10 + lib/bloub-official/cycles.ts | 225 ++++++++ lib/bloub-official/decor.ts | 285 ++++++++++ lib/bloub-official/engine.ts | 556 +++++++++++++++++++ lib/bloub-official/expressions.ts | 192 +++++++ lib/bloub-official/eyefit.ts | 455 ++++++++++++++++ lib/bloub-official/face.ts | 179 +++++++ lib/bloub-official/math.ts | 42 ++ lib/bloub-official/profiles.ts | 21 + lib/bloub-official/repere.ts | 31 ++ lib/bloub-official/shape.ts | 310 +++++++++++ lib/bloub-official/skins.ts | 147 ++++++ lib/bloub-official/states.ts | 616 ++++++++++++++++++++++ 21 files changed, 3391 insertions(+), 81 deletions(-) create mode 100644 components/bloub-avatar.tsx create mode 100644 lib/bloub-official.LICENSE create mode 100644 lib/bloub-official/UPSTREAM.md create mode 100644 lib/bloub-official/cycles.ts create mode 100644 lib/bloub-official/decor.ts create mode 100644 lib/bloub-official/engine.ts create mode 100644 lib/bloub-official/expressions.ts create mode 100644 lib/bloub-official/eyefit.ts create mode 100644 lib/bloub-official/face.ts create mode 100644 lib/bloub-official/math.ts create mode 100644 lib/bloub-official/profiles.ts create mode 100644 lib/bloub-official/repere.ts create mode 100644 lib/bloub-official/shape.ts create mode 100644 lib/bloub-official/skins.ts create mode 100644 lib/bloub-official/states.ts diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 8eca253ba..1fdb87d78 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -1,5 +1,6 @@ import { Suspense } from "react"; import { ChatShell } from "@/components/chat-shell"; +import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading"; import { DataStreamProvider } from "@/components/data-stream-provider"; import { auth } from "../(auth)/auth"; import { @@ -13,7 +14,9 @@ export const dynamic = "force-dynamic"; export default function Layout({ children }: { children: React.ReactNode }) { return ( - }> + } + > {children} diff --git a/components/bloub-avatar.tsx b/components/bloub-avatar.tsx new file mode 100644 index 000000000..26e8ec7a7 --- /dev/null +++ b/components/bloub-avatar.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { defaultCycle } from "@/lib/bloub-official/cycles"; +import { NOTIF_BLUE } from "@/lib/bloub-official/decor"; +import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine"; +import { + DEFAULT_EXPRESSION, + EXPRESSION_BY_ID, +} from "@/lib/bloub-official/expressions"; +import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere"; +import { SHAPE_BY_ID } from "@/lib/bloub-official/skins"; +import type { StateId } from "@/lib/bloub-official/states"; +import { cn } from "@/lib/utils"; + +export type BloubAvatarProps = { + animated?: boolean; + className?: string; + label?: string; + playSequence?: boolean; + size?: number; + state?: StateId; + surface?: "chat" | "page"; +}; + +export function BloubAvatar({ + animated = true, + className, + label = "AI 助手", + playSequence = false, + size = 32, + state = "idle", + surface = "chat", +}: BloubAvatarProps) { + const rawId = useId(); + const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, ""); + const maskId = `bloub-mask-${uid}`; + const engineRef = useRef(null); + const clockRef = useRef(0); + + if (!engineRef.current) { + engineRef.current = new BotEngine( + RAYON, + state, + SHAPE_BY_ID.get("squircle")?.radii ?? null, + EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null + ); + } + + const [frame, setFrame] = useState(() => + engineRef.current!.sample(0) + ); + + useEffect(() => { + if (playSequence) return; + const engine = engineRef.current; + if (!engine || engine.state === state) return; + engine.setState(state, clockRef.current); + setFrame(engine.sample(clockRef.current)); + }, [playSequence, state]); + + useEffect(() => { + const engine = engineRef.current; + if (!engine) return; + + const cycle = defaultCycle().blocks; + const firstState = playSequence + ? cycle[0]?.state ?? "idle" + : engine.state; + engine.reset(firstState, 0); + clockRef.current = 0; + setFrame(engine.sample(0)); + + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + if (!animated || reducedMotion) return; + + let animationFrame = 0; + let lastFrameAt = 0; + let blockIndex = 0; + let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY; + + const tick = (timestamp: number) => { + const delta = lastFrameAt + ? Math.min((timestamp - lastFrameAt) / 1000, 0.064) + : 0; + lastFrameAt = timestamp; + clockRef.current += delta; + + if (playSequence && cycle.length > 0) { + while (clockRef.current >= nextBlockAt) { + blockIndex = (blockIndex + 1) % cycle.length; + const nextBlock = cycle[blockIndex]!; + engine.setState(nextBlock.state, nextBlockAt); + nextBlockAt += nextBlock.duration; + } + } + + setFrame(engine.sample(clockRef.current)); + animationFrame = window.requestAnimationFrame(tick); + }; + + animationFrame = window.requestAnimationFrame(tick); + return () => window.cancelAnimationFrame(animationFrame); + }, [animated, playSequence]); + + const renderDot = ( + dot: BotFrame["dots"][number], + index: number, + layer: "back" | "front" + ) => { + const fill = dot.color ?? "currentColor"; + const common = { + fill, + opacity: + dot.color || dot.depth === undefined + ? dot.opacity + : dot.opacity * dot.depth, + }; + const key = `${layer}-${index}`; + + if (dot.d) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + + + + {frame.eyes.map((eye, index) => ( + + ))} + {frame.notch ? ( + + ) : null} + + + {frame.arcs.map((arc) => ( + + {arc.grad.stops.map((color, index) => ( + + ))} + + ))} + + + + {frame.arcs.map((arc) => ( + + ))} + + + {frame.dotsBehind ? ( + {frame.dots.map((dot, index) => renderDot(dot, index, "back"))} + ) : null} + + + + + + + + + {!frame.dotsBehind ? ( + {frame.dots.map((dot, index) => renderDot(dot, index, "front"))} + ) : null} + + {frame.notif ? ( + + ) : null} + + + {frame.arcs.map((arc) => ( + + ))} + + + ); +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 1e1901da1..aecfa2b97 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -153,6 +153,7 @@ import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-ic import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar"; import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar"; import { ImageGenerationLoading } from "@/components/image-generation-loading"; +import { BloubAvatar } from "@/components/bloub-avatar"; import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog"; import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel"; @@ -2631,7 +2632,12 @@ function CanvasAssistantTypingPlaceholder({ aria-label={label} className="inline-flex h-8 items-center gap-2 rounded-[16px] bg-[#f4f5f7] px-3.5 text-[15px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400" > - + {label} {onCancel ? (