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/(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/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/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/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/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/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 2ab6e55b3..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"; @@ -1356,7 +1357,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 = [ "打造引人注目的社交媒体视觉", "帮我把这张产品图做成高级电商海报", @@ -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 ? (