"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 { 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 normalizeImageParamsForProvider( params: ImageGenerationParams, providerType?: string ): ImageGenerationParams { const normalized = { ...params }; const currentCount = normalized.imageCount || 1; if (providerType === "ruxa-image" || providerType === "openai-image" || providerType === "midjourney") { normalized.imageCount = 1; 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: VideoGenerationParams | null | undefined, providerType?: string, capability?: VideoToolCapabilityContract | null ): VideoGenerationParams { if (capability) { const modeOptions = capability.modeOptions || []; const ratioOptions = capability.ratioOptions || []; const durationOptions = capability.durationOptions || []; const resolutionOptions = capability.resolutionOptions || []; const modeSet = new Set(modeOptions.map((item) => item.mode)); const ratioSet = new Set(ratioOptions); const durationSet = new Set(durationOptions); const resolutionSet = new Set(resolutionOptions); const fallbackMode = capability.defaults?.mode || modeOptions[0]?.mode || "text-to-video"; const fallbackRatio = capability.defaults?.ratio || ratioOptions[0] || "16:9"; const fallbackDuration = capability.defaults?.duration || durationOptions[0] || 5; const fallbackResolution = capability.defaults?.resolution || resolutionOptions[0] || "720p"; const merged = { mode: fallbackMode, resolution: fallbackResolution as VideoGenerationParams["resolution"], ratio: fallbackRatio as VideoGenerationParams["ratio"], duration: Number(fallbackDuration) || 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"]; } if (!ratioSet.has(merged.ratio)) { merged.ratio = fallbackRatio as VideoGenerationParams["ratio"]; } if (!durationSet.has(Number(merged.duration))) { merged.duration = Number(fallbackDuration) || 5; } else { merged.duration = Number(merged.duration) || Number(fallbackDuration) || 5; } if (!resolutionSet.has(merged.resolution)) { 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); // 🆕 存储通过 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; videoToolCapability?: VideoToolCapabilityContract | null; imageGenConfig?: { maxImages?: number; supportsSize?: boolean; supports1K?: boolean; supports2K?: boolean; supports4K?: boolean; supportsPromptExtend?: boolean; supportsNegativePrompt?: boolean; supportsWatermark?: boolean; supportsSeed?: boolean; } | null; }>; }>("/api/models", fetcher); const currentModel = modelsData?.models?.find(m => m.id === currentModelId); const isVideoModel = currentModel?.providerType === "volcano-video" || currentModel?.providerType === "doubao-video" || currentModel?.providerType === "qianwen-video" || currentModel?.providerType === "fal-video" || currentModel?.providerType === "ruxa-video"; const isImageModel = currentModel?.supportsImageGen === true; // 🔍 调试日志 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, }); } 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(error.message); } } else { toast.error(fallbackMessage || "请求失败,请稍后重试"); } }, }); useEffect(() => { const isImagePending = pendingGeneration?.modelType === "gemini" || pendingGeneration?.modelType === "volcano-vision" || pendingGeneration?.modelType === "alibaba-image"; if (!pendingGeneration || !isImagePending) return; let cancelled = false; const maxDurationMs = 10 * 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 === "huanke-gemini") modelType = "gemini"; else if (model?.providerType === "volcano-vision") modelType = "volcano-vision"; else if (model?.providerType === "volcano-video" || model?.providerType === "doubao-video") modelType = "volcano-video"; else if (model?.providerType === "qianwen-video") modelType = "qianwen-video"; else if (model?.providerType === "fal-video") modelType = "fal-video"; else if (model?.providerType === "ruxa-video") modelType = "ruxa-video"; else if ( model?.providerType === "alibaba-image" || model?.providerType === "ruxa-image" || model?.providerType === "fal-image" || model?.providerType === "openai-image" || model?.providerType === "midjourney" ) 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(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); // 移除加载消息 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(error instanceof Error ? error.message : "变清晰失败,请稍后重试"); 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(error instanceof Error ? error.message : "抠图失败,请稍后重试"); // 移除加载消息 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(error instanceof Error ? error.message : "抠图失败,请稍后重试"); 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(error instanceof Error ? error.message : "去水印失败,请稍后重试"); // 移除加载消息 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 { // 获取图片并转换为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 extendResponse = await fetch("/api/extend", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ image_base64: base64Data, prompt: "图片图像拓展处理", }), }); if (!extendResponse.ok) { const error = await extendResponse.json(); throw new Error(error.error || "图像拓展处理失败"); } const result = await extendResponse.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(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); // 移除加载消息 setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); } finally { console.log("[图像拓展] finally 块执行,重置 isProcessingExtend"); setIsProcessingExtend(false); } }; reader.onerror = () => { console.log("[图像拓展] reader.onerror 执行,重置 isProcessingExtend"); toast.error("图片读取失败"); setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); setIsProcessingExtend(false); }; reader.readAsDataURL(blob); } catch (error) { console.error("[图像拓展] 错误:", error); console.log("[图像拓展] 外层 catch 执行,重置 isProcessingExtend"); toast.error(error instanceof Error ? error.message : "图像拓展失败,请稍后重试"); setMessages((prev) => prev.filter((msg) => msg.id !== messageId)); 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 === "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"; }} > 去充值 ); }