Guard the auto image-loading placeholder so it starts only after a visible text reply and cancel pending timers when the image arrives.
1430 lines
51 KiB
TypeScript
1430 lines
51 KiB
TypeScript
"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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息(前端显示用)
|
||
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 = ``;
|
||
|
||
// 创建最终消息
|
||
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>
|
||
</>
|
||
);
|
||
}
|