| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641 |
- "use client";
- import { useEffect, useRef, useState } from "react";
- import { MessageItem } from "@/features/agent/types";
- import { formatMessageTime } from "@/utils/format";
- import { highlightText } from "@/utils/highlight";
- import { Badge } from "@/components/ui/badge";
- import { Button } from "@/components/ui/button";
- import { Dialog, DialogContent } from "@/components/ui/dialog";
- import { Paperclip, Download, X } from "lucide-react";
- import { API_BASE_URL } from "@/lib/config";
- import { getAvatarUrl } from "@/utils/avatar";
- function TypewriterText({
- text,
- animateKey,
- speedMs = 18,
- }: {
- text: string;
- animateKey: number | string;
- speedMs?: number;
- }) {
- const [shown, setShown] = useState("");
- useEffect(() => {
- setShown("");
- }, [animateKey, text]);
- useEffect(() => {
- if (!text) return;
- const len = text.length;
- // 性能优先:很长的文本不可能真的每 1 个字符 setState 一次
- // 但短文本保持更细粒度,让你看到“逐字打出”的效果。
- const chunkSize = len < 250 ? 1 : len < 800 ? 2 : 4;
- let idx = 0;
- const timer = window.setInterval(() => {
- idx = Math.min(len, idx + chunkSize);
- setShown(text.slice(0, idx));
- if (idx >= len) {
- window.clearInterval(timer);
- }
- }, speedMs);
- return () => {
- window.clearInterval(timer);
- };
- }, [text, animateKey, speedMs]);
- return <>{shown}</>;
- }
- interface MessageListProps {
- messages: MessageItem[];
- loading: boolean;
- highlightKeyword: string;
- onHighlightClear: () => void;
- currentUserIsAgent?: boolean;
- disableAutoScroll?: boolean;
- conversationId?: number | null;
- onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
- /** 底部插槽(如 AI 正在输入提示),会渲染在消息列表最下方并参与滚动 */
- bottomSlot?: React.ReactNode;
- /** 知识库测试(内部对话)模式:AI 回复(sender_id=0)显示在左侧,客服消息显示在右侧 */
- internalChatMode?: boolean;
- /** 访客侧左侧消息头像(key 为 sender_id) */
- leftAvatarBySenderId?: Record<number, string | null | undefined>;
- }
- export function MessageList({
- messages,
- loading,
- highlightKeyword,
- onHighlightClear,
- currentUserIsAgent = true,
- disableAutoScroll = false,
- conversationId = null,
- onMarkMessagesRead,
- bottomSlot,
- internalChatMode = false,
- leftAvatarBySenderId,
- }: MessageListProps) {
- const containerRef = useRef<HTMLDivElement>(null);
- const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
- const shouldStickToBottomRef = useRef(true);
- const lastConversationIdRef = useRef<number | null>(null);
- const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
- const lastMarkedReadRef = useRef<number>(0);
- const lastMessageIdRef = useRef<number | null>(null);
- const lastMessageCountRef = useRef<number>(0);
- const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
- // 图片预览状态(必须在所有条件返回之前声明)
- const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
- const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
- useEffect(() => {
- if (conversationId !== lastConversationIdRef.current) {
- lastConversationIdRef.current = conversationId;
- shouldStickToBottomRef.current = true;
- lastMessageIdRef.current = null;
- lastMessageCountRef.current = 0;
- hasInitialScrolledRef.current = false; // 重置初始滚动标记
- }
- }, [conversationId]);
- // 监听滚动事件,当滚动到底部附近时标记消息为已读
- // 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
- useEffect(() => {
- const container = containerRef.current;
- if (!container || !conversationId || !onMarkMessagesRead) {
- return;
- }
- const handleScroll = () => {
- const { scrollTop, scrollHeight, clientHeight } = container;
- const distanceToBottom = scrollHeight - scrollTop - clientHeight;
- const isNearBottom = distanceToBottom < 100;
- shouldStickToBottomRef.current = isNearBottom;
- // 当滚动到底部附近时,检查是否有未读消息需要标记为已读
- if (isNearBottom) {
- // 防抖:延迟 500ms 后标记为已读,避免频繁调用
- if (markReadTimerRef.current) {
- clearTimeout(markReadTimerRef.current);
- }
- markReadTimerRef.current = setTimeout(() => {
- // 检查是否有未读的消息(对方发送的消息)
- const unreadMessages = messages.filter((msg) => {
- const isFromOther = internalChatMode
- ? msg.sender_is_agent && msg.sender_id === 0 // 内部对话:AI 回复视为对方
- : currentUserIsAgent
- ? !msg.sender_is_agent
- : msg.sender_is_agent;
- return isFromOther && !msg.is_read;
- });
- if (unreadMessages.length > 0) {
- // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
- const now = Date.now();
- if (now - lastMarkedReadRef.current < 2000) {
- return;
- }
- // 标记为已读
- onMarkMessagesRead(conversationId, currentUserIsAgent);
- lastMarkedReadRef.current = now;
- }
- }, 500);
- }
- };
- handleScroll();
- container.addEventListener("scroll", handleScroll);
- return () => {
- container.removeEventListener("scroll", handleScroll);
- if (markReadTimerRef.current) {
- clearTimeout(markReadTimerRef.current);
- }
- };
- }, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent, internalChatMode]);
- useEffect(() => {
- if (messages.length === 0) {
- return;
- }
- const container = containerRef.current;
- if (!container) {
- return;
- }
- const keyword = highlightKeyword.trim();
- const lastMessage = messages[messages.length - 1];
- const isLastMessageFromCurrentUser = lastMessage
- ? currentUserIsAgent
- ? lastMessage.sender_is_agent
- : !lastMessage.sender_is_agent
- : false;
- // 检查是否有新消息(通过比较消息ID或消息数量)
- const hasNewMessage =
- lastMessage.id !== lastMessageIdRef.current ||
- messages.length !== lastMessageCountRef.current;
- // 更新记录
- lastMessageIdRef.current = lastMessage.id;
- lastMessageCountRef.current = messages.length;
- // 使用 requestAnimationFrame 确保 DOM 已更新后再检查位置
- requestAnimationFrame(() => {
- // 重新获取容器引用,确保使用最新的 DOM 元素
- const currentContainer = containerRef.current;
- if (!currentContainer) {
- return;
- }
- // 对于新消息,需要延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
- // 使用双重 requestAnimationFrame + 小延迟,给图片加载留出时间
- const checkAndScroll = () => {
- const container = containerRef.current;
- if (!container) {
- return;
- }
- // 在 DOM 更新后检查当前位置
- const { scrollTop, scrollHeight, clientHeight } = container;
- const distanceToBottom = scrollHeight - scrollTop - clientHeight;
- const isNearBottom = distanceToBottom < 100;
- // 更新 shouldStickToBottomRef,确保使用最新的位置信息
- shouldStickToBottomRef.current = isNearBottom;
- // 检查是否是初始加载(首次加载消息或切换对话后首次加载)
- const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
- // 滚动逻辑:
- // 1. 如果是初始加载(首次加载消息或切换对话),无论什么情况都自动滚动到底部
- // 2. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
- // 3. 如果最后一条消息是对方发送的:
- // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
- // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
- // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
- // 4. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
- // 这样确保访客端和客服端的行为一致:初始加载时显示最新消息,当用户在底部附近时,收到新消息会自动滚动到底部
- const shouldAutoScroll =
- isInitialLoad ||
- (hasNewMessage &&
- (isLastMessageFromCurrentUser ||
- isNearBottom ||
- (!currentUserIsAgent && !isLastMessageFromCurrentUser)));
- if (keyword) {
- const keywordLower = keyword.toLowerCase();
- const matchingMessage = messages.find((message) =>
- message.content.toLowerCase().includes(keywordLower)
- );
- if (matchingMessage) {
- const scroll = () => {
- const target = messageRefs.current[matchingMessage.id];
- if (target) {
- target.scrollIntoView({
- behavior: "smooth",
- block: "center",
- inline: "nearest",
- });
- }
- setTimeout(onHighlightClear, 3000);
- };
- setTimeout(scroll, 200);
- } else {
- if (!shouldAutoScroll) {
- return;
- }
- const scrollBottom = () => {
- const container = containerRef.current;
- if (!container) {
- return;
- }
- container.scrollTo({
- top: container.scrollHeight,
- behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
- });
- // 标记初始滚动已完成
- if (isInitialLoad) {
- hasInitialScrolledRef.current = true;
- }
- };
- setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
- onHighlightClear();
- }
- } else {
- if (!shouldAutoScroll) {
- return;
- }
- const scrollBottom = () => {
- const container = containerRef.current;
- if (!container) {
- return;
- }
- if (container.scrollHeight === container.clientHeight && container.parentElement) {
- const parent = container.parentElement;
- const parentHeight = parent.offsetHeight;
- container.style.height = `${parentHeight}px`;
- container.style.maxHeight = `${parentHeight}px`;
- }
- // 访客端收到对方(如 AI)的新消息时:从该气泡头部开始显示,长消息无需往上翻
- const lastMsgEl = messageRefs.current[lastMessage.id];
- if (
- lastMsgEl &&
- !currentUserIsAgent &&
- !isLastMessageFromCurrentUser
- ) {
- lastMsgEl.scrollIntoView({
- block: "start",
- behavior: isInitialLoad ? "auto" : "smooth",
- inline: "nearest",
- });
- } else {
- container.scrollTo({
- top: container.scrollHeight,
- behavior: isInitialLoad ? "auto" : "smooth",
- });
- }
- if (isInitialLoad) {
- hasInitialScrolledRef.current = true;
- }
- };
- setTimeout(scrollBottom, isInitialLoad ? 0 : 100);
- }
- // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
- // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
- if (conversationId && onMarkMessagesRead && messages.length > 0) {
- // 延迟标记为已读,确保滚动动画完成
- if (markReadTimerRef.current) {
- clearTimeout(markReadTimerRef.current);
- }
- markReadTimerRef.current = setTimeout(() => {
- // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
- const shouldMarkRead = shouldAutoScroll || isNearBottom;
- if (!shouldMarkRead) {
- return;
- }
- const unreadMessages = messages.filter((msg) => {
- const isFromOther = internalChatMode
- ? msg.sender_is_agent && msg.sender_id === 0
- : currentUserIsAgent
- ? !msg.sender_is_agent
- : msg.sender_is_agent;
- return isFromOther && !msg.is_read;
- });
- if (unreadMessages.length > 0) {
- // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
- const now = Date.now();
- if (now - lastMarkedReadRef.current < 2000) {
- return;
- }
- onMarkMessagesRead(conversationId, currentUserIsAgent);
- lastMarkedReadRef.current = now;
- }
- }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
- }
- };
- // 对于新消息,延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
- if (hasNewMessage) {
- // 检查最后一条消息是否包含图片/文件
- const lastMessageHasFile = lastMessage.file_url;
-
- if (lastMessageHasFile) {
- // 如果包含文件,延迟更长时间,确保图片加载完成
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- setTimeout(() => {
- checkAndScroll();
- }, 200); // 给图片加载留出更多时间
- });
- });
- } else {
- // 普通消息,正常延迟
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- checkAndScroll();
- });
- });
- }
- } else {
- // 非新消息(如状态更新),直接检查
- checkAndScroll();
- }
- });
- }, [
- messages,
- highlightKeyword,
- onHighlightClear,
- disableAutoScroll,
- currentUserIsAgent,
- conversationId,
- onMarkMessagesRead,
- internalChatMode,
- ]);
- if (loading) {
- return (
- <div className="flex-1 flex items-center justify-center bg-muted/30">
- <span className="text-sm text-muted-foreground">消息加载中...</span>
- </div>
- );
- }
- if (messages.length === 0) {
- return (
- <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-3 bg-muted/20 scrollbar-auto">
- <div className="text-center text-muted-foreground mt-8 text-sm">暂无消息</div>
- {bottomSlot ? <div className="mt-4">{bottomSlot}</div> : null}
- </div>
- );
- }
- return (
- <>
- {/* 图片预览对话框 */}
- <Dialog open={imagePreviewOpen} onOpenChange={setImagePreviewOpen}>
- <DialogContent className="max-w-4xl max-h-[90vh] p-0">
- {previewImageUrl && (
- <div className="relative">
- <Button
- variant="ghost"
- size="sm"
- className="absolute top-2 right-2 z-10"
- onClick={() => setImagePreviewOpen(false)}
- >
- <X className="w-4 h-4" />
- </Button>
- <img
- src={previewImageUrl}
- alt="预览"
- className="w-full h-auto max-h-[90vh] object-contain"
- />
- </div>
- )}
- </DialogContent>
- </Dialog>
- <div
- ref={containerRef}
- className="h-full w-full overflow-y-auto p-3 bg-muted/20 scrollbar-auto"
- style={{ height: '100%' }}
- >
- <div className="space-y-3.5">
- {messages.map((message) => {
- const keyword = highlightKeyword.trim();
- const isMatching =
- keyword !== "" &&
- message.content.toLowerCase().includes(keyword.toLowerCase());
- const bubbleContent =
- keyword !== "" && isMatching
- ? highlightText(message.content, keyword)
- : message.content;
- const isAIMessage = Boolean(message.sender_is_agent) && message.sender_id === 0;
- // 仅当不需要高亮搜索关键词、且该消息为 AI 回复时才启用逐字显示
- const shouldTypewriter =
- isAIMessage &&
- keyword === "" &&
- !message.file_url &&
- typeof message.content === "string" &&
- message.content.length > 0;
- if (message.message_type === "system_message") {
- return (
- <div
- key={message.id}
- ref={(element) => {
- messageRefs.current[message.id] = element;
- }}
- className="text-center text-xs text-muted-foreground/90"
- >
- <Badge variant="secondary" className="inline-block border border-border/40 bg-background/70 text-muted-foreground">
- {message.content}
- </Badge>
- </div>
- );
- }
- const isSenderAgent = Boolean(message.sender_is_agent);
- // 内部对话(知识库测试):AI 回复 sender_id=0 显示左侧,客服消息显示右侧
- const isCurrentUser = internalChatMode
- ? isSenderAgent && message.sender_id !== 0
- : currentUserIsAgent
- ? isSenderAgent
- : !isSenderAgent;
- const alignment = isCurrentUser ? "justify-end" : "justify-start";
- const bubbleColor = isCurrentUser
- ? "bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20"
- : "bg-background/95 text-card-foreground border border-border/45 shadow-[0_1px_4px_rgba(15,23,42,0.06)]";
- // 拉开双方气泡圆角差异:自己消息更利落、对方消息更柔和,便于快速分辨
- const cornerClass = isCurrentUser
- ? "rounded-[18px] rounded-br-md"
- : "rounded-[18px] rounded-bl-md";
- // 计算已读回执的样式类名
- // 统一使用相同的样式:蓝色半透明(text-primary/70)
- // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
- const receiptClass = isCurrentUser ? "text-primary/70" : "";
- // 文件相关
- const hasFile = Boolean(message.file_url);
- const isImage = message.file_type === "image";
- const isDocument = message.file_type === "document";
- // 获取文件URL(完整URL)
- const getFileUrl = (fileUrl: string | null | undefined): string => {
- if (!fileUrl) return "";
- if (fileUrl.startsWith("http")) return fileUrl;
- return `${API_BASE_URL}${fileUrl}`;
- };
- // 格式化文件大小
- const formatFileSize = (bytes: number | null | undefined): string => {
- if (!bytes) return "";
- if (bytes < 1024) return bytes + " B";
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
- return (bytes / (1024 * 1024)).toFixed(1) + " MB";
- };
- // 打开图片预览
- const handleImageClick = (url: string) => {
- setPreviewImageUrl(url);
- setImagePreviewOpen(true);
- };
- // 下载文件
- const handleDownload = (url: string, fileName: string | null | undefined) => {
- const link = document.createElement("a");
- link.href = url;
- link.download = fileName || "file";
- link.target = "_blank";
- document.body.appendChild(link);
- link.click();
- document.body.removeChild(link);
- };
- const leftAvatarUrl = !isCurrentUser ? getAvatarUrl(leftAvatarBySenderId?.[message.sender_id]) : null;
- const showLeftAvatar = !isCurrentUser && Boolean(leftAvatarBySenderId);
- return (
- <div
- key={message.id}
- ref={(element) => {
- messageRefs.current[message.id] = element;
- }}
- className={`flex ${alignment} items-end gap-2`}
- >
- {showLeftAvatar ? (
- <div className="w-7 h-7 rounded-full overflow-hidden bg-slate-200 border border-slate-300 flex-shrink-0">
- {leftAvatarUrl ? (
- <img src={leftAvatarUrl} alt="客服头像" className="w-full h-full object-cover" />
- ) : (
- <div className="w-full h-full flex items-center justify-center text-[10px] text-slate-600">客</div>
- )}
- </div>
- ) : null}
- <div className="max-w-[72%]">
- <div
- className={`px-3.5 py-2.5 rounded-2xl ${
- cornerClass
- } ${bubbleColor} transition-shadow hover:shadow-sm`}
- >
- {/* 文本内容 */}
- {message.content && (
- <div className="whitespace-pre-wrap break-words text-sm">
- {shouldTypewriter ? (
- <TypewriterText text={message.content} animateKey={message.id} />
- ) : (
- bubbleContent
- )}
- </div>
- )}
-
- {/* 文件显示 */}
- {hasFile && message.file_url && (
- <div className={message.content ? "mt-2" : ""}>
- {isImage ? (
- // 图片预览
- <div
- className="cursor-pointer rounded-lg overflow-hidden max-w-[300px] border border-border/30 hover:border-primary/50 transition-colors shadow-sm"
- onClick={() => handleImageClick(getFileUrl(message.file_url))}
- >
- <img
- src={getFileUrl(message.file_url)}
- alt={message.file_name || "图片"}
- className="max-w-full h-auto"
- loading="lazy"
- />
- </div>
- ) : isDocument ? (
- // 文档显示
- <div className="flex items-center gap-2 p-3 bg-background/60 rounded-lg border border-border/30 hover:bg-background/80 transition-colors">
- <Paperclip className="w-4 h-4 flex-shrink-0" />
- <div className="flex-1 min-w-0">
- <div className="text-sm font-medium truncate">
- {message.file_name || "文件"}
- </div>
- {message.file_size && (
- <div className="text-xs text-muted-foreground">
- {formatFileSize(message.file_size)}
- </div>
- )}
- </div>
- <Button
- variant="ghost"
- size="sm"
- onClick={() =>
- handleDownload(
- getFileUrl(message.file_url),
- message.file_name
- )
- }
- className="flex-shrink-0"
- >
- <Download className="w-4 h-4" />
- </Button>
- </div>
- ) : null}
- </div>
- )}
- </div>
- <div className="flex items-center gap-1 mt-1.5 px-0.5 text-[10px] text-muted-foreground/80">
- {isCurrentUser && (
- <span className={receiptClass}>
- {message.is_read ? "✓✓" : "✓"}
- </span>
- )}
- <span>{formatMessageTime(message.created_at)}</span>
- </div>
- {/* AI 回复的数据源标记(仅对方消息且存在 sources_used 时显示) */}
- {!isCurrentUser && message.sources_used && (
- <div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-x-2 gap-y-0">
- {message.sources_used.split(",").map((s) => s.trim()).filter(Boolean).map((src) => (
- <span key={src}>
- {src === "knowledge_base" && "已使用知识库"}
- {src === "llm" && "已使用大模型"}
- {src === "web" && "已使用联网搜索"}
- </span>
- ))}
- </div>
- )}
- </div>
- </div>
- );
- })}
- </div>
- {bottomSlot}
- </div>
- </>
- );
- }
|