Your Name 0cc791a71e
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: delay image placeholder until text shows
Guard the auto image-loading placeholder so it starts only after a visible text reply and cancel pending timers when the image arrives.
2026-02-03 16:59:23 +08:00

1430 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
import { Artifact } from "@/components/artifact";
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";
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 { visibilityType } = useChatVisibility({
chatId: id,
initialVisibilityType,
});
// 🆕 持久化生成任务状态(解决用户离开再返回时加载状态丢失)
const { pendingGeneration, startGeneration, completeGeneration } = usePendingGeneration(id);
// 🆕 标记是否已经尝试刷新过(避免无限刷新)
const hasTriedRefreshRef = useRef(false);
// 🆕 跟踪是否正在生成图片(用于占位图显示)
const [isGeneratingImageFlag, setIsGeneratingImageFlag] = useState(false);
const imagePlaceholderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 🆕 存储通过 data-appendMessage 添加的消息,避免与 useChat 内部状态冲突
const [appendedMessages, setAppendedMessages] = useState<ChatMessage[]>([]);
// 🆕 当检测到恢复的 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(() => {
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<string>("");
const [usage, setUsage] = useState<AppUsage | undefined>(initialLastContext);
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
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<VideoGenerationParams | null>(null);
// 🆕 图片参数状态(默认值:auto比例,2K分辨率)
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
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) => {
setImageParams(params);
try {
localStorage.setItem("gemini-image-params", JSON.stringify(params));
} catch (e) {
console.error("Failed to save image params:", e);
}
console.log("[Chat] Image params updated:", params);
};
// 🆕 获取模型列表以检查是否是视频模型/图片模型
const { data: modelsData } = useSWR<{
models: Array<{
id: string;
modelId: string;
displayName: string;
providerType: string;
supportsVision?: boolean;
supportsImageGen?: boolean;
}>;
}>("/api/models", fetcher);
const currentModel = modelsData?.models?.find(m => m.id === currentModelId);
const isVideoModel = currentModel?.providerType === "volcano-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 && !videoParams) {
const defaultParams: VideoGenerationParams = {
mode: "text-to-video",
resolution: "720p",
ratio: "16:9",
duration: 5,
watermark: false,
cameraFixed: false,
cameraMove: "none",
fps: 24,
};
setVideoParams(defaultParams);
console.log("[Chat] 视频模型已选择,初始化默认参数:", defaultParams);
} else if (!isVideoModel && videoParams) {
// 切换到非视频模型时清空 videoParams
setVideoParams(null);
console.log("[Chat] 已切换到非视频模型,清空视频参数");
}
}, [isVideoModel, videoParams]);
// 🆕 当切换到图片模型时,强制更新 imageParams(确保组件重新渲染)
useEffect(() => {
if (isImageModel) {
// 强制更新一次 imageParams 以触发重新渲染
setImageParams(prev => ({...prev}));
console.log("[Chat] 图片模型已激活,触发参数更新");
}
}, [isImageModel]);
useEffect(() => {
currentModelIdRef.current = currentModelId;
// 调试:在控制台显示当前模型切换
console.log(`[Chat] Model changed to: ${currentModelId}`);
}, [currentModelId]);
const {
messages,
setMessages,
sendMessage,
status,
stop,
regenerate,
resumeStream,
} = useChat<ChatMessage>({
id,
messages: initialMessages,
experimental_throttle: 100,
generateId: generateUUID,
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
prepareSendMessagesRequest(request) {
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,
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);
// 🆕 检测是否包含 GENERATING_IMAGE 标记
const hasGeneratingMarker = messageData.parts?.some(
(part: any) => part.type === "text" && part.text?.includes("<!-- GENERATING_IMAGE -->")
);
if (hasGeneratingMarker) {
console.log("[Chat] 🖼️ Detected GENERATING_IMAGE marker, will show placeholder after delay");
const hasDisplayText = messageData.parts?.some((part: any) => {
if (part.type !== "text" || !part.text) return false;
const cleanedText = part.text.replace(/\n*<!-- GENERATING_IMAGE -->\n*/g, "").trim();
return cleanedText.length > 0;
});
if (!hasDisplayText) {
console.log("[Chat] ⚠️ Marker message has no display text, skip image placeholder timer");
return;
}
// 🆕 延迟显示占位图,让用户先看到 AI 的文字回复
// 消息会立即添加到 appendedMessages 并显示,3 秒后再显示动态占位图
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
imagePlaceholderTimerRef.current = setTimeout(() => {
console.log("[Chat] 🖼️ Now showing placeholder after 3s delay");
setIsGeneratingImageFlag(true);
imagePlaceholderTimerRef.current = null;
}, 3000); // 延迟 3 秒,让用户有时间阅读文字
}
// 🆕 检测是否包含图片(生成完成)
const messageText = messageData.parts?.[0]?.text || "";
console.log("[Chat] 📝 Message text preview:", messageText.substring(0, 100));
const hasImage = messageData.parts?.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
)
);
console.log("[Chat] 🔍 hasImage check:", hasImage);
if (hasImage) {
console.log("[Chat] 🎉 Image generated, hiding placeholder");
if (imagePlaceholderTimerRef.current) {
clearTimeout(imagePlaceholderTimerRef.current);
imagePlaceholderTimerRef.current = null;
}
setIsGeneratingImageFlag(false);
}
// 🆕 将消息添加到 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: [],
} 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];
});
// 🆕 收到消息,清除持久化状态
completeGeneration();
} catch (e) {
console.error("[Chat] Failed to parse data-appendMessage:", e);
}
}
},
onFinish: async () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
// 🆕 生成完成,清除持久化状态
completeGeneration();
console.log("[Chat] onFinish called");
// 🆕 从数据库重新加载消息,确保 data-appendMessage 的消息正确显示
// 这是根治方案 B:后端已保存消息到数据库,前端从数据库重新加载
try {
// 延迟一小段时间确保数据库写入完成
await new Promise(resolve => setTimeout(resolve, 500));
// 获取最新消息
const response = await fetch(`/api/messages?chatId=${id}`);
if (response.ok) {
const data = await response.json();
if (data.messages && Array.isArray(data.messages)) {
console.log("[Chat] Reloading messages from database:", data.messages.length);
// 更新消息状态
setMessages(data.messages);
// 清空 appendedMessages,因为已经从数据库加载了完整消息
setAppendedMessages([]);
}
}
} catch (error) {
console.error("[Chat] Failed to reload messages:", error);
}
},
onError: (error) => {
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);
}
}
},
});
// 🆕 保存 initialMessages 的引用,防止历史消息丢失
// 当 useChat 内部状态重置时,我们仍然有初始消息
const initialMessagesRef = useRef<ChatMessage[]>(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}`);
// 🆕 当状态变为 submitted 时,记录生成任务
if (status === "submitted") {
// 判断模型类型 - 使用 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") modelType = "volcano-video";
else if (model?.providerType === "qianwen-video") modelType = "qianwen-video";
else if (model?.providerType === "alibaba-image") modelType = "alibaba-image";
console.log(`[Chat] 🚀 记录生成任务: modelType=${modelType}, modelId=${currentModelId}`);
startGeneration(currentModelId, modelType);
}
}, [status, currentModelId, startGeneration]);
const searchParams = useSearchParams();
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<Vote[]>(
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
fetcher
);
const [attachments, setAttachments] = useState<Attachment[]>([]);
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 fetch("/api/files/upload", {
method: "POST",
body: formData,
});
if (!uploadResponse.ok) {
throw new Error("上传失败");
}
const uploadResult = await uploadResponse.json();
setAttachments((prev) => [
...prev,
{
url: uploadResult.url,
name: fileName,
contentType: "image/png",
},
]);
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];
// 调用变清晰API
const enhanceResponse = await fetch("/api/enhance", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_base64: base64Data,
prompt: "图片变清晰处理",
}),
});
if (!enhanceResponse.ok) {
const error = await enhanceResponse.json();
throw new Error(error.error || "变清晰处理失败");
}
const result = await enhanceResponse.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 块执行,重置 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 {
// 调用去水印API
const watermarkRemoveResponse = await fetch("/api/watermark-remove", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
image_url: imageUrl,
}),
});
if (!watermarkRemoveResponse.ok) {
const error = await watermarkRemoveResponse.json();
throw new Error(error.error || "去水印处理失败");
}
const result = await watermarkRemoveResponse.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 }],
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);
}
};
useAutoResume({
autoResume,
initialMessages,
resumeStream,
setMessages,
});
// 🆕 合并消息:初始消息 + useChat 消息 + appendedMessages
// 确保历史消息不会因为 useChat 内部状态重置而丢失
const mergedMessages = useMemo(() => {
// 使用 Map 进行去重,以 ID 为键
const messageMap = new Map<string, ChatMessage>();
// 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 => {
if (!messageMap.has(m.id)) {
messageMap.set(m.id, m);
}
});
// 按创建时间排序
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 (
<>
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
isReadonly={isReadonly}
selectedVisibilityType={initialVisibilityType}
/>
<Messages
chatId={id}
isArtifactVisible={isArtifactVisible}
isReadonly={isReadonly}
messages={mergedMessages}
regenerate={regenerate}
sendMessage={sendMessage}
selectedModelId={currentModelId}
setMessages={setMessages}
status={status}
votes={votes}
onImageEdit={handleImageEdit}
onDrawingComplete={handleDrawingComplete}
onImageEnhance={handleImageEnhance}
onImageRmbg={handleImageRmbg}
onImageExtend={handleImageExtend}
onImageAngleEdit={handleImageAngleEdit}
onImageWatermarkRemove={handleImageWatermarkRemove}
isProcessingEnhance={isProcessingEnhance}
isProcessingRmbg={isProcessingRmbg}
isProcessingExtend={isProcessingExtend}
isProcessingAngleEdit={isProcessingAngleEdit}
isProcessingWatermarkRemove={isProcessingWatermarkRemove}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
isGeneratingImageFlag={isGeneratingImageFlag}
/>
<div className="sticky bottom-0 z-1 mx-auto w-full max-w-4xl bg-background px-2 pb-3 md:px-4 md:pb-4">
{!isReadonly && (
<MultimodalInput
attachments={attachments}
chatId={id}
input={input}
messages={mergedMessages}
onModelChange={setCurrentModelId}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
usage={usage}
isVideoModel={isVideoModel}
videoModelName={currentModel?.displayName}
videoParams={videoParams}
onVideoParamsChange={(params) => {
setVideoParams(params);
console.log("[Chat] Video params updated:", params);
}}
isImageModel={isImageModel}
imageModelName={currentModel?.displayName}
imageParams={imageParams}
onImageParamsChange={handleImageParamsChange}
imageProviderType={currentModel?.providerType}
/>
)}
</div>
</div>
<Artifact
attachments={attachments}
chatId={id}
input={input}
isReadonly={isReadonly}
messages={mergedMessages}
regenerate={regenerate}
selectedModelId={currentModelId}
selectedVisibilityType={visibilityType}
sendMessage={sendMessage}
setAttachments={setAttachments}
setInput={setInput}
setMessages={setMessages}
status={status}
stop={stop}
votes={votes}
/>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>启用 AI Gateway</AlertDialogTitle>
<AlertDialogDescription>
此应用需要
{process.env.NODE_ENV === "production" ? "项目拥有者" : "你"}
激活 Vercel AI Gateway。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
window.open(
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
"_blank"
);
window.location.href = "/";
}}
>
立即启用
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}