ai-chatbot/components/messages.tsx
Your Name f5c87efba7
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: 统一图片预览按钮样式,防止文字换行
- 添加 whitespace-nowrap 防止按钮文字换行
- 减小按钮尺寸:图标 14px->12px,文字 text-xs->text-[10px]
- 减小按钮间距和内边距,适应不同宽度的图片
- 添加 flex-wrap 允许按钮换行到下一行
2026-01-12 14:14:47 +08:00

374 lines
14 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.

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
});
}
// 对于图片模型,检查是否有图片内容(包含 ![thumb| 或 ![thumb||| 标记)
const hasImageContent = lastMessage?.parts && lastMessage.parts.some(
(part: any) => part.type === "text" && part.text && (
part.text.includes("![thumb|") ||
part.text.includes("![thumb|||") ||
part.text.includes("![Generated Image")
)
);
// 对于其他模型,检查是否有实际的文本内容
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;
// 如果是 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, 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;
});