424 lines
16 KiB
TypeScript
424 lines
16 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 { PendingGeneration } from "@/hooks/use-pending-generation";
|
||
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 { ImageGenerationLoading } from "./image-generation-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;
|
||
onImageEnhance?: (imageUrl: string) => void;
|
||
onImageRmbg?: (imageUrl: string) => void;
|
||
onImageExtend?: (imageUrl: string) => void;
|
||
onImageAngleEdit?: (imageUrl: string) => void;
|
||
isProcessingEnhance?: boolean;
|
||
isProcessingRmbg?: boolean;
|
||
isProcessingExtend?: boolean;
|
||
isProcessingAngleEdit?: boolean;
|
||
pendingGeneration?: PendingGeneration | null; // 🆕 持久化的生成状态
|
||
imageCount?: number; // 🆕 用户选择的图片数量
|
||
};
|
||
|
||
function PureMessages({
|
||
chatId,
|
||
status,
|
||
votes,
|
||
messages,
|
||
setMessages,
|
||
regenerate,
|
||
sendMessage,
|
||
isReadonly,
|
||
selectedModelId,
|
||
onImageEdit,
|
||
onDrawingComplete,
|
||
onImageEnhance,
|
||
onImageRmbg,
|
||
onImageExtend,
|
||
onImageAngleEdit,
|
||
isProcessingEnhance = false,
|
||
isProcessingRmbg = false,
|
||
isProcessingExtend = false,
|
||
isProcessingAngleEdit = false,
|
||
pendingGeneration,
|
||
imageCount = 1,
|
||
}: 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;
|
||
|
||
// 🆕 判断是否是阿里生图模型
|
||
const isAlibabaImageModel = modelsData?.models?.some(
|
||
(m: DynamicModel) => m.id === selectedModelId && m.providerType === "alibaba-image"
|
||
) ?? false;
|
||
|
||
// 🆕 获取当前模型的显示名称
|
||
const currentModelDisplayName = modelsData?.models?.find(
|
||
(m: DynamicModel) => m.id === selectedModelId
|
||
)?.displayName;
|
||
|
||
// 判断是否是视频生成模型(通过 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
|
||
});
|
||
}
|
||
|
||
// 对于图片模型,检查是否有图片内容(更全面的匹配)
|
||
const hasImageContent = lastMessage?.parts && lastMessage.parts.some(
|
||
(part: any) => part.type === "text" && part.text && (
|
||
part.text.includes(" || // Markdown 图片链接
|
||
part.text.includes(".png)") ||
|
||
part.text.includes(".jpg)") ||
|
||
part.text.includes(".jpeg)") ||
|
||
part.text.includes(".webp)") ||
|
||
part.text.includes("图片生成") ||
|
||
part.text.includes("已生成")
|
||
)
|
||
);
|
||
|
||
// 对于其他模型,检查是否有实际的文本内容
|
||
const hasTextContent = lastMessage?.parts && lastMessage.parts.some(
|
||
(part: any) => part.type === "text" && part.text && part.text.trim().length > 0
|
||
);
|
||
|
||
// 判断是否是图片生成模型(包括 Gemini、火山、阿里)
|
||
const isImageGenModel = isGeminiModel || isVolcanoVisionModel || isAlibabaImageModel;
|
||
|
||
// 根据模型类型判断是否有有效内容
|
||
let hasContent = false;
|
||
if (isQianwenVideoModel || isVideoModel) {
|
||
hasContent = hasVideoContent ?? false; // 视频模型:只有真正的视频才算有内容
|
||
} else if (isImageGenModel) {
|
||
hasContent = hasImageContent ?? false; // 图片模型:只有图片才算有内容
|
||
} else {
|
||
hasContent = hasTextContent ?? false; // 其他模型:任何文本都算有内容
|
||
}
|
||
|
||
// 根据模型类型设置延迟时间(视频模型 60 秒,图片模型 4 秒,其他 6 秒)
|
||
const delayTime = (isQianwenVideoModel || isVideoModel) ? 60000 : isImageGenModel ? 4000 : 6000;
|
||
|
||
// 🆕 超时保护:如果占位图显示超过 3 分钟,强制隐藏(防止卡住)
|
||
const maxShowTime = 3 * 60 * 1000; // 3 分钟
|
||
|
||
// 用于存储需要清理的定时器
|
||
let delayTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
// 如果是 submitted 状态,立即显示
|
||
if (status === "submitted") {
|
||
setShowThinkingWithDelay(true);
|
||
thinkingStartTimeRef.current = Date.now();
|
||
}
|
||
// 🆕 如果是 streaming 状态但最后一条是用户消息(还没有 AI 回复),也显示占位图
|
||
else if (status === "streaming" && lastMessage?.role === "user") {
|
||
setShowThinkingWithDelay(true);
|
||
if (!thinkingStartTimeRef.current) {
|
||
thinkingStartTimeRef.current = Date.now();
|
||
}
|
||
}
|
||
// 如果是 streaming 状态且没有内容,显示
|
||
else if (status === "streaming" && lastMessage?.role === "assistant" && !hasContent) {
|
||
setShowThinkingWithDelay(true);
|
||
if (!thinkingStartTimeRef.current) {
|
||
thinkingStartTimeRef.current = Date.now();
|
||
}
|
||
}
|
||
// 如果 AI 开始有内容了,延迟后隐藏
|
||
else if (status === "streaming" && lastMessage?.role === "assistant" && hasContent) {
|
||
delayTimer = setTimeout(() => {
|
||
setShowThinkingWithDelay(false);
|
||
thinkingStartTimeRef.current = null;
|
||
}, delayTime);
|
||
}
|
||
// 🆕 如果状态是 ready(生成完成),立即隐藏
|
||
else if (status === "ready") {
|
||
setShowThinkingWithDelay(false);
|
||
thinkingStartTimeRef.current = null;
|
||
}
|
||
// 其他情况立即隐藏
|
||
else {
|
||
setShowThinkingWithDelay(false);
|
||
thinkingStartTimeRef.current = null;
|
||
}
|
||
|
||
// 🆕 超时保护定时器(只在显示占位图时启动)
|
||
if (status === "submitted" || status === "streaming") {
|
||
timeoutTimer = setTimeout(() => {
|
||
if (thinkingStartTimeRef.current && Date.now() - thinkingStartTimeRef.current > maxShowTime) {
|
||
console.warn("[占位图] ⚠️ 超时保护触发:占位图显示超过 3 分钟,强制隐藏");
|
||
setShowThinkingWithDelay(false);
|
||
thinkingStartTimeRef.current = null;
|
||
}
|
||
}, maxShowTime);
|
||
}
|
||
|
||
// 清理所有定时器
|
||
return () => {
|
||
if (delayTimer) clearTimeout(delayTimer);
|
||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||
};
|
||
}, [status, messages, isGeminiModel, isQianwenVideoModel, isVolcanoVisionModel, isVideoModel, isAlibabaImageModel]);
|
||
|
||
// 🆕 监听新消息添加,当用户发送带图片的消息或添加加载消息时,强制滚动到底部
|
||
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);
|
||
}
|
||
}
|
||
// 如果是新添加的助手消息且是加载消息(正在变清晰/抠图/图像拓展),强制滚动到底部
|
||
if (lastMessage?.role === "assistant") {
|
||
const firstPart = lastMessage.parts?.[0];
|
||
if (firstPart && "text" in firstPart) {
|
||
const loadingText = firstPart.text;
|
||
if (loadingText === "正在变清晰..." || loadingText === "正在抠图..." || loadingText === "正在图像拓展...") {
|
||
// 延迟一小段时间确保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}
|
||
onImageEnhance={onImageEnhance}
|
||
onImageRmbg={onImageRmbg}
|
||
onImageExtend={onImageExtend}
|
||
onImageAngleEdit={onImageAngleEdit}
|
||
isProcessingEnhance={isProcessingEnhance}
|
||
isProcessingRmbg={isProcessingRmbg}
|
||
isProcessingExtend={isProcessingExtend}
|
||
isProcessingAngleEdit={isProcessingAngleEdit}
|
||
/>
|
||
))}
|
||
|
||
{/* 等待动画:根据模型类型显示不同的加载效果 */}
|
||
{/* 🆕 支持恢复的生成状态(用户离开再返回) */}
|
||
{(() => {
|
||
// 检查最后一条消息是否是用户消息(表示还在等待 AI 响应)
|
||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null;
|
||
const isWaitingForResponse = lastMessage?.role === "user";
|
||
|
||
// 恢复的待处理状态:有 pendingGeneration 且最后一条是用户消息
|
||
const hasRestoredPending = pendingGeneration && isWaitingForResponse;
|
||
|
||
// 🔍 调试日志
|
||
console.log("[Messages] 占位图状态检查:", {
|
||
pendingGeneration,
|
||
lastMessageRole: lastMessage?.role,
|
||
isWaitingForResponse,
|
||
hasRestoredPending,
|
||
showThinkingWithDelay,
|
||
messagesCount: messages.length,
|
||
});
|
||
|
||
// 显示条件:正常等待动画 或 恢复的待处理状态
|
||
const shouldShow = showThinkingWithDelay || hasRestoredPending;
|
||
|
||
if (!shouldShow) {
|
||
console.log("[Messages] 不显示占位图: shouldShow=false");
|
||
return null;
|
||
}
|
||
|
||
console.log("[Messages] ✅ 显示占位图!");
|
||
|
||
// 优先使用恢复的状态,否则使用当前模型类型
|
||
const modelType = pendingGeneration?.modelType;
|
||
const showGemini = modelType === "gemini" || (!modelType && isGeminiModel);
|
||
const showVolcano = modelType === "volcano-vision" || (!modelType && isVolcanoVisionModel);
|
||
const showAlibabaImage = modelType === "alibaba-image" || (!modelType && isAlibabaImageModel);
|
||
const showVideo = modelType === "volcano-video" || modelType === "qianwen-video" || (!modelType && (isQianwenVideoModel || isVideoModel));
|
||
|
||
if (showVideo) return <QianwenVideoLoading />;
|
||
if (showGemini) return <GeminiImageLoading imageCount={imageCount} />;
|
||
if (showVolcano) return <VolcanoVisionLoading imageCount={imageCount} />;
|
||
// 🆕 阿里生图模型使用通用组件,橙色主题,最多 6 张,传递自定义模型名称
|
||
if (showAlibabaImage) return <ImageGenerationLoading imageCount={imageCount} maxImages={6} providerType="alibaba-image" modelName={currentModelDisplayName} />;
|
||
return <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 (!equal(prevProps.pendingGeneration, nextProps.pendingGeneration)) {
|
||
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;
|
||
});
|