Your Name 712041bbeb
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
添加模型后备功能和优化图片生成
2025-12-19 15:30:18 +08:00

568 lines
20 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 { useEffect, 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 "@/components/toast";
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);
// 🆕 当检测到恢复的 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]);
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 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) {
setImageParams(JSON.parse(saved));
}
} 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 {
// dataPart.data 是 JSON 字符串
const messageData = typeof dataPart.data === 'string'
? JSON.parse(dataPart.data)
: dataPart.data;
console.log("[Chat] Received appendMessage:", messageData);
// 🆕 收到消息,清除持久化状态
completeGeneration();
setMessages((prevMessages) => {
if (prevMessages.some(m => m.id === messageData.id)) {
return prevMessages;
}
// 构造 ChatMessage 对象
// 注意:这里需要确保符合 ChatMessage 类型定义
const newMessage: any = {
id: messageData.id,
role: messageData.role,
content: messageData.parts?.[0]?.text || "",
createdAt: new Date(),
parts: messageData.parts || [],
attachments: [],
};
return [...prevMessages, newMessage];
});
} catch (e) {
console.error("[Chat] Failed to parse data-appendMessage:", e);
}
}
},
onFinish: () => {
mutate(unstable_serialize(getChatHistoryPaginationKey));
// 🆕 生成完成,清除持久化状态
completeGeneration();
},
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({
type: "error",
description: error.message,
});
}
}
},
});
// 调试:监控聊天状态变化 & 持久化生成状态
// 使用 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";
if (model?.providerType === "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";
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 { 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 = (annotatedImageUrl: string) => {
// annotatedImageUrl 是 base64 data URL 格式
const fileName = `annotated-${Date.now()}.png`;
setAttachments((prev) => [
...prev,
{
url: annotatedImageUrl,
name: fileName,
contentType: "image/png",
},
]);
// 如果输入框为空,自动填入提示词
setInput((prev) => prev ? prev : "请根据我的标注修改这张图片:");
};
useAutoResume({
autoResume,
initialMessages,
resumeStream,
setMessages,
});
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={messages}
regenerate={regenerate}
sendMessage={sendMessage}
selectedModelId={currentModelId}
setMessages={setMessages}
status={status}
votes={votes}
onImageEdit={handleImageEdit}
onDrawingComplete={handleDrawingComplete}
pendingGeneration={pendingGeneration}
imageCount={imageParams?.imageCount || 1}
/>
<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={messages}
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={messages}
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>
</>
);
}