diff --git a/app/(chat)/chat/[id]/page.tsx b/app/(chat)/chat/[id]/page.tsx index 718403ab5..657b8edf4 100644 --- a/app/(chat)/chat/[id]/page.tsx +++ b/app/(chat)/chat/[id]/page.tsx @@ -1,83 +1,5 @@ -import { cookies } from "next/headers"; -import { notFound, redirect } from "next/navigation"; -import { Suspense } from "react"; +import { redirect } from "next/navigation"; -import { auth } from "@/app/(auth)/auth"; -import { Chat } from "@/components/chat"; -import { DataStreamHandler } from "@/components/data-stream-handler"; -import { DEFAULT_CHAT_MODEL } from "@/lib/ai/models"; -import { getChatById, getMessagesByChatId } from "@/lib/db/queries"; -import { convertToUIMessages } from "@/lib/utils"; - -export default function Page(props: { params: Promise<{ id: string }> }) { - return ( - }> - - - ); -} - -async function ChatPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const chat = await getChatById({ id }); - - if (!chat) { - notFound(); - } - - const session = await auth(); - - // 如果用户未登录,重定向到登录页面 - if (!session?.user) { - redirect("/login"); - } - - if (chat.visibility === "private") { - if (session.user.id !== chat.userId) { - return notFound(); - } - } - - const messagesFromDb = await getMessagesByChatId({ - id, - }); - - const uiMessages = convertToUIMessages(messagesFromDb); - - const cookieStore = await cookies(); - const chatModelFromCookie = cookieStore.get("chat-model"); - - if (!chatModelFromCookie) { - return ( - <> - - - - ); - } - - return ( - <> - - - - ); +export default function Page() { + redirect("/"); } diff --git a/app/(chat)/classic/page.tsx b/app/(chat)/classic/page.tsx deleted file mode 100644 index 63c97c5bd..000000000 --- a/app/(chat)/classic/page.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; -import { Suspense } from "react"; -import { ClassicChatStart } from "@/components/classic-chat-start"; -import { isVideoModelDescriptor } from "@/lib/ai/model-classification"; -import { isAiModelAgentAuto } from "@/lib/db/ai-model-flags"; -import { - getDefaultAiModel, - getEnabledAiModelsWithProvider, - getUserById, -} from "@/lib/db/queries"; -import { generateUUID } from "@/lib/utils"; -import { auth } from "../../(auth)/auth"; - -function isNanoBananaProModel(model: { - displayName?: string | null; - modelId?: string | null; -}) { - const displayName = String(model.displayName || "").toLowerCase(); - const modelId = String(model.modelId || "").toLowerCase(); - return ( - displayName.includes("nano banana pro") || - displayName.includes("nano-banana-pro") || - modelId.includes("nano-banana-pro") || - modelId.includes("nano_banana_pro") || - modelId.includes("nano-banana") - ); -} - -export default function ClassicPage() { - return ( - }> - - - ); -} - -async function ClassicChatPage() { - const id = generateUUID(); - const [session, cookieStore] = await Promise.all([auth(), cookies()]); - - if (!session?.user) { - redirect("/login"); - } - - const modelIdFromCookie = cookieStore.get("chat-model"); - let initialModelId = modelIdFromCookie?.value; - const currentUser = session.user.id - ? await getUserById({ id: session.user.id }).catch(() => undefined) - : undefined; - const allowHiddenModels = - session.user.type === "admin" || Boolean(currentUser?.isHighVip); - const defaultModel = await getDefaultAiModel(); - const enabledModels = await getEnabledAiModelsWithProvider(false, true); - const visibleModels = enabledModels.filter( - ({ model }) => - !isAiModelAgentAuto(model) && (allowHiddenModels || !model.isHidden) - ); - const preferredImageModels = visibleModels.filter( - ({ model, provider }) => - model.supportsImageGen && - !isVideoModelDescriptor({ - providerType: provider.type, - modelId: model.modelId, - capabilities: model.capabilities, - supportsImageGen: model.supportsImageGen, - }) - ); - const nanoBananaModel = preferredImageModels.find(({ model }) => - isNanoBananaProModel(model) - ); - const cookieModel = initialModelId - ? visibleModels.find(({ model }) => model.id === initialModelId) - : undefined; - - if (!initialModelId || !cookieModel) { - if (nanoBananaModel) { - initialModelId = nanoBananaModel.model.id; - } else if ( - defaultModel && - !isAiModelAgentAuto(defaultModel) && - (allowHiddenModels || !defaultModel.isHidden) && - preferredImageModels.some(({ model }) => model.id === defaultModel.id) - ) { - initialModelId = defaultModel.id; - } else if (preferredImageModels.length > 0) { - const preferredDefaultImageModel = preferredImageModels.find( - ({ model }) => model.isDefault - ); - initialModelId = (preferredDefaultImageModel || preferredImageModels[0]) - .model.id; - } else if ( - defaultModel && - !isAiModelAgentAuto(defaultModel) && - (allowHiddenModels || !defaultModel.isHidden) - ) { - initialModelId = defaultModel.id; - } else if (visibleModels.length > 0) { - initialModelId = visibleModels[0].model.id; - } - } - - return ( - - ); -} diff --git a/app/(chat)/layout.tsx b/app/(chat)/layout.tsx index 32da4cea5..646705092 100644 --- a/app/(chat)/layout.tsx +++ b/app/(chat)/layout.tsx @@ -1,4 +1,3 @@ -import { cookies } from "next/headers"; import { Suspense } from "react"; import { ChatShell } from "@/components/chat-shell"; import { DataStreamProvider } from "@/components/data-stream-provider"; @@ -6,7 +5,6 @@ import { auth } from "../(auth)/auth"; import { getNavLinks, getSiteSettings, - getSystemSetting, getTutorialVideos, } from "@/lib/db/queries"; @@ -21,18 +19,13 @@ export default function Layout({ children }: { children: React.ReactNode }) { } async function SidebarWrapper({ children }: { children: React.ReactNode }) { - const [session, cookieStore, siteSettings, navLinks, tutorialVideos, homeVariantSetting] = + const [session, siteSettings, navLinks, tutorialVideos] = await Promise.all([ auth(), - cookies(), getSiteSettings(), getNavLinks(), getTutorialVideos(), - getSystemSetting({ key: "home_variant" }).catch(() => undefined), ]); - const isCollapsed = cookieStore.get("sidebar_state")?.value !== "true"; - const homeVariant = - homeVariantSetting?.value === "lovart" ? "lovart" : "classic"; const settingsHref = session?.user?.type === "admin" ? "/settings" @@ -43,8 +36,6 @@ async function SidebarWrapper({ children }: { children: React.ReactNode }) { return ( undefined), getSystemSetting({ key: "home_surprise_categories" }).catch(() => undefined), ]); - const homeVariant = homeVariantSetting?.value === "lovart" ? "lovart" : "classic"; const surpriseCategories = Array.isArray(surpriseCategoriesSetting?.value) ? surpriseCategoriesSetting?.value .map((item: unknown) => String(item || "").trim()) .filter(Boolean) : ["全部", "精选"]; - if (homeVariant !== "lovart") { - redirect("/classic"); - } - return ( ): return createSettingsValueSignature(createAgentWorkflowSettingsStateFromValues(valueByKey)); } -function normalizeHomeVariant(value: unknown): "classic" | "lovart" { - return value === "lovart" ? "lovart" : "classic"; -} - function normalizeSurpriseCategories(value: unknown): string[] { if (!Array.isArray(value)) { return ["全部", "精选"]; @@ -933,7 +928,6 @@ export async function GET(request: NextRequest) { const [ settings, notificationSetting, - homeVariantSetting, surpriseCategoriesSetting, aiDetailGeneratorEnabledSetting, lovartComposerPreviewTextsSetting, @@ -975,12 +969,6 @@ export async function GET(request: NextRequest) { .where(eq(systemSettings.key, "login_notification")) .limit(1) .then(rows => rows[0]), - db - .select() - .from(systemSettings) - .where(eq(systemSettings.key, HOME_VARIANT_KEY)) - .limit(1) - .then(rows => rows[0]), db .select() .from(systemSettings) @@ -1177,7 +1165,6 @@ export async function GET(request: NextRequest) { ]); const notification = notificationSetting?.value || { enabled: false, title: "", content: "" }; - const homeVariant = normalizeHomeVariant(homeVariantSetting?.value); const surpriseCategories = normalizeSurpriseCategories(surpriseCategoriesSetting?.value); const aiDetailGenerationEnabled = Boolean(aiDetailGeneratorEnabledSetting?.value); const lovartComposerPreviewTexts = normalizeLovartComposerPreviewTexts( @@ -1335,7 +1322,6 @@ export async function GET(request: NextRequest) { lovartPromoActionText: "立即升级", lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", - homeVariant, surpriseCategories, aiDetailGenerationEnabled, lovartComposerPrefix, @@ -1358,7 +1344,6 @@ export async function GET(request: NextRequest) { lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单", lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切", notification, - homeVariant, surpriseCategories, aiDetailGenerationEnabled, lovartComposerPrefix, @@ -1836,7 +1821,6 @@ const updateSchema = z.object({ lovartPromoActionText: z.string().max(32).optional(), lovartHeroTitle: z.string().max(300).optional(), lovartHeroSubtitle: z.string().max(300).optional(), - homeVariant: z.enum(["classic", "lovart"]).optional(), surpriseCategories: z.array(z.string().trim().min(1).max(20)).max(12).optional(), lovartComposerPrefix: z.string().trim().min(1).max(40).optional(), lovartComposerPreviewTexts: z.array(z.string().trim().min(1).max(120)).max(20).optional(), @@ -1926,7 +1910,6 @@ export async function PUT(request: NextRequest) { } const data = updateSchema.parse(body); const { - homeVariant, surpriseCategories, aiDetailGenerationEnabled, lovartComposerPrefix, @@ -2138,25 +2121,6 @@ export async function PUT(request: NextRequest) { .where(eq(siteSettings.id, existing.id)) .returning(); - if (homeVariant !== undefined) { - await db - .insert(systemSettings) - .values({ - key: HOME_VARIANT_KEY, - value: homeVariant, - updatedAt: new Date(), - updatedBy: session.user.id, - }) - .onConflictDoUpdate({ - target: systemSettings.key, - set: { - value: homeVariant, - updatedAt: new Date(), - updatedBy: session.user.id, - }, - }); - } - if (surpriseCategories !== undefined) { const normalized = normalizeSurpriseCategories(surpriseCategories); await db @@ -2644,7 +2608,6 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ ...updated, - homeVariant: homeVariant ?? normalizeHomeVariant(undefined), surpriseCategories: surpriseCategories !== undefined ? normalizeSurpriseCategories(surpriseCategories) @@ -2809,25 +2772,6 @@ export async function PUT(request: NextRequest) { }) .returning(); - if (homeVariant !== undefined) { - await db - .insert(systemSettings) - .values({ - key: HOME_VARIANT_KEY, - value: homeVariant, - updatedAt: new Date(), - updatedBy: session.user.id, - }) - .onConflictDoUpdate({ - target: systemSettings.key, - set: { - value: homeVariant, - updatedAt: new Date(), - updatedBy: session.user.id, - }, - }); - } - if (surpriseCategories !== undefined) { const normalized = normalizeSurpriseCategories(surpriseCategories); await db @@ -3293,7 +3237,6 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ ...created, - homeVariant: homeVariant ?? normalizeHomeVariant(undefined), surpriseCategories: surpriseCategories !== undefined ? normalizeSurpriseCategories(surpriseCategories) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx deleted file mode 100644 index d2cc11a55..000000000 --- a/components/app-sidebar.tsx +++ /dev/null @@ -1,140 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import type { User } from "next-auth"; -import { useState } from "react"; -import { toast } from "sonner"; -import { useSWRConfig } from "swr"; -import { unstable_serialize } from "swr/infinite"; -import { PlusIcon, TrashIcon } from "@/components/icons"; -import { - getChatHistoryPaginationKey, - SidebarHistory, -} from "@/components/sidebar-history"; -import { SidebarUserNav } from "@/components/sidebar-user-nav"; -import { Button } from "@/components/ui/button"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - useSidebar, -} from "@/components/ui/sidebar"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "./ui/alert-dialog"; -import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; - -export function AppSidebar({ - user, -}: { - user: User | undefined; -}) { - const router = useRouter(); - const { setOpenMobile } = useSidebar(); - const { mutate } = useSWRConfig(); - const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false); - - const handleDeleteAll = () => { - const deletePromise = fetch("/api/history", { - method: "DELETE", - }); - - toast.promise(deletePromise, { - loading: "正在删除所有会话...", - success: () => { - mutate(unstable_serialize(getChatHistoryPaginationKey)); - router.push("/"); - setShowDeleteAllDialog(false); - return "所有会话已删除"; - }, - error: "删除会话失败,请稍后重试", - }); - }; - - return ( - <> - - - -
- {/* Logo removed */} -
-
- {user && ( - - - - - - 删除所有会话 - - - )} - - - - - - 新建会话 - - -
-
- - - - - - {user && } - - - - - - 确定删除全部会话? - - 操作不可撤销,所有会话记录将从服务器永久移除。 - - - - 取消 - - 删除所有 - - - - - - ); -} diff --git a/components/chat-header.tsx b/components/chat-header.tsx deleted file mode 100644 index 368a16e88..000000000 --- a/components/chat-header.tsx +++ /dev/null @@ -1,61 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { memo } from "react"; -import { useWindowSize } from "usehooks-ts"; -import { SidebarToggle } from "@/components/sidebar-toggle"; -import { Button } from "@/components/ui/button"; -import { PlusIcon } from "@/components/icons"; -import { useSidebar } from "@/components/ui/sidebar"; -import { VisibilitySelector, type VisibilityType } from "@/components/visibility-selector"; - -function PureChatHeader({ - chatId, - selectedVisibilityType, - isReadonly, -}: { - chatId: string; - selectedVisibilityType: VisibilityType; - isReadonly: boolean; -}) { - const router = useRouter(); - const { open } = useSidebar(); - - const { width: windowWidth } = useWindowSize(); - - return ( -
- - - {(!open || windowWidth < 768) && ( - - )} - - {!isReadonly && ( - - )} -
- ); -} - -export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => { - return ( - prevProps.chatId === nextProps.chatId && - prevProps.selectedVisibilityType === nextProps.selectedVisibilityType && - prevProps.isReadonly === nextProps.isReadonly - ); -}); diff --git a/components/chat-shell-chrome.tsx b/components/chat-shell-chrome.tsx index dcdfbf104..caf2a1c35 100644 --- a/components/chat-shell-chrome.tsx +++ b/components/chat-shell-chrome.tsx @@ -1,9 +1,7 @@ "use client"; import type { User } from "next-auth"; -import { AppSidebar } from "@/components/app-sidebar"; import { TopNavbar } from "@/components/top-navbar"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; type CustomLink = { id: string; @@ -25,7 +23,6 @@ type TutorialVideo = { export type ChatShellChromeProps = { children: React.ReactNode; user?: User; - isCollapsed: boolean; siteName: string; siteLogo?: string; siteLogoDark?: string; @@ -47,7 +44,6 @@ export type ChatShellChromeProps = { export function ChatShellChrome({ children, user, - isCollapsed, siteName, siteLogo, siteLogoDark, @@ -66,7 +62,7 @@ export function ChatShellChrome({ videoCanvasEnabled, }: ChatShellChromeProps) { return ( - + <>
- - {children} +
{children}
-
+ ); } diff --git a/components/chat-shell.tsx b/components/chat-shell.tsx index 8c6a10e05..1faa62ede 100644 --- a/components/chat-shell.tsx +++ b/components/chat-shell.tsx @@ -25,8 +25,6 @@ type TutorialVideo = { type ChatShellProps = { children: React.ReactNode; user?: User; - isCollapsed: boolean; - homeVariant: "classic" | "lovart"; siteName: string; siteLogo?: string; siteLogoDark?: string; @@ -59,8 +57,6 @@ const ChatShellChrome = dynamic( export function ChatShell({ children, user, - isCollapsed, - homeVariant, siteName, siteLogo, siteLogoDark, @@ -79,7 +75,7 @@ export function ChatShell({ videoCanvasEnabled, }: ChatShellProps) { const pathname = usePathname(); - const isLovartHome = pathname === "/" && homeVariant === "lovart"; + const isLovartHome = pathname === "/"; const isAiCadWorkspace = pathname === "/ai-cad"; const isFlowBoardWorkspace = pathname === "/flow-board"; @@ -96,7 +92,6 @@ export function ChatShell({ children, customLinks, isAdmin, - isCollapsed, productPlanningEnabled, rankingEnabled, settingsHref, diff --git a/components/chat.tsx b/components/chat.tsx deleted file mode 100644 index 97daf3a5a..000000000 --- a/components/chat.tsx +++ /dev/null @@ -1,2333 +0,0 @@ -"use client"; - -import { useChat } from "@ai-sdk/react"; -import { DefaultChatTransport } from "ai"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import useSWR, { useSWRConfig } from "swr"; -import { unstable_serialize } from "swr/infinite"; -import { ChatHeader } from "@/components/chat-header"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { useArtifactSelector } from "@/hooks/use-artifact"; -import { useAutoResume } from "@/hooks/use-auto-resume"; -import { useChatVisibility } from "@/hooks/use-chat-visibility"; -import { usePendingGeneration, type PendingGeneration } from "@/hooks/use-pending-generation"; -import type { Vote } from "@/lib/db/schema"; -import type { UserType } from "@/app/(auth)/auth"; -import { ChatSDKError } from "@/lib/errors"; -import type { Attachment, ChatMessage } from "@/lib/types"; -import type { AppUsage } from "@/lib/usage"; -import { requestLongTaskResult } from "@/lib/client/long-task"; -import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; -import { - isImageGenerationModelDescriptor, - isVideoModelDescriptor, -} from "@/lib/ai/model-classification"; -import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; -import { getFriendlyRateLimitMessage, isRateLimitError, getUserFacingRequestErrorMessage } from "@/lib/request-error"; -import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils"; -import { Artifact } from "@/components/artifact"; -import { ProtocolDebugPanel } from "@/components/protocol-debug-panel"; -import { useDataStream } from "@/components/data-stream-provider"; -import { Messages } from "@/components/messages"; -import { MultimodalInput } from "@/components/multimodal-input"; -import { getChatHistoryPaginationKey } from "@/components/sidebar-history"; -import { toast } from "sonner"; -import type { VisibilityType } from "@/components/visibility-selector"; -import type { VideoGenerationParams } from "@/components/video-params-bar"; -import type { ImageGenerationParams } from "@/components/image-params-bar"; -import type { VideoToolCapabilityContract } from "@/types/video-tool-capability"; - -type ImageGenerationPhase = "idle" | "queued" | "processing" | "finalizing" | "failed"; - -function getImageFileExtension(mimeType: string | null | undefined): string { - switch (String(mimeType || "").toLowerCase()) { - case "image/jpeg": - case "image/jpg": - return "jpg"; - case "image/webp": - return "webp"; - case "image/gif": - return "gif"; - case "image/svg+xml": - return "svg"; - case "image/png": - default: - return "png"; - } -} - -function normalizeImageParamsForProvider( - params: ImageGenerationParams, - providerType?: string -): ImageGenerationParams { - const normalized = { ...params }; - const currentCount = normalized.imageCount || 1; - - if ( - providerType === "ruxa-image" || - providerType === "openai-image" || - providerType === "stepfun-image" || - providerType === "midjourney" - ) { - normalized.imageCount = 1; - return normalized; - } - - if (providerType === "runway") { - normalized.imageCount = 1; - return normalized; - } - - if (providerType === "krea") { - normalized.imageCount = Math.min(Math.max(currentCount, 1), 4); - return normalized; - } - - if (providerType === "alibaba-image") { - normalized.imageCount = Math.min(Math.max(currentCount, 1), 6); - return normalized; - } - - if (providerType === "volcano-vision") { - normalized.imageCount = Math.min(Math.max(currentCount, 1), 3); - return normalized; - } - - normalized.imageCount = Math.min(Math.max(currentCount, 1), 2); - return normalized; -} - -function normalizeVideoParamsForProvider( - params: Partial | null | undefined, - providerType?: string, - capability?: VideoToolCapabilityContract | null -): VideoGenerationParams { - if (capability) { - const modeOptions = capability.modeOptions || []; - const modeSet = new Set(modeOptions.map((item) => item.mode)); - const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video"; - - const merged = { - mode: fallbackMode, - resolution: (capability.defaults?.resolution || "720p") as VideoGenerationParams["resolution"], - ratio: (capability.defaults?.ratio || "16:9") as VideoGenerationParams["ratio"], - duration: Number(capability.defaults?.duration) || 5, - generateAudio: capability.defaults?.generateAudio ?? false, - watermark: false, - cameraFixed: false, - cameraMove: "none" as VideoGenerationParams["cameraMove"], - fps: capability.defaults?.fps || 24, - ...(params || {}), - } as VideoGenerationParams; - - if (!modeSet.has(merged.mode)) { - merged.mode = fallbackMode as VideoGenerationParams["mode"]; - } - - const scopedOptions = getVideoToolOptionsForMode(capability, merged.mode); - const ratioOptions = scopedOptions.ratioOptions || []; - const durationOptions = scopedOptions.durationOptions || []; - const resolutionOptions = scopedOptions.resolutionOptions || []; - const ratioSet = new Set(ratioOptions); - const durationSet = new Set(durationOptions); - const resolutionSet = new Set(resolutionOptions); - const fallbackRatio = - scopedOptions.defaults?.ratio || ratioOptions[0] || capability.defaults?.ratio || "16:9"; - const fallbackDuration = - Number(scopedOptions.defaults?.duration) || - durationOptions[0] || - Number(capability.defaults?.duration) || - 5; - const fallbackResolution = - scopedOptions.defaults?.resolution || - resolutionOptions[0] || - capability.defaults?.resolution || - "720p"; - - if (!ratioSet.has(merged.ratio)) { - merged.ratio = fallbackRatio as VideoGenerationParams["ratio"]; - } - if (!durationSet.has(Number(merged.duration))) { - merged.duration = fallbackDuration; - } else { - merged.duration = Number(merged.duration) || fallbackDuration; - } - if (resolutionOptions.length > 0 && !resolutionSet.has(merged.resolution)) { - merged.resolution = fallbackResolution as VideoGenerationParams["resolution"]; - } else if (resolutionOptions.length === 0) { - merged.resolution = fallbackResolution as VideoGenerationParams["resolution"]; - } - - if (!capability.ui.showGenerateAudio) { - merged.generateAudio = false; - } - if (!capability.ui.showFps) { - merged.fps = capability.defaults?.fps || 24; - } - if (!capability.ui.showCameraMove) { - merged.cameraMove = "none"; - merged.cameraFixed = false; - } - if (!capability.ui.showReturnLastFrame) { - merged.returnLastFrame = false; - } - if (!capability.ui.showSeed) { - merged.seed = undefined; - } - - return merged; - } - - const isFalVideo = providerType === "fal-video"; - const isRuxaVideo = providerType === "ruxa-video"; - const base: VideoGenerationParams = { - mode: isFalVideo || isRuxaVideo ? "first-frame" : "text-to-video", - resolution: "720p", - ratio: "16:9", - duration: 5, - generateAudio: true, - watermark: false, - cameraFixed: false, - cameraMove: "none", - fps: 24, - }; - - const merged = { ...base, ...(params || {}) }; - - if (isFalVideo) { - if (merged.mode !== "first-frame" && merged.mode !== "first-last-frame") { - merged.mode = "first-frame"; - } - - if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) { - merged.ratio = "16:9"; - } - - merged.duration = Math.max(3, Math.min(15, Math.round(Number(merged.duration) || 5))); - merged.generateAudio = merged.generateAudio ?? true; - return merged; - } - - if (isRuxaVideo) { - merged.mode = "first-frame"; - if (!["16:9", "9:16", "1:1"].includes(merged.ratio)) { - merged.ratio = "16:9"; - } - merged.duration = merged.duration >= 8 ? 10 : 5; - merged.generateAudio = false; - merged.cameraMove = "none"; - merged.cameraFixed = false; - merged.fps = 24; - return merged; - } - - if (!["text-to-video", "first-frame", "first-last-frame", "reference-images"].includes(merged.mode)) { - merged.mode = "text-to-video"; - } - - if (!["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"].includes(merged.ratio)) { - merged.ratio = "16:9"; - } - - merged.duration = merged.duration >= 8 ? 10 : 5; - return merged; -} - -function estimateTypewriterDurationMs(text: string): number { - const length = Array.from(String(text || "")).length; - return Math.min(9000, Math.max(1200, length * 22)); -} - -function extractDisplayTextFromParts(parts?: Array): string { - if (!Array.isArray(parts) || parts.length === 0) return ""; - return parts - .filter((part) => part?.type === "text" && typeof part.text === "string") - .map((part) => - String(part.text) - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .trim() - ) - .join("\n") - .trim(); -} - -function sortMessagesByCreatedAt(messages: ChatMessage[]): ChatMessage[] { - return [...messages].sort((a, b) => { - const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0; - const timeB = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0; - return timeA - timeB; - }); -} - -function hasImageMarkdown(parts?: Array) { - if (!parts || parts.length === 0) return false; - return parts.some((part) => { - if (!part) return false; - - if ( - part?.type === "text" && - typeof part.text === "string" && - (part.text.includes("![thumb|") || - part.text.includes("![thumb|||") || - part.text.includes("![Generated Image")) - ) { - return true; - } - - if (part?.type === "image" && typeof part.url === "string" && part.url.length > 0) { - return true; - } - - if ( - part?.type === "file" && - typeof part.url === "string" && - ((typeof part.mediaType === "string" && part.mediaType.startsWith("image/")) || - /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(part.url)) - ) { - return true; - } - - return false; - }); -} - -function getMessageTextLength(message?: ChatMessage | null) { - if (!message?.parts || message.parts.length === 0) return 0; - return message.parts - .filter((part: any) => part?.type === "text" && typeof part.text === "string") - .map((part: any) => part.text.trim()) - .join("\n") - .length; -} - -function hasLikelyGenerationErrorMessage(message?: ChatMessage | null) { - if (!message?.parts || message.parts.length === 0) return false; - const text = message.parts - .filter((part: any) => part?.type === "text" && typeof part.text === "string") - .map((part: any) => part.text) - .join("\n") - .trim(); - if (!text) return false; - return /失败|超时|重试|error|failed/i.test(text); -} - -function getAssistantMessagesAfterTargetUser( - dbMessages: ChatMessage[], - targetUserMessageId?: string | null, -) { - if (!Array.isArray(dbMessages) || dbMessages.length === 0) return [] as ChatMessage[]; - - let targetUserIndex = -1; - - if (targetUserMessageId) { - targetUserIndex = dbMessages.findIndex( - (message) => message.role === "user" && message.id === targetUserMessageId, - ); - } - - if (targetUserIndex < 0) { - targetUserIndex = dbMessages.findLastIndex((message) => message.role === "user"); - } - - if (targetUserIndex < 0) { - return dbMessages.filter((message) => message.role === "assistant"); - } - - return dbMessages - .slice(targetUserIndex + 1) - .filter((message) => message.role === "assistant"); -} - -export function Chat({ - id, - initialMessages, - initialChatModel, - initialVisibilityType, - isReadonly, - autoResume, - initialLastContext, - userType, -}: { - id: string; - initialMessages: ChatMessage[]; - initialChatModel: string; - initialVisibilityType: VisibilityType; - isReadonly: boolean; - autoResume: boolean; - initialLastContext?: AppUsage; - userType?: UserType; -}) { - const router = useRouter(); - const searchParams = useSearchParams(); - const protocolDebugEnabled = searchParams.get("debugProtocol") === "1"; - const [protocolDebugEvents, setProtocolDebugEvents] = useState< - Array<{ id: string; at: number; payload: Record }> - >([]); - - useEffect(() => { - setProtocolDebugEvents([]); - }, [id]); - - const { visibilityType } = useChatVisibility({ - chatId: id, - initialVisibilityType, - }); - - // 🆕 持久化生成任务状态(解决用户离开再返回时加载状态丢失) - const { pendingGeneration, startGeneration, completeGeneration } = usePendingGeneration(id); - const pendingGenerationRef = useRef(pendingGeneration); - - // 🆕 标记是否已经尝试刷新过(避免无限刷新) - const hasTriedRefreshRef = useRef(false); - - - // 🆕 跟踪是否正在生成图片(用于占位图显示) - const [isGeneratingImageFlag, setIsGeneratingImageFlag] = useState(false); - const [imageGenerationPhase, setImageGenerationPhase] = useState("idle"); - const [imageGenerationStartedAt, setImageGenerationStartedAt] = useState(null); - const imageGenerationPhaseRef = useRef("idle"); - const imagePlaceholderTimerRef = useRef | null>(null); - const latestAssistantDisplayTextRef = useRef(""); - const startedGenerationMessageIdRef = useRef(null); - const lastRateLimitToastAtRef = useRef(0); - - // 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突 - const [appendedMessages, setAppendedMessages] = useState([]); - - // 🆕 当检测到恢复的 pendingGeneration 时,刷新页面获取最新消息 - useEffect(() => { - if (pendingGeneration && !hasTriedRefreshRef.current) { - // 检查最后一条消息是否已经是 AI 回复 - const lastMessage = initialMessages.length > 0 ? initialMessages[initialMessages.length - 1] : null; - - if (lastMessage?.role === "assistant") { - // 最后一条已经是 AI 回复,说明生成已完成,清除 pending 状态 - console.log("[Chat] 🎉 检测到生成已完成(最后消息是 AI 回复),清除 pending 状态"); - completeGeneration(); - } else if (lastMessage?.role === "user") { - // 最后一条是用户消息,可能还在生成中,或者已经完成但页面数据过期 - // 标记已尝试刷新,然后刷新页面获取最新数据 - hasTriedRefreshRef.current = true; - console.log("[Chat] 🔄 检测到恢复的 pending 状态,刷新页面获取最新消息..."); - - // 延迟一小段时间再刷新,避免立即刷新 - setTimeout(() => { - router.refresh(); - }, 500); - } - } - }, [pendingGeneration, initialMessages, completeGeneration, router]); - - useEffect(() => { - pendingGenerationRef.current = pendingGeneration; - }, [pendingGeneration]); - - const updateImageGenerationPhase = useCallback((nextPhase: ImageGenerationPhase) => { - imageGenerationPhaseRef.current = nextPhase; - setImageGenerationPhase(nextPhase); - - if (nextPhase === "idle" || nextPhase === "failed") { - setIsGeneratingImageFlag(false); - setImageGenerationStartedAt(null); - return; - } - - setIsGeneratingImageFlag(true); - setImageGenerationStartedAt((previous) => previous ?? Date.now()); - }, []); - - // 清理占位图定时器 - useEffect(() => { - return () => { - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - }; - }, []); - - const { mutate } = useSWRConfig(); - - // Handle browser back/forward navigation - useEffect(() => { - const handlePopState = () => { - // When user navigates back/forward, refresh to sync with URL - router.refresh(); - }; - - window.addEventListener("popstate", handlePopState); - return () => window.removeEventListener("popstate", handlePopState); - }, [router]); - const { setDataStream } = useDataStream(); - - const [input, setInput] = useState(""); - const [usage, setUsage] = useState(initialLastContext); - const [showCreditCardAlert, setShowCreditCardAlert] = useState(false); - const [insufficientPointsState, setInsufficientPointsState] = useState<{ - balance: number; - expectedCost: number; - unitCost: number; - imageCount: number; - } | null>(null); - const [currentModelId, setCurrentModelId] = useState(initialChatModel); - // 图片处理状态,防止重复点击 - const [isProcessingEnhance, setIsProcessingEnhance] = useState(false); - const [isProcessingRmbg, setIsProcessingRmbg] = useState(false); - const [isProcessingExtend, setIsProcessingExtend] = useState(false); - const [isProcessingAngleEdit, setIsProcessingAngleEdit] = useState(false); - const [isProcessingWatermarkRemove, setIsProcessingWatermarkRemove] = useState(false); - const currentModelIdRef = useRef(currentModelId); - - // 🆕 视频参数状态 - const [videoParams, setVideoParams] = useState(null); - - // 🆕 图片参数状态(默认值:auto比例,2K分辨率) - const [imageParams, setImageParams] = useState({ - aspectRatio: "auto", - imageSize: "2K" - }); - - // 🆕 从 localStorage 读取保存的图片参数 - useEffect(() => { - try { - const saved = localStorage.getItem("gemini-image-params"); - if (saved) { - const parsed = JSON.parse(saved); - // 确保所有字段都有值,避免传递 undefined - setImageParams({ - aspectRatio: "auto", - imageSize: "2K", - imageCount: 1, - ...parsed, - }); - } - } catch (e) { - console.error("Failed to load image params:", e); - } - }, []); - - // 🆕 保存图片参数到 localStorage - const handleImageParamsChange = (params: ImageGenerationParams) => { - const normalized = normalizeImageParamsForProvider(params, currentModel?.providerType); - setImageParams(normalized); - try { - localStorage.setItem("gemini-image-params", JSON.stringify(normalized)); - } catch (e) { - console.error("Failed to save image params:", e); - } - console.log("[Chat] Image params updated:", normalized); - }; - - // 🆕 获取模型列表以检查是否是视频模型/图片模型 - const { data: modelsData } = useSWR<{ - models: Array<{ - id: string; - modelId: string; - displayName: string; - providerType: string; - supportsVision?: boolean; - supportsImageGen?: boolean; - capabilities?: any; - videoToolCapability?: VideoToolCapabilityContract | null; - imageGenConfig?: { - maxImages?: number; - supportsSize?: boolean; - supports1K?: boolean; - supports2K?: boolean; - supports4K?: boolean; - supportsPromptExtend?: boolean; - supportsNegativePrompt?: boolean; - supportsWatermark?: boolean; - supportsSeed?: boolean; - creditCostPerImage?: number; - creditCostMultiplierBySize?: Record; - creditDiscountRate?: number; - creditCostPerVideo?: number; - videoDurationOptions?: number[]; - creditCostPerVideoByDuration?: Record; - creditCostPerVideoByResolution?: Record; - } | null; - }>; - }>("/api/models", fetcher); - - const currentModel = modelsData?.models?.find(m => m.id === currentModelId); - const isVideoModel = isVideoModelDescriptor({ - providerType: currentModel?.providerType, - modelId: currentModel?.modelId, - capabilities: currentModel?.capabilities, - supportsImageGen: currentModel?.supportsImageGen, - }); - const isImageModel = isImageGenerationModelDescriptor({ - providerType: currentModel?.providerType, - modelId: currentModel?.modelId, - capabilities: currentModel?.capabilities, - supportsImageGen: currentModel?.supportsImageGen, - }); - - // 🔍 调试日志 - console.log('[Chat] Render - currentModelId:', currentModelId); - console.log('[Chat] Render - modelsData loaded:', !!modelsData); - console.log('[Chat] Render - currentModel:', currentModel?.displayName, { - id: currentModel?.id, - supportsImageGen: currentModel?.supportsImageGen, - providerType: currentModel?.providerType, - isImageModel, - imageParams - }); - - // 使用 ref 保存视频/图片相关状态,以便在 prepareSendMessagesRequest 中访问最新值 - const isVideoModelRef = useRef(isVideoModel); - const videoParamsRef = useRef(videoParams); - const isImageModelRef = useRef(isImageModel); - const imageParamsRef = useRef(imageParams); - - useEffect(() => { - isVideoModelRef.current = isVideoModel; - videoParamsRef.current = videoParams; - isImageModelRef.current = isImageModel; - imageParamsRef.current = imageParams; - console.log("[Chat] Refs updated - imageParams:", imageParams); - }, [isVideoModel, videoParams, isImageModel, imageParams]); - - // 调试日志 - console.log("[Chat Debug] currentModelId:", currentModelId); - console.log("[Chat Debug] currentModel:", currentModel); - console.log("[Chat Debug] isVideoModel:", isVideoModel); - - // 🆕 当切换到视频模型时,自动初始化 videoParams - useEffect(() => { - if (!isVideoModel) { - if (!videoParams) return; - setVideoParams(null); - console.log("[Chat] 已切换到非视频模型,清空视频参数"); - return; - } - - const normalized = normalizeVideoParamsForProvider( - videoParams, - currentModel?.providerType, - currentModel?.videoToolCapability - ); - if (!videoParams || JSON.stringify(videoParams) !== JSON.stringify(normalized)) { - setVideoParams(normalized); - console.log("[Chat] 视频参数已按当前模型规范化:", normalized); - } - }, [isVideoModel, videoParams, currentModel?.providerType, currentModel?.videoToolCapability]); - - // 🆕 当切换到图片模型时,强制更新 imageParams(确保组件重新渲染) - useEffect(() => { - if (isImageModel) { - setImageParams((prev) => { - const next = normalizeImageParamsForProvider(prev, currentModel?.providerType); - if (JSON.stringify(prev) === JSON.stringify(next)) return prev; - try { - localStorage.setItem("gemini-image-params", JSON.stringify(next)); - } catch {} - return next; - }); - console.log("[Chat] 图片模型已激活,参数已按当前模型规范化"); - } - }, [isImageModel, currentModel?.providerType]); - - useEffect(() => { - currentModelIdRef.current = currentModelId; - // 调试:在控制台显示当前模型切换 - console.log(`[Chat] Model changed to: ${currentModelId}`); - }, [currentModelId]); - - const { - messages, - setMessages, - sendMessage, - status, - stop, - regenerate, - resumeStream, - } = useChat({ - id, - messages: initialMessages, - experimental_throttle: 100, - generateId: generateUUID, - transport: new DefaultChatTransport({ - api: "/api/chat", - fetch: fetchWithErrorHandlers, - prepareSendMessagesRequest(request) { - latestAssistantDisplayTextRef.current = ""; - console.log("[Chat] Preparing request, videoParams:", videoParamsRef.current); - console.log("[Chat] Preparing request, imageParams:", imageParamsRef.current); - console.log("[Chat] Is video model:", isVideoModelRef.current); - console.log("[Chat] Is image model:", isImageModelRef.current); - return { - body: { - id: request.id, - message: request.messages.at(-1), - selectedChatModel: currentModelIdRef.current, - selectedVisibilityType: visibilityType, - debugProtocol: protocolDebugEnabled, - videoParams: videoParamsRef.current || undefined, // 🆕 传递视频参数(使用ref获取最新值) - imageParams: imageParamsRef.current || undefined, // 🆕 传递图片参数(使用ref获取最新值) - ...request.body, - }, - }; - }, - }), - onData: (dataPart: any) => { - setDataStream((ds) => (ds ? [...ds, dataPart] : [])); - if (dataPart.type === "data-usage") { - setUsage(dataPart.data); - } - - // 🆕 处理后端返回的自定义消息(用于图片/视频生成的非流式响应) - if (dataPart.type === "data-appendMessage") { - try { - const messageData = typeof dataPart.data === 'string' - ? JSON.parse(dataPart.data) - : dataPart.data; - - console.log("[Chat] Received appendMessage:", messageData); - - const hasImageGenStartMarker = messageData.parts?.some( - (part: any) => part.type === "text" && part.text?.includes("") - ); - const hasImageGenEndMarker = messageData.parts?.some( - (part: any) => part.type === "text" && part.text?.includes("") - ); - const hasLegacyGeneratingMarker = messageData.parts?.some( - (part: any) => part.type === "text" && part.text?.includes("") - ); - const isMarkerOnlyMessage = messageData.parts?.every((part: any) => { - if (part.type !== "text" || !part.text) return false; - const cleanedText = part.text - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .trim(); - return cleanedText.length === 0; - }); - const displayTextFromMessage = extractDisplayTextFromParts(messageData.parts); - if (displayTextFromMessage) { - latestAssistantDisplayTextRef.current = displayTextFromMessage; - } - - if (hasImageGenEndMarker && isMarkerOnlyMessage) { - console.log("[Chat] 🖼️ Image generation entered finalizing stage"); - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - updateImageGenerationPhase("finalizing"); - return; // 不渲染标记消息 - } - - if (hasImageGenStartMarker && isMarkerOnlyMessage) { - const delayText = latestAssistantDisplayTextRef.current; - const delayMs = delayText - ? estimateTypewriterDurationMs(delayText) + 240 - : 1200; - console.log("[Chat] 🖼️ Image generation start marker received, delay processing placeholder:", delayMs); - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - updateImageGenerationPhase("queued"); - imagePlaceholderTimerRef.current = setTimeout(() => { - updateImageGenerationPhase("processing"); - imagePlaceholderTimerRef.current = null; - }, delayMs); - return; // 不渲染标记消息 - } - - if (hasLegacyGeneratingMarker) { - console.log("[Chat] 🖼️ Detected legacy marker, will show placeholder after delay"); - const displayText = displayTextFromMessage; - const hasDisplayText = displayText.length > 0; - const placeholderDelayMs = hasDisplayText - ? estimateTypewriterDurationMs(displayText) + 240 - : 3000; - console.log("[Chat] Placeholder delay:", placeholderDelayMs); - if (!hasDisplayText) { - console.log("[Chat] ⚠️ Marker message has no display text, skip image placeholder timer"); - return; - } - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - imagePlaceholderTimerRef.current = setTimeout(() => { - console.log("[Chat] 🖼️ Now showing placeholder after text reveal"); - updateImageGenerationPhase("processing"); - imagePlaceholderTimerRef.current = null; - }, placeholderDelayMs); - } - - // 🆕 检测是否包含图片(生成完成) - const messageText = messageData.parts?.[0]?.text || ""; - console.log("[Chat] 📝 Message text preview:", messageText.substring(0, 100)); - - const hasImage = hasImageMarkdown(messageData.parts); - console.log("[Chat] 🔍 hasImage check:", hasImage); - - if (hasImage) { - console.log("[Chat] 🎉 Image generated, hiding placeholder"); - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - updateImageGenerationPhase("idle"); - completeGeneration(); - } - - // 🆕 将消息添加到 appendedMessages 状态,而不是直接修改 useChat 的 messages - // 这样可以避免与 useChat 内部状态冲突导致消息丢失 - const newMessage: ChatMessage = { - id: messageData.id, - role: messageData.role, - content: messageData.parts?.[0]?.text || "", - createdAt: new Date(), - parts: messageData.parts || [], - attachments: Array.isArray(messageData.attachments) ? messageData.attachments : [], - } as ChatMessage; - - setAppendedMessages((prev) => { - // 检查消息是否已存在 - if (prev.some(m => m.id === messageData.id)) { - console.log("[Chat] Appended message already exists, skipping:", messageData.id); - return prev; - } - console.log("[Chat] Adding to appendedMessages:", messageData.id); - return [...prev, newMessage]; - }); - - const messageTextForErrorCheck = messageData.parts - ?.filter((part: any) => part.type === "text" && typeof part.text === "string") - .map((part: any) => part.text) - .join("\n") - .trim(); - const isLikelyGenerationError = - !hasImage && - !!messageTextForErrorCheck && - /失败|超时|重试|error|failed/i.test(messageTextForErrorCheck) && - imageGenerationPhaseRef.current !== "idle"; - if (isLikelyGenerationError) { - console.log("[Chat] ❌ Generation error detected, reset loading state"); - updateImageGenerationPhase("failed"); - completeGeneration(); - } - } catch (e) { - console.error("[Chat] Failed to parse data-appendMessage:", e); - } - } - - if (dataPart.type === "data-protocolDebug") { - try { - const payload = - typeof dataPart.data === "string" ? JSON.parse(dataPart.data) : dataPart.data; - if (!payload || typeof payload !== "object") return; - setProtocolDebugEvents((prev) => [ - ...prev.slice(-59), - { - id: generateUUID(), - at: Date.now(), - payload, - }, - ]); - } catch (e) { - console.error("[Chat] Failed to parse data-protocolDebug:", e); - } - } - }, - onFinish: async () => { - mutate(unstable_serialize(getChatHistoryPaginationKey)); - console.log("[Chat] onFinish called"); - const pendingSnapshot = pendingGenerationRef.current; - const keepPendingUntilDbSync = - pendingSnapshot?.modelType === "gemini" || - pendingSnapshot?.modelType === "volcano-vision" || - pendingSnapshot?.modelType === "alibaba-image"; - if (!keepPendingUntilDbSync) { - completeGeneration(); - } - - // 🆕 从数据库重新加载消息,确保 data-appendMessage 的消息正确显示 - // 这是根治方案 B:后端已保存消息到数据库,前端从数据库重新加载 - try { - const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => { - setMessages((prev) => { - const mergedMap = new Map(); - prev.forEach((message) => mergedMap.set(message.id, message)); - dbMessages.forEach((message) => mergedMap.set(message.id, message)); - return sortMessagesByCreatedAt(Array.from(mergedMap.values())); - }); - - const persistedIds = new Set(dbMessages.map((message) => message.id)); - setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id))); - }; - - const reloadMessagesOnce = async () => { - const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, { - cache: "no-store", - }); - if (!response.ok) return { hasImage: false, hasError: false }; - - const data = await response.json(); - if (!data.messages || !Array.isArray(data.messages)) { - return { hasImage: false, hasError: false }; - } - - const dbMessages = data.messages as ChatMessage[]; - console.log("[Chat] Reloading messages from database:", dbMessages.length); - mergeMessagesFromDatabase(dbMessages); - - const targetUserMessageId = - pendingSnapshot?.userMessageId || startedGenerationMessageIdRef.current; - const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser( - dbMessages, - targetUserMessageId, - ); - const hasPersistedImage = assistantAfterTargetUser.some((message) => - hasImageMarkdown((message as any).parts), - ); - const hasPersistedError = assistantAfterTargetUser.some((message) => - hasLikelyGenerationErrorMessage(message), - ); - return { hasImage: hasPersistedImage, hasError: hasPersistedError }; - }; - - // 延迟一小段时间确保数据库写入完成 - await new Promise((resolve) => setTimeout(resolve, 500)); - - let { hasImage, hasError } = await reloadMessagesOnce(); - if ( - !hasImage && - !hasError && - (imageGenerationPhaseRef.current === "queued" || - imageGenerationPhaseRef.current === "processing" || - imageGenerationPhaseRef.current === "finalizing" || - keepPendingUntilDbSync) - ) { - for (let attempt = 1; attempt <= 6; attempt++) { - await new Promise((resolve) => setTimeout(resolve, 600)); - const result = await reloadMessagesOnce(); - hasImage = result.hasImage; - hasError = result.hasError; - if (hasImage || hasError) break; - } - } - - if (hasImage) { - updateImageGenerationPhase("idle"); - completeGeneration(); - } else if (hasError) { - updateImageGenerationPhase("failed"); - completeGeneration(); - } else if ( - imageGenerationPhaseRef.current !== "idle" && - imageGenerationPhaseRef.current !== "failed" - ) { - // 保持 pending,让轮询继续从数据库拉取结果,避免前端“空白直到刷新” - console.log("[Chat] Waiting for persisted result, keep pending state"); - } - } catch (error) { - console.error("[Chat] Failed to reload messages:", error); - } finally { - // 兜底:即使消息重载失败,也不能让占位图卡死在处理中状态 - if ( - imageGenerationPhaseRef.current === "queued" || - imageGenerationPhaseRef.current === "processing" || - imageGenerationPhaseRef.current === "finalizing" - ) { - if (!keepPendingUntilDbSync) { - updateImageGenerationPhase("idle"); - } - } - } - }, - onError: (error) => { - if (imagePlaceholderTimerRef.current) { - clearTimeout(imagePlaceholderTimerRef.current); - imagePlaceholderTimerRef.current = null; - } - updateImageGenerationPhase("failed"); - completeGeneration(); - - const fallbackMessage = error instanceof Error ? error.message : "请求失败"; - const responseData = (error as any)?.data; - const isInsufficientPoints = - responseData?.error === "insufficient_points" || - /积分不足/.test(fallbackMessage); - - if (isInsufficientPoints) { - const balance = Math.max(0, Number(responseData?.balance ?? 0)); - const expectedCost = Math.max(0, Number(responseData?.expectedCost ?? 0)); - const unitCost = Math.max(0, Number(responseData?.unitCost ?? 0)); - const imageCount = - unitCost > 0 && expectedCost > 0 - ? Math.max(1, Math.round(expectedCost / unitCost)) - : Math.max(1, Math.round(Number(imageParamsRef.current?.imageCount || 1))); - setInsufficientPointsState({ - balance, - expectedCost, - unitCost, - imageCount, - }); - } - - const normalizedErrorMessage = getUserFacingRequestErrorMessage( - { - ...(error instanceof Error ? error : {}), - data: responseData, - message: fallbackMessage, - }, - "请求失败,请稍后重试", - { - rateLimitMessage: getFriendlyRateLimitMessage(), - } - ); - - if (error instanceof ChatSDKError) { - // Check if it's a credit card error - if ( - error.message?.includes("AI Gateway requires a valid credit card") - ) { - setShowCreditCardAlert(true); - } else { - toast.error(normalizedErrorMessage); - } - } else { - if (isRateLimitError({ ...(error as any), data: responseData, message: fallbackMessage })) { - const now = Date.now(); - if (now - lastRateLimitToastAtRef.current < 4000) { - return; - } - lastRateLimitToastAtRef.current = now; - } - toast.error(normalizedErrorMessage); - } - }, - }); - - useEffect(() => { - const isImagePending = - pendingGeneration?.modelType === "gemini" || - pendingGeneration?.modelType === "volcano-vision" || - pendingGeneration?.modelType === "alibaba-image"; - if (!pendingGeneration || !isImagePending) return; - - let cancelled = false; - const maxDurationMs = 20 * 60 * 1000; - - const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => { - setMessages((prev) => { - const mergedMap = new Map(); - prev.forEach((message) => mergedMap.set(message.id, message)); - dbMessages.forEach((message) => mergedMap.set(message.id, message)); - return sortMessagesByCreatedAt(Array.from(mergedMap.values())); - }); - - const persistedIds = new Set(dbMessages.map((message) => message.id)); - setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id))); - }; - - const pollMessages = async () => { - if (cancelled) return; - - if (Date.now() - pendingGeneration.timestamp > maxDurationMs) { - console.warn("[Chat] Polling timeout for pending generation"); - completeGeneration(); - if ( - imageGenerationPhaseRef.current === "queued" || - imageGenerationPhaseRef.current === "processing" || - imageGenerationPhaseRef.current === "finalizing" - ) { - updateImageGenerationPhase("failed"); - } - return; - } - - try { - const response = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, { - cache: "no-store", - }); - if (!response.ok) return; - - const data = await response.json(); - if (!data.messages || !Array.isArray(data.messages)) return; - - const dbMessages = data.messages as ChatMessage[]; - mergeMessagesFromDatabase(dbMessages); - - const assistantAfterTargetUser = getAssistantMessagesAfterTargetUser( - dbMessages, - pendingGeneration.userMessageId, - ); - if (assistantAfterTargetUser.length === 0) return; - - const hasImage = assistantAfterTargetUser.some((message) => - hasImageMarkdown((message as any).parts), - ); - const hasError = assistantAfterTargetUser.some((message) => - hasLikelyGenerationErrorMessage(message) - ); - if (!hasImage && !hasError) return; - - console.log("[Chat] Polling detected finished assistant response"); - completeGeneration(); - if (hasImage) { - updateImageGenerationPhase("idle"); - } else if ( - imageGenerationPhaseRef.current === "queued" || - imageGenerationPhaseRef.current === "processing" || - imageGenerationPhaseRef.current === "finalizing" - ) { - updateImageGenerationPhase("failed"); - } - } catch (error) { - console.error("[Chat] Polling messages failed:", error); - } - }; - - pollMessages(); - const timer = setInterval(pollMessages, 800); - return () => { - cancelled = true; - clearInterval(timer); - }; - }, [pendingGeneration, id, setMessages, completeGeneration, updateImageGenerationPhase]); - - // 🆕 保存 initialMessages 的引用,防止历史消息丢失 - // 当 useChat 内部状态重置时,我们仍然有初始消息 - const initialMessagesRef = useRef(initialMessages); - useEffect(() => { - // 只在初始化时设置,不在后续更新 - if (initialMessagesRef.current.length === 0 && initialMessages.length > 0) { - initialMessagesRef.current = initialMessages; - } - }, [initialMessages]); - - // 调试:监控聊天状态变化 & 持久化生成状态 - // 使用 ref 来访问最新的 modelsData,避免依赖数组问题 - const modelsDataRef = useRef(modelsData); - useEffect(() => { - modelsDataRef.current = modelsData; - }, [modelsData]); - - useEffect(() => { - console.log(`[Chat] Status changed: ${status}`); - - if (status !== "submitted") { - startedGenerationMessageIdRef.current = null; - } - - // 🆕 当状态变为 submitted 时,记录生成任务 - if (status === "submitted") { - const latestMessage = messages.length > 0 ? messages[messages.length - 1] : null; - const latestUserMessageId = latestMessage?.role === "user" ? latestMessage.id : null; - if (latestUserMessageId && startedGenerationMessageIdRef.current === latestUserMessageId) { - return; - } - - const latestUserHasImageInput = - latestMessage?.role === "user" && - Array.isArray(latestMessage.parts) && - latestMessage.parts.some((part: any) => { - if (!part) return false; - if (part.type === "file" || part.type === "image" || part.type === "image_url") return true; - if (part.type === "text" && typeof part.text === "string") { - return /\!\[thumb\||\!\[Generated Image/i.test(part.text); - } - return false; - }); - - // 判断模型类型 - 使用 ref 获取最新的 modelsData - const models = modelsDataRef.current?.models; - const model = models?.find((m: any) => m.id === currentModelId); - - let modelType: PendingGeneration["modelType"] = "other"; - // 🆕 gemini-image 和 huanke-gemini 都视为 gemini 类型 - if ( - model?.providerType === "gemini" || - model?.providerType === "gemini-image" || - model?.providerType === "grsai-gpt-image" || - model?.providerType === "huanke-gemini" - ) modelType = "gemini"; - else if (model?.providerType === "volcano-vision") modelType = "volcano-vision"; - else if ( - isVideoModelDescriptor({ - providerType: model?.providerType, - modelId: model?.modelId, - capabilities: model?.capabilities, - supportsImageGen: model?.supportsImageGen, - }) - ) modelType = model?.providerType === "qianwen-video" - ? "qianwen-video" - : model?.providerType === "fal-video" - ? "fal-video" - : model?.providerType === "ruxa-video" - ? "ruxa-video" - : model?.providerType === "krea" - ? "krea-video" - : "volcano-video"; - else if ( - isImageGenerationModelDescriptor({ - providerType: model?.providerType, - modelId: model?.modelId, - capabilities: model?.capabilities, - supportsImageGen: model?.supportsImageGen, - }) - ) modelType = "alibaba-image"; - - // 智能自动模式下,如果当前轮输入包含图片,默认按图片生成链路跟踪 - if (currentModelId === "auto" && latestUserHasImageInput) { - modelType = "gemini"; - } - - console.log(`[Chat] 🚀 记录生成任务: modelType=${modelType}, modelId=${currentModelId}`); - startGeneration(currentModelId, modelType, latestUserMessageId || undefined); - if (latestUserMessageId) { - startedGenerationMessageIdRef.current = latestUserMessageId; - } - - if (modelType === "gemini" || modelType === "volcano-vision" || modelType === "alibaba-image") { - updateImageGenerationPhase("queued"); - } - } - }, [status, currentModelId, messages, startGeneration, updateImageGenerationPhase]); - - const query = searchParams.get("query"); - const editImageUrl = searchParams.get("editImage"); - - const [hasAppendedQuery, setHasAppendedQuery] = useState(false); - const [hasLoadedEditImage, setHasLoadedEditImage] = useState(false); - - useEffect(() => { - if (query && !hasAppendedQuery) { - sendMessage({ - role: "user" as const, - parts: [{ type: "text", text: query }], - }); - - setHasAppendedQuery(true); - window.history.replaceState({}, "", `/chat/${id}`); - } - }, [query, sendMessage, hasAppendedQuery, id]); - - // 🆕 处理从Gallery跳转过来的图片编辑请求 - useEffect(() => { - if (editImageUrl && !hasLoadedEditImage) { - const fileName = editImageUrl.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`; - - // 推断图片类型 - let contentType = "image/jpeg"; - if (fileName.toLowerCase().endsWith(".png")) contentType = "image/png"; - if (fileName.toLowerCase().endsWith(".webp")) contentType = "image/webp"; - if (fileName.toLowerCase().endsWith(".gif")) contentType = "image/gif"; - - setAttachments([{ - url: decodeURIComponent(editImageUrl), - name: fileName, - contentType: contentType, - }]); - - // 自动填充提示词 - setInput("请帮我编辑这张图片:"); - - setHasLoadedEditImage(true); - window.history.replaceState({}, "", `/chat/${id}`); - } - }, [editImageUrl, hasLoadedEditImage, id, setInput]); - - // 🆕 处理从导航栏图片编辑器跳转过来的图片编辑请求 - const [hasLoadedPendingImage, setHasLoadedPendingImage] = useState(false); - useEffect(() => { - if (!hasLoadedPendingImage) { - try { - // 安全地访问sessionStorage(某些浏览器设置可能禁止访问) - let pendingImage: string | null = null; - try { - pendingImage = sessionStorage.getItem("pending-edited-image"); - } catch (storageError) { - // sessionStorage不可用(如隐私模式),忽略错误继续执行 - console.warn("sessionStorage不可用,跳过待处理图片加载:", storageError); - } - - if (pendingImage) { - // 清除 sessionStorage 中的数据,避免重复添加 - try { - sessionStorage.removeItem("pending-edited-image"); - } catch (removeError) { - // 忽略清除错误 - } - - // 判断是 base64 data URL 还是普通 URL - const fileName = pendingImage.startsWith("data:image/") - ? `edited-image-${Date.now()}.png` - : pendingImage.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`; - - // 推断图片类型 - let contentType = "image/png"; - if (pendingImage.startsWith("data:image/jpeg") || pendingImage.startsWith("data:image/jpg")) { - contentType = "image/jpeg"; - } else if (pendingImage.startsWith("data:image/webp")) { - contentType = "image/webp"; - } else if (pendingImage.startsWith("data:image/gif")) { - contentType = "image/gif"; - } else if (!pendingImage.startsWith("data:")) { - // 普通 URL,根据文件名推断 - if (fileName.toLowerCase().endsWith(".jpg") || fileName.toLowerCase().endsWith(".jpeg")) { - contentType = "image/jpeg"; - } else if (fileName.toLowerCase().endsWith(".webp")) { - contentType = "image/webp"; - } else if (fileName.toLowerCase().endsWith(".gif")) { - contentType = "image/gif"; - } - } - - setAttachments((prev) => [ - ...prev, - { - url: pendingImage, - name: fileName, - contentType: contentType, - }, - ]); - - // 自动填充提示词(如果输入框为空) - setInput((prev) => prev || "请帮我处理这张图片:"); - - setHasLoadedPendingImage(true); - } - } catch (error) { - console.error("读取待处理图片失败:", error); - setHasLoadedPendingImage(true); - } - } - }, [hasLoadedPendingImage, setInput]); - - const { data: votes } = useSWR( - messages.length >= 2 ? `/api/vote?chatId=${id}` : null, - fetcher - ); - - const [attachments, setAttachments] = useState([]); - const isArtifactVisible = useArtifactSelector((state) => state.isVisible); - - // 处理图片编辑请求 - const handleImageEdit = (imageUrl: string) => { - // 优化:直接使用 URL 构造附件对象,无需重新下载上传 - // 后端 API 会在发送时处理 URL(如果需要 Base64 转换) - const fileName = imageUrl.split('/').pop()?.split('?')[0] || `image-${Date.now()}.jpg`; - - // 简单的类型推断 - let contentType = "image/jpeg"; - if (fileName.toLowerCase().endsWith(".png")) contentType = "image/png"; - if (fileName.toLowerCase().endsWith(".webp")) contentType = "image/webp"; - if (fileName.toLowerCase().endsWith(".gif")) contentType = "image/gif"; - - setAttachments((prev) => [ - ...prev, - { - url: imageUrl, - name: fileName, - contentType: contentType, - }, - ]); - - // 如果输入框为空,自动填入提示词 - setInput((prev) => prev ? prev : "请帮我编辑这张图片:"); - }; - - // 处理画图标注完成 - const handleDrawingComplete = async (annotatedImageUrl: string) => { - // annotatedImageUrl 是 base64 data URL 格式,需要先上传到服务器获取 URL - const fileName = `annotated-${Date.now()}.png`; - - try { - // 如果是 base64 格式,先上传到服务器 - if (annotatedImageUrl.startsWith("data:")) { - toast.info("正在上传标注图片..."); - - // 将 base64 转换为 Blob - const response = await fetch(annotatedImageUrl); - const blob = await response.blob(); - - // 创建 FormData 上传 - const formData = new FormData(); - formData.append("file", blob, fileName); - - const uploadResponse = await fetchUploadWithRetry(formData); - - if (!uploadResponse.ok) { - throw new Error("上传失败"); - } - - const uploadResult = await uploadResponse.json(); - - setAttachments((prev) => [ - ...prev, - { - url: uploadResult.url, - name: fileName, - contentType: "image/png", - assetId: - typeof uploadResult.assetId === "string" ? uploadResult.assetId : undefined, - }, - ]); - - toast.success("标注图片已添加"); - } else { - // 如果已经是 URL,直接使用 - setAttachments((prev) => [ - ...prev, - { - url: annotatedImageUrl, - name: fileName, - contentType: "image/png", - }, - ]); - } - - // 如果输入框为空,自动填入提示词 - setInput((prev) => prev ? prev : "请根据我的标注修改这张图片:"); - } catch (error) { - console.error("上传标注图片失败:", error); - toast.error("上传标注图片失败,请重试"); - } - }; - - // 处理图片变清晰 - const handleImageEnhance = async (imageUrl: string) => { - // 防止重复点击 - if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) { - toast.info("正在处理中,请稍候..."); - return; - } - - setIsProcessingEnhance(true); - const messageId = generateUUID(); - - // 创建临时加载消息 - const loadingMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: "正在变清晰..." }], - createdAt: new Date().toISOString(), - } as ChatMessage; - - setMessages((prev) => [...prev, loadingMessage]); - - // 延迟滚动到底部,确保DOM已更新 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - try { - // 获取图片并转换为Base64 - const response = await fetch(imageUrl); - const blob = await response.blob(); - const reader = new FileReader(); - - reader.onloadend = async () => { - try { - const base64Data = (reader.result as string).split(",")[1]; - - const result = await requestLongTaskResult({ - endpoint: "/api/enhance", - payload: { - image_base64: base64Data, - prompt: "图片变清晰处理", - chatId: id, - messageId, - }, - pollingParams: { - prompt: "图片变清晰处理", - chatId: id, - messageId, - }, - }); - - if (result.success && result.original_url) { - // 构造图片Markdown(使用与Gemini生成相同的格式) - const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; - - // 创建最终消息(前端显示用) - const finalMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - metadata: { - createdAt: new Date().toISOString(), - }, - } as ChatMessage; - - // 更新消息 - setMessages((prev) => - prev.map((msg) => (msg.id === messageId ? finalMessage : msg)) - ); - - // 滚动到底部,显示处理结果 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - // 保存消息到数据库 - try { - await fetch("/api/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - chatId: id, - message: { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - attachments: [], - createdAt: new Date().toISOString(), - }, - }), - }); - } catch (saveError) { - console.error("[Chat] Failed to save message:", saveError); - } - - toast.success(`变清晰完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); - } else { - throw new Error(result.error || "变清晰处理失败"); - } - } catch (error) { - console.error("[变清晰] 错误:", error); - toast.error( - getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"), - }) - ); - // 移除加载消息 - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - } finally { - console.log("[变清晰] finally 块执行,重置 isProcessingEnhance"); - setIsProcessingEnhance(false); - } - }; - - reader.onerror = () => { - console.log("[变清晰] reader.onerror 执行,重置 isProcessingEnhance"); - toast.error("图片读取失败"); - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - setIsProcessingEnhance(false); - }; - - reader.readAsDataURL(blob); - } catch (error) { - console.error("[变清晰] 错误:", error); - console.log("[变清晰] 外层 catch 执行,重置 isProcessingEnhance"); - toast.error( - getUserFacingRequestErrorMessage(error, "变清晰失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前变清晰请求过于频繁,请稍后再试"), - }) - ); - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - setIsProcessingEnhance(false); - } - }; - - // 处理图片抠图 - const handleImageRmbg = async (imageUrl: string) => { - // 防止重复点击 - if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) { - toast.info("正在处理中,请稍候..."); - return; - } - - setIsProcessingRmbg(true); - const messageId = generateUUID(); - - // 创建临时加载消息 - const loadingMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: "正在抠图..." }], - createdAt: new Date().toISOString(), - } as ChatMessage; - - setMessages((prev) => [...prev, loadingMessage]); - - // 延迟滚动到底部,确保DOM已更新 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - try { - // 获取图片并转换为Base64 - const response = await fetch(imageUrl); - const blob = await response.blob(); - const reader = new FileReader(); - - reader.onloadend = async () => { - try { - const base64Data = (reader.result as string).split(",")[1]; - - // 调用抠图API - const rmbgResponse = await fetch("/api/rmbg", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - image_base64: base64Data, - prompt: "抠图处理", - }), - }); - - if (!rmbgResponse.ok) { - const error = await rmbgResponse.json(); - throw new Error(error.error || "抠图处理失败"); - } - - const result = await rmbgResponse.json(); - - if (result.success && result.original_url) { - // 构造图片Markdown(使用与Gemini生成相同的格式) - const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; - - // 创建最终消息(前端显示用) - const finalMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - metadata: { - createdAt: new Date().toISOString(), - }, - } as ChatMessage; - - // 更新消息 - setMessages((prev) => - prev.map((msg) => (msg.id === messageId ? finalMessage : msg)) - ); - - // 滚动到底部,显示处理结果 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - // 保存消息到数据库 - try { - await fetch("/api/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - chatId: id, - message: { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - attachments: [], - createdAt: new Date().toISOString(), - }, - }), - }); - } catch (saveError) { - console.error("[Chat] Failed to save message:", saveError); - } - - toast.success(`抠图完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); - } else { - throw new Error(result.error || "抠图处理失败"); - } - } catch (error) { - console.error("[抠图] 错误:", error); - toast.error( - getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"), - }) - ); - // 移除加载消息 - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - } finally { - console.log("[抠图] finally 块执行,重置 isProcessingRmbg"); - setIsProcessingRmbg(false); - } - }; - - reader.onerror = () => { - console.log("[抠图] reader.onerror 执行,重置 isProcessingRmbg"); - toast.error("图片读取失败"); - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - setIsProcessingRmbg(false); - }; - - reader.readAsDataURL(blob); - } catch (error) { - console.error("[抠图] 错误:", error); - console.log("[抠图] 外层 catch 执行,重置 isProcessingRmbg"); - toast.error( - getUserFacingRequestErrorMessage(error, "抠图失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前抠图请求过于频繁,请稍后再试"), - }) - ); - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - setIsProcessingRmbg(false); - } - }; - - // 处理图片去水印 - const handleImageWatermarkRemove = async (imageUrl: string) => { - // 防止重复点击 - if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend || isProcessingWatermarkRemove) { - toast.info("正在处理中,请稍候..."); - return; - } - - setIsProcessingWatermarkRemove(true); - const messageId = generateUUID(); - - // 创建临时加载消息 - const loadingMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: "正在去水印..." }], - createdAt: new Date().toISOString(), - } as ChatMessage; - - setMessages((prev) => [...prev, loadingMessage]); - - // 延迟滚动到底部,确保DOM已更新 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - try { - const result = await requestLongTaskResult({ - endpoint: "/api/watermark-remove", - payload: { - image_url: imageUrl, - }, - }); - - if (result.success && result.original_url) { - // 构造图片Markdown(使用与Gemini生成相同的格式) - const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; - - // 创建最终消息(前端显示用) - const finalMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - createdAt: new Date().toISOString(), - } as ChatMessage; - - setMessages((prev) => { - const filtered = prev.filter((msg) => msg.id !== messageId); - return [...filtered, finalMessage]; - }); - - // 保存消息到数据库 - try { - const saveResponse = await fetch("/api/messages", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chatId: id, - message: { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - }, - }), - }); - - if (!saveResponse.ok) { - console.error("[Chat] Failed to save message:", await saveResponse.text()); - } - } catch (saveError) { - console.error("[Chat] Failed to save message:", saveError); - } - - toast.success(`去水印完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); - } else { - throw new Error(result.error || "去水印处理失败"); - } - } catch (error) { - console.error("[去水印] 错误:", error); - toast.error( - getUserFacingRequestErrorMessage(error, "去水印失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前去水印请求过于频繁,请稍后再试"), - }) - ); - // 移除加载消息 - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - } finally { - setIsProcessingWatermarkRemove(false); - } - }; - - // 处理图像拓展 - const handleImageExtend = async (imageUrl: string) => { - // 防止重复点击 - if (isProcessingEnhance || isProcessingRmbg || isProcessingExtend) { - toast.info("正在处理中,请稍候..."); - return; - } - - setIsProcessingExtend(true); - const messageId = generateUUID(); - - // 创建临时加载消息 - const loadingMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: "正在图像拓展..." }], - metadata: { - createdAt: new Date().toISOString(), - }, - } as ChatMessage; - - setMessages((prev) => [...prev, loadingMessage]); - - // 延迟滚动到底部,确保DOM已更新 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - try { - let extendSourceUrl = String(imageUrl || "").trim(); - if (!extendSourceUrl) { - throw new Error("缺少可拓展的图片地址"); - } - - if (extendSourceUrl.startsWith("data:") || extendSourceUrl.startsWith("blob:")) { - const sourceResponse = await fetch(extendSourceUrl); - if (!sourceResponse.ok) { - throw new Error("读取待拓展图片失败"); - } - const blob = await sourceResponse.blob(); - const mimeType = - blob.type || sourceResponse.headers.get("content-type") || "image/png"; - const file = new File( - [blob], - `extend-source-${Date.now()}.${getImageFileExtension(mimeType)}`, - { type: mimeType }, - ); - const formData = new FormData(); - formData.append("file", file); - - const uploadResponse = await fetchUploadWithRetry(formData); - const uploadPayload = await uploadResponse - .json() - .catch(() => null as { url?: string; error?: string } | null); - if (!uploadResponse.ok) { - throw new Error(uploadPayload?.error || "上传待拓展图片失败"); - } - - const uploadedUrl = String(uploadPayload?.url || "").trim(); - if (!uploadedUrl) { - throw new Error("上传待拓展图片成功,但未返回图片地址"); - } - extendSourceUrl = uploadedUrl; - } else { - extendSourceUrl = new URL(extendSourceUrl, window.location.origin).toString(); - } - - const extendResponse = await fetch("/api/extend", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - image_url: extendSourceUrl, - prompt: "图片图像拓展处理", - chatId: id, - messageId, - includeBase64: false, - }), - }); - - const result = await extendResponse.json().catch( - () => - null as { - success?: boolean; - error?: string; - original_url?: string; - thumbnail_url?: string; - processing_time?: number; - } | null, - ); - - if (!extendResponse.ok) { - throw new Error(result?.error || "图像拓展处理失败"); - } - - if (result?.success && result.original_url) { - // 构造图片Markdown(使用与Gemini生成相同的格式) - const imageMarkdown = `![thumb|||${result.original_url}|||2K](${result.thumbnail_url || result.original_url})`; - - // 创建最终消息(前端显示用) - const finalMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - metadata: { - createdAt: new Date().toISOString(), - }, - } as ChatMessage; - - // 更新消息 - setMessages((prev) => - prev.map((msg) => (msg.id === messageId ? finalMessage : msg)), - ); - - // 滚动到底部,显示处理结果 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - // 保存消息到数据库 - try { - await fetch("/api/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - chatId: id, - message: { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - attachments: [], - createdAt: new Date().toISOString(), - }, - }), - }); - } catch (saveError) { - console.error("[Chat] Failed to save message:", saveError); - } - - toast.success(`图像拓展完成!处理时间: ${result.processing_time?.toFixed(2) || "未知"}秒`); - } else { - throw new Error(result?.error || "图像拓展处理失败"); - } - } catch (error) { - console.error("[图像拓展] 错误:", error); - toast.error( - getUserFacingRequestErrorMessage(error, "图像拓展失败,请稍后重试", { - rateLimitMessage: getFriendlyRateLimitMessage("当前图像拓展请求过于频繁,请稍后再试"), - }) - ); - setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); - } finally { - console.log("[图像拓展] finally 块执行,重置 isProcessingExtend"); - setIsProcessingExtend(false); - } - }; - - // 处理角度编辑完成 - const handleImageAngleEdit = async (resultUrl: string) => { - const messageId = generateUUID(); - - // 构造图片Markdown(使用与其他处理功能相同的格式) - const imageMarkdown = `![thumb|||${resultUrl}|||2K](${resultUrl})`; - - // 创建最终消息 - const finalMessage: ChatMessage = { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - metadata: { - createdAt: new Date().toISOString(), - }, - } as ChatMessage; - - // 添加消息 - setMessages((prev) => [...prev, finalMessage]); - - // 滚动到底部 - setTimeout(() => { - const messagesContainer = document.querySelector('[role="log"]'); - if (messagesContainer) { - messagesContainer.scrollTo({ - top: messagesContainer.scrollHeight, - behavior: "smooth", - }); - } - }, 100); - - // 保存消息到数据库 - try { - await fetch("/api/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - chatId: id, - message: { - id: messageId, - role: "assistant", - parts: [{ type: "text", text: imageMarkdown }], - attachments: [], - createdAt: new Date().toISOString(), - }, - }), - }); - } catch (saveError) { - console.error("[Chat] Failed to save angle edit message:", saveError); - } - }; - - // 轮询视频任务状态:解决刷新后“处理中”无法自动恢复结果的问题 - useEffect(() => { - if (isReadonly) return; - - let cancelled = false; - let timer: ReturnType | null = null; - let previousPendingCount = -1; - - const mergeMessagesFromDatabase = (dbMessages: ChatMessage[]) => { - setMessages((prev) => { - const mergedMap = new Map(); - prev.forEach((message) => mergedMap.set(message.id, message)); - dbMessages.forEach((message) => mergedMap.set(message.id, message)); - return sortMessagesByCreatedAt(Array.from(mergedMap.values())); - }); - - const persistedIds = new Set(dbMessages.map((message) => message.id)); - setAppendedMessages((prev) => prev.filter((message) => !persistedIds.has(message.id))); - }; - - const resolveModelTypeByProvider = ( - provider?: string - ): PendingGeneration["modelType"] => { - if (provider === "qianwen-video") return "qianwen-video"; - if (provider === "fal-video") return "fal-video"; - if (provider === "ruxa-video") return "ruxa-video"; - if (provider === "krea") return "krea-video"; - if (provider === "volcano-video" || provider === "doubao-video") return "volcano-video"; - return "other"; - }; - - const pollPendingVideoTasks = async () => { - try { - const response = await fetch(`/api/video-tasks?chatId=${id}&_t=${Date.now()}`, { - cache: "no-store", - }); - if (!response.ok) return; - - const data = await response.json(); - const tasks = Array.isArray(data?.tasks) ? data.tasks : []; - const pendingCount = tasks.length; - - if (pendingCount > 0 && !pendingGenerationRef.current) { - const firstTask = tasks[0] as { provider?: string } | undefined; - startGeneration( - currentModelIdRef.current, - resolveModelTypeByProvider(firstTask?.provider), - ); - } - - if (previousPendingCount > 0 && pendingCount === 0) { - const msgResponse = await fetch(`/api/messages?chatId=${id}&_t=${Date.now()}`, { - cache: "no-store", - }); - if (msgResponse.ok) { - const msgData = await msgResponse.json(); - if (Array.isArray(msgData?.messages)) { - mergeMessagesFromDatabase(msgData.messages as ChatMessage[]); - } - } - completeGeneration(); - } - - previousPendingCount = pendingCount; - } catch (error) { - console.warn("[Chat] video task polling failed", error); - } finally { - if (!cancelled) { - timer = setTimeout(pollPendingVideoTasks, 5000); - } - } - }; - - pollPendingVideoTasks(); - - return () => { - cancelled = true; - if (timer) { - clearTimeout(timer); - } - }; - }, [id, isReadonly, setMessages, setAppendedMessages, startGeneration, completeGeneration]); - - useAutoResume({ - autoResume, - initialMessages, - resumeStream, - setMessages, - }); - - // 🆕 合并消息:初始消息 + useChat 消息 + appendedMessages - // 确保历史消息不会因为 useChat 内部状态重置而丢失 - const mergedMessages = useMemo(() => { - // 使用 Map 进行去重,以 ID 为键 - const messageMap = new Map(); - - // 1. 首先添加初始消息(历史记录) - initialMessagesRef.current.forEach(m => { - messageMap.set(m.id, m); - }); - - // 2. 然后添加 useChat 的当前消息(会覆盖相同 ID 的旧消息) - messages.forEach(m => { - messageMap.set(m.id, m); - }); - - // 3. 最后添加 appendedMessages(data-appendMessage 的消息) - appendedMessages.forEach(m => { - const existing = messageMap.get(m.id); - if (!existing) { - messageMap.set(m.id, m); - return; - } - - const existingHasImage = hasImageMarkdown((existing as any).parts); - const nextHasImage = hasImageMarkdown((m as any).parts); - - const shouldReplace = - (nextHasImage && !existingHasImage) || - getMessageTextLength(m) > getMessageTextLength(existing); - - if (shouldReplace) { - messageMap.set(m.id, { - ...existing, - ...m, - createdAt: (existing as any).createdAt || (m as any).createdAt, - } as ChatMessage); - } - }); - - // 按创建时间排序 - const result = Array.from(messageMap.values()).sort((a, b) => { - const timeA = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0; - const timeB = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0; - return timeA - timeB; - }); - - console.log("[Chat] Merged messages:", result.length, "(initial:", initialMessagesRef.current.length, ", current:", messages.length, ", appended:", appendedMessages.length, ")"); - return result; - }, [messages, appendedMessages]); - - return ( - <> -
- - - - -
- {!isReadonly && ( - { - setVideoParams(params); - console.log("[Chat] Video params updated:", params); - }} - isImageModel={isImageModel} - imageModelName={currentModel?.displayName} - imageParams={imageParams} - onImageParamsChange={handleImageParamsChange} - imageProviderType={currentModel?.providerType} - imageGenConfig={currentModel?.imageGenConfig || undefined} - /> - )} -
- setProtocolDebugEvents([])} - /> -
- - - - - - - 启用 AI Gateway - - 此应用需要 - {process.env.NODE_ENV === "production" ? "项目拥有者" : "你"} - 激活 Vercel AI Gateway。 - - - - 取消 - { - window.open( - "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card", - "_blank" - ); - window.location.href = "/"; - }} - > - 立即启用 - - - - - { - if (!open) setInsufficientPointsState(null); - }} - > - - - 积分不足 - - {insufficientPointsState - ? `当前余额 ${insufficientPointsState.balance},本次预计消耗 ${insufficientPointsState.expectedCost}(${insufficientPointsState.unitCost} x ${insufficientPointsState.imageCount} 张)。请先充值后再发送生图需求。` - : "当前积分不足,请先充值后再发送生图需求。"} - - - - 稍后再说 - { - setInsufficientPointsState(null); - window.location.href = "/recharge"; - }} - > - 去充值 - - - - - - ); -} diff --git a/components/classic-chat-start.tsx b/components/classic-chat-start.tsx deleted file mode 100644 index edd9da118..000000000 --- a/components/classic-chat-start.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { UserType } from "@/app/(auth)/auth"; -import { Chat } from "@/components/chat"; -import { DataStreamHandler } from "@/components/data-stream-handler"; - -type ClassicChatStartProps = { - id: string; - initialModelId: string; - userType?: UserType; -}; - -export function ClassicChatStart({ - id, - initialModelId, - userType, -}: ClassicChatStartProps) { - return ( - <> - - - - ); -} diff --git a/components/data-stream-handler.tsx b/components/data-stream-handler.tsx deleted file mode 100644 index 0d5eeeb8d..000000000 --- a/components/data-stream-handler.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { initialArtifactData, useArtifact } from "@/hooks/use-artifact"; -import { artifactDefinitions } from "./artifact"; -import { useDataStream } from "./data-stream-provider"; - -export function DataStreamHandler() { - const { dataStream, setDataStream } = useDataStream(); - - const { artifact, setArtifact, setMetadata } = useArtifact(); - - useEffect(() => { - if (!dataStream?.length) { - return; - } - - const newDeltas = dataStream.slice(); - setDataStream([]); - - for (const delta of newDeltas) { - const artifactDefinition = artifactDefinitions.find( - (currentArtifactDefinition) => - currentArtifactDefinition.kind === artifact.kind - ); - - if (artifactDefinition?.onStreamPart) { - artifactDefinition.onStreamPart({ - streamPart: delta, - setArtifact, - setMetadata, - }); - } - - setArtifact((draftArtifact) => { - if (!draftArtifact) { - return { ...initialArtifactData, status: "streaming" }; - } - - switch (delta.type) { - case "data-id": - return { - ...draftArtifact, - documentId: delta.data, - status: "streaming", - }; - - case "data-title": - return { - ...draftArtifact, - title: delta.data, - status: "streaming", - }; - - case "data-kind": - return { - ...draftArtifact, - kind: delta.data, - status: "streaming", - }; - - case "data-clear": - return { - ...draftArtifact, - content: "", - status: "streaming", - }; - - case "data-finish": - return { - ...draftArtifact, - status: "idle", - }; - - default: - return draftArtifact; - } - }); - } - }, [dataStream, setArtifact, setMetadata, artifact, setDataStream]); - - return null; -} diff --git a/components/messages.tsx b/components/messages.tsx deleted file mode 100644 index f9be29342..000000000 --- a/components/messages.tsx +++ /dev/null @@ -1,804 +0,0 @@ -import type { UseChatHelpers } from "@ai-sdk/react"; -import equal from "fast-deep-equal"; -import { ArrowDownIcon } from "lucide-react"; -import { memo, useEffect, useState, useRef } from "react"; -import useSWR from "swr"; -import { - isImageGenerationModelDescriptor, - isVideoModelDescriptor, -} from "@/lib/ai/model-classification"; -import { useMessages } from "@/hooks/use-messages"; -import type { PendingGeneration } from "@/hooks/use-pending-generation"; -import type { Vote } from "@/lib/db/schema"; -import type { ChatMessage } from "@/lib/types"; -import { useDataStream } from "./data-stream-provider"; -import { GeminiImageLoading } from "./gemini-image-loading"; -import { QianwenVideoLoading } from "./qianwen-video-loading"; -import { VolcanoVisionLoading } from "./volcano-vision-loading"; -import { ImageGenerationLoading } from "./image-generation-loading"; -import { Greeting } from "./greeting"; -import { PreviewMessage, ThinkingMessage } from "./message"; - -// 模型数据类型 -type DynamicModel = { - id: string; - providerType: string; - modelId: string; - displayName: string; - supportsImageGen?: boolean; - capabilities?: unknown; -}; - -const fetcher = (url: string) => fetch(url).then((res) => res.json()); - -type MessagesProps = { - chatId: string; - status: UseChatHelpers["status"]; - votes: Vote[] | undefined; - messages: ChatMessage[]; - setMessages: UseChatHelpers["setMessages"]; - regenerate: UseChatHelpers["regenerate"]; - sendMessage: UseChatHelpers["sendMessage"]; - isReadonly: boolean; - isArtifactVisible: boolean; - selectedModelId: string; - onImageEdit?: (imageUrl: string) => void; - onDrawingComplete?: (annotatedImageUrl: string) => void; - onImageEnhance?: (imageUrl: string) => void; - onImageRmbg?: (imageUrl: string) => void; - onImageExtend?: (imageUrl: string) => void; - onImageAngleEdit?: (imageUrl: string) => void; - onImageWatermarkRemove?: (imageUrl: string) => void; - isProcessingEnhance?: boolean; - isProcessingRmbg?: boolean; - isProcessingExtend?: boolean; - isProcessingAngleEdit?: boolean; - isProcessingWatermarkRemove?: boolean; - pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态 - imageCount?: number; // 🆕 用户选择的图片数量 - isGeneratingImageFlag?: boolean; // 🆕 正在生成图片的标志(来自 Chat 组件) - imageGenerationPhase?: "idle" | "queued" | "processing" | "finalizing" | "failed"; - imageGenerationStartedAt?: number | null; -}; - -function PureMessages({ - chatId, - status, - votes, - messages, - setMessages, - regenerate, - sendMessage, - isReadonly, - selectedModelId, - onImageEdit, - onDrawingComplete, - onImageEnhance, - onImageRmbg, - onImageExtend, - onImageAngleEdit, - onImageWatermarkRemove, - isProcessingEnhance = false, - isProcessingRmbg = false, - isProcessingExtend = false, - isProcessingAngleEdit = false, - isProcessingWatermarkRemove = false, - pendingGeneration, - imageCount = 1, - isGeneratingImageFlag = false, - imageGenerationPhase = "idle", - imageGenerationStartedAt = null, -}: MessagesProps) { - const { - containerRef: messagesContainerRef, - endRef: messagesEndRef, - isAtBottom, - scrollToBottom, - hasSentMessage, - } = useMessages({ - status, - }); - - useDataStream(); - - // 获取模型列表以判断当前模型类型 - const { data: modelsData } = useSWR<{ models: DynamicModel[] }>( - "/api/models", - fetcher - ); - - // 判断当前选中的模型类型 - // 🆕 包含 Gemini 和 Grsai GPT Image 图片生成 provider - const isGeminiModel = modelsData?.models?.some( - (m: DynamicModel) => m.id === selectedModelId && ( - m.providerType === "gemini" || - m.providerType === "gemini-image" || - m.providerType === "grsai-gpt-image" || - m.providerType === "huanke-gemini" - ) - ) ?? false; - - const isQianwenVideoModel = modelsData?.models?.some( - (m: DynamicModel) => - m.id === selectedModelId && - (m.providerType === "qianwen-video" || m.providerType === "fal-video" || m.providerType === "ruxa-video" || m.providerType === "krea") - ) ?? false; - - const isVolcanoVisionModel = modelsData?.models?.some( - (m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision" - ) ?? false; - - // 🆕 判断是否是阿里生图模型(或者智能自动模式默认使用阿里生图占位图) - const isAlibabaImageModel = modelsData?.models?.some( - (m: DynamicModel) => - m.id === selectedModelId && - isImageGenerationModelDescriptor({ - providerType: m.providerType, - modelId: m.modelId, - supportsImageGen: m.supportsImageGen, - }) - ) ?? false; - - // 🆕 获取当前模型的显示名称 - const currentModelDisplayName = modelsData?.models?.find( - (m: DynamicModel) => m.id === selectedModelId - )?.displayName; - const currentModelProviderType = modelsData?.models?.find( - (m: DynamicModel) => m.id === selectedModelId - )?.providerType; - - // 判断是否是视频生成模型(通过 providerType 或模型名称) - const isVideoModel = modelsData?.models?.some( - (m: DynamicModel) => - m.id === selectedModelId && - isVideoModelDescriptor({ - providerType: m.providerType, - modelId: m.modelId, - supportsImageGen: m.supportsImageGen, - }) - ) ?? false; - - // 控制等待动画延迟消失(Gemini 图片模型延迟 4 秒,其他模型 6 秒) - const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false); - const [autoImagePlaceholderReady, setAutoImagePlaceholderReady] = useState(false); - const autoImagePlaceholderTimerRef = useRef | null>(null); - const [imageGenerationElapsedSeconds, setImageGenerationElapsedSeconds] = useState(0); - const isImageGenerationActive = - imageGenerationPhase === "queued" || - imageGenerationPhase === "processing" || - imageGenerationPhase === "finalizing"; - const stripInternalMarkers = (text: string) => - text - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .replace(/\n*\n*/g, "") - .trim(); - const hasImageContentInMessage = (message?: ChatMessage | null) => { - if (!message?.parts || message.parts.length === 0) return false; - return message.parts.some((part: any) => { - if (part?.type === "text" && typeof part.text === "string") { - return ( - part.text.includes("![thumb|") || - part.text.includes("![thumb|||") || - part.text.includes("![Generated Image") - ); - } - - if (part?.type === "image" && typeof part.url === "string" && part.url.length > 0) { - return true; - } - - if ( - part?.type === "file" && - typeof part.url === "string" && - ((typeof part.mediaType === "string" && part.mediaType.startsWith("image/")) || - /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(part.url)) - ) { - return true; - } - - return false; - }); - }; - - useEffect(() => { - return () => { - if (autoImagePlaceholderTimerRef.current) { - clearTimeout(autoImagePlaceholderTimerRef.current); - autoImagePlaceholderTimerRef.current = null; - } - }; - }, []); - - useEffect(() => { - if (!isImageGenerationActive || !imageGenerationStartedAt) { - setImageGenerationElapsedSeconds(0); - return; - } - - const updateElapsed = () => { - setImageGenerationElapsedSeconds( - Math.max(0, Math.floor((Date.now() - imageGenerationStartedAt) / 1000)) - ); - }; - - updateElapsed(); - const timer = setInterval(updateElapsed, 1000); - return () => clearInterval(timer); - }, [isImageGenerationActive, imageGenerationStartedAt]); - - useEffect(() => { - const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null; - - // 对于视频模型,检查是否有真正的视频内容(包含