275 lines
9.4 KiB
TypeScript
275 lines
9.4 KiB
TypeScript
import type { UseChatHelpers } from "@ai-sdk/react";
|
||
import equal from "fast-deep-equal";
|
||
import { ArrowDownIcon } from "lucide-react";
|
||
import { memo, useEffect, useState, useRef } from "react";
|
||
import useSWR from "swr";
|
||
import { useMessages } from "@/hooks/use-messages";
|
||
import type { Vote } from "@/lib/db/schema";
|
||
import type { ChatMessage } from "@/lib/types";
|
||
import { useDataStream } from "./data-stream-provider";
|
||
import { GeminiImageLoading } from "./gemini-image-loading";
|
||
import { QianwenVideoLoading } from "./qianwen-video-loading";
|
||
import { VolcanoVisionLoading } from "./volcano-vision-loading";
|
||
import { Greeting } from "./greeting";
|
||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||
|
||
// 模型数据类型
|
||
type DynamicModel = {
|
||
id: string;
|
||
providerType: string;
|
||
modelId: string;
|
||
displayName: string;
|
||
};
|
||
|
||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||
|
||
type MessagesProps = {
|
||
chatId: string;
|
||
status: UseChatHelpers<ChatMessage>["status"];
|
||
votes: Vote[] | undefined;
|
||
messages: ChatMessage[];
|
||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||
isReadonly: boolean;
|
||
isArtifactVisible: boolean;
|
||
selectedModelId: string;
|
||
onImageEdit?: (imageUrl: string) => void;
|
||
onDrawingComplete?: (annotatedImageUrl: string) => void;
|
||
};
|
||
|
||
function PureMessages({
|
||
chatId,
|
||
status,
|
||
votes,
|
||
messages,
|
||
setMessages,
|
||
regenerate,
|
||
sendMessage,
|
||
isReadonly,
|
||
selectedModelId,
|
||
onImageEdit,
|
||
onDrawingComplete,
|
||
}: MessagesProps) {
|
||
const {
|
||
containerRef: messagesContainerRef,
|
||
endRef: messagesEndRef,
|
||
isAtBottom,
|
||
scrollToBottom,
|
||
hasSentMessage,
|
||
} = useMessages({
|
||
status,
|
||
});
|
||
|
||
useDataStream();
|
||
|
||
// 获取模型列表以判断当前模型类型
|
||
const { data: modelsData } = useSWR<{ models: DynamicModel[] }>(
|
||
"/api/models",
|
||
fetcher
|
||
);
|
||
|
||
// 判断当前选中的模型类型
|
||
const isGeminiModel = modelsData?.models?.some(
|
||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "gemini"
|
||
) ?? false;
|
||
|
||
const isQianwenVideoModel = modelsData?.models?.some(
|
||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "qianwen-video"
|
||
) ?? false;
|
||
|
||
const isVolcanoVisionModel = modelsData?.models?.some(
|
||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "volcano-vision"
|
||
) ?? false;
|
||
|
||
// 判断是否是视频生成模型(通过 providerType 或模型名称)
|
||
const isVideoModel = modelsData?.models?.some(
|
||
(m: DynamicModel) =>
|
||
m.id === selectedModelId &&
|
||
(m.providerType === "volcano-video" ||
|
||
m.modelId?.toLowerCase().includes("video") ||
|
||
m.modelId?.toLowerCase().includes("sora") ||
|
||
m.displayName?.includes("视频"))
|
||
) ?? false;
|
||
|
||
// 控制等待动画延迟消失(Gemini 图片模型延迟 4 秒,其他模型 6 秒)
|
||
const [showThinkingWithDelay, setShowThinkingWithDelay] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
||
|
||
// 对于视频模型,检查是否有真正的视频内容(包含 <video 标签或 [视频] 标记)
|
||
const hasVideoContent = lastMessage?.parts && lastMessage.parts.some(
|
||
(part: any) => part.type === "text" && part.text && (
|
||
part.text.includes("[视频]") ||
|
||
part.text.includes("<video") ||
|
||
part.text.includes("视频生成完成")
|
||
)
|
||
);
|
||
|
||
// 调试日志
|
||
if (isVideoModel) {
|
||
const firstPart = lastMessage?.parts?.[0];
|
||
const messageText = firstPart && 'text' in firstPart ? firstPart.text?.substring(0, 100) : undefined;
|
||
console.log("[占位图调试] 状态:", {
|
||
status,
|
||
isVideoModel,
|
||
hasVideoContent,
|
||
messageRole: lastMessage?.role,
|
||
messageText
|
||
});
|
||
}
|
||
|
||
// 对于图片模型,检查是否有图片内容(包含 ![thumb| 标记)
|
||
const hasImageContent = lastMessage?.parts && lastMessage.parts.some(
|
||
(part: any) => part.type === "text" && part.text && part.text.includes("![thumb|")
|
||
);
|
||
|
||
// 对于其他模型,检查是否有实际的文本内容
|
||
const hasTextContent = lastMessage?.parts && lastMessage.parts.some(
|
||
(part: any) => part.type === "text" && part.text && part.text.trim().length > 0
|
||
);
|
||
|
||
// 根据模型类型判断是否有有效内容
|
||
let hasContent = false;
|
||
if (isQianwenVideoModel || isVideoModel) {
|
||
hasContent = hasVideoContent ?? false; // 视频模型:只有真正的视频才算有内容
|
||
} else if (isGeminiModel || isVolcanoVisionModel) {
|
||
hasContent = hasImageContent ?? false; // 图片模型:只有图片才算有内容
|
||
} else {
|
||
hasContent = hasTextContent ?? false; // 其他模型:任何文本都算有内容
|
||
}
|
||
|
||
// 根据模型类型设置延迟时间(视频模型 60 秒,图片模型 4 秒,其他 6 秒)
|
||
const delayTime = (isQianwenVideoModel || isVideoModel) ? 60000 : (isGeminiModel || isVolcanoVisionModel) ? 4000 : 6000;
|
||
|
||
// 如果是 submitted 状态,立即显示
|
||
if (status === "submitted") {
|
||
setShowThinkingWithDelay(true);
|
||
}
|
||
// 如果是 streaming 状态且没有内容,显示
|
||
else if (status === "streaming" && lastMessage?.role === "assistant" && !hasContent) {
|
||
setShowThinkingWithDelay(true);
|
||
}
|
||
// 如果 AI 开始有内容了,延迟后隐藏
|
||
else if (status === "streaming" && lastMessage?.role === "assistant" && hasContent) {
|
||
const timer = setTimeout(() => {
|
||
setShowThinkingWithDelay(false);
|
||
}, delayTime);
|
||
|
||
return () => clearTimeout(timer);
|
||
}
|
||
// 其他情况立即隐藏
|
||
else {
|
||
setShowThinkingWithDelay(false);
|
||
}
|
||
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel]);
|
||
|
||
// 🆕 监听新消息添加,当用户发送带图片的消息时,强制滚动到底部
|
||
const prevMessagesLengthRef = useRef(messages.length);
|
||
useEffect(() => {
|
||
if (messages.length > prevMessagesLengthRef.current) {
|
||
const lastMessage = messages[messages.length - 1];
|
||
// 如果是新添加的用户消息且包含图片附件,强制滚动到底部
|
||
if (lastMessage?.role === "user") {
|
||
const hasImageAttachment = lastMessage.parts?.some(
|
||
(part: any) => part.type === "file" && part.mediaType?.startsWith("image/")
|
||
);
|
||
if (hasImageAttachment) {
|
||
// 延迟一小段时间确保DOM已更新
|
||
setTimeout(() => scrollToBottom("smooth"), 100);
|
||
}
|
||
}
|
||
}
|
||
prevMessagesLengthRef.current = messages.length;
|
||
}, [messages, scrollToBottom]);
|
||
|
||
return (
|
||
<div className="relative flex-1">
|
||
<div
|
||
className="absolute inset-0 touch-pan-y overflow-y-auto"
|
||
ref={messagesContainerRef}
|
||
>
|
||
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
|
||
{messages.length === 0 && <Greeting />}
|
||
|
||
{messages.map((message, index) => (
|
||
<PreviewMessage
|
||
chatId={chatId}
|
||
isLoading={
|
||
status === "streaming" && messages.length - 1 === index
|
||
}
|
||
isReadonly={isReadonly}
|
||
key={message.id}
|
||
message={message}
|
||
regenerate={regenerate}
|
||
sendMessage={sendMessage}
|
||
requiresScrollPadding={
|
||
hasSentMessage && index === messages.length - 1
|
||
}
|
||
setMessages={setMessages}
|
||
vote={
|
||
votes
|
||
? votes.find((vote) => vote.messageId === message.id)
|
||
: undefined
|
||
}
|
||
onImageEdit={onImageEdit}
|
||
onDrawingComplete={onDrawingComplete}
|
||
/>
|
||
))}
|
||
|
||
{/* 等待动画:根据模型类型显示不同的加载效果 */}
|
||
{showThinkingWithDelay && (
|
||
(isQianwenVideoModel || isVideoModel) ? <QianwenVideoLoading /> :
|
||
isGeminiModel ? <GeminiImageLoading /> :
|
||
isVolcanoVisionModel ? <VolcanoVisionLoading /> :
|
||
<ThinkingMessage />
|
||
)}
|
||
|
||
<div
|
||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||
ref={messagesEndRef}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
aria-label="滚动至底部"
|
||
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||
isAtBottom
|
||
? "pointer-events-none scale-0 opacity-0"
|
||
: "pointer-events-auto scale-100 opacity-100"
|
||
}`}
|
||
onClick={() => scrollToBottom("smooth")}
|
||
type="button"
|
||
>
|
||
<ArrowDownIcon className="size-4" />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const Messages = memo(PureMessages, (prevProps: MessagesProps, nextProps: MessagesProps) => {
|
||
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
|
||
return true;
|
||
}
|
||
|
||
if (prevProps.status !== nextProps.status) {
|
||
return false;
|
||
}
|
||
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
||
return false;
|
||
}
|
||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||
return false;
|
||
}
|
||
if (!equal(prevProps.messages, nextProps.messages)) {
|
||
return false;
|
||
}
|
||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||
return false;
|
||
}
|
||
|
||
return false;
|
||
});
|