MessageList.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. "use client";
  2. import { useEffect, useRef, useState } from "react";
  3. import { MessageItem } from "@/features/agent/types";
  4. import { formatMessageTime } from "@/utils/format";
  5. import { highlightText } from "@/utils/highlight";
  6. import { Badge } from "@/components/ui/badge";
  7. import { Button } from "@/components/ui/button";
  8. import { Dialog, DialogContent } from "@/components/ui/dialog";
  9. import { Paperclip, Download, X } from "lucide-react";
  10. import { API_BASE_URL } from "@/lib/config";
  11. import { getAvatarUrl } from "@/utils/avatar";
  12. function TypewriterText({
  13. text,
  14. animateKey,
  15. speedMs = 18,
  16. }: {
  17. text: string;
  18. animateKey: number | string;
  19. speedMs?: number;
  20. }) {
  21. const [shown, setShown] = useState("");
  22. useEffect(() => {
  23. setShown("");
  24. }, [animateKey, text]);
  25. useEffect(() => {
  26. if (!text) return;
  27. const len = text.length;
  28. // 性能优先:很长的文本不可能真的每 1 个字符 setState 一次
  29. // 但短文本保持更细粒度,让你看到“逐字打出”的效果。
  30. const chunkSize = len < 250 ? 1 : len < 800 ? 2 : 4;
  31. let idx = 0;
  32. const timer = window.setInterval(() => {
  33. idx = Math.min(len, idx + chunkSize);
  34. setShown(text.slice(0, idx));
  35. if (idx >= len) {
  36. window.clearInterval(timer);
  37. }
  38. }, speedMs);
  39. return () => {
  40. window.clearInterval(timer);
  41. };
  42. }, [text, animateKey, speedMs]);
  43. return <>{shown}</>;
  44. }
  45. interface MessageListProps {
  46. messages: MessageItem[];
  47. loading: boolean;
  48. highlightKeyword: string;
  49. onHighlightClear: () => void;
  50. currentUserIsAgent?: boolean;
  51. disableAutoScroll?: boolean;
  52. conversationId?: number | null;
  53. onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
  54. /** 底部插槽(如 AI 正在输入提示),会渲染在消息列表最下方并参与滚动 */
  55. bottomSlot?: React.ReactNode;
  56. /** 知识库测试(内部对话)模式:AI 回复(sender_id=0)显示在左侧,客服消息显示在右侧 */
  57. internalChatMode?: boolean;
  58. /** 访客侧左侧消息头像(key 为 sender_id) */
  59. leftAvatarBySenderId?: Record<number, string | null | undefined>;
  60. }
  61. export function MessageList({
  62. messages,
  63. loading,
  64. highlightKeyword,
  65. onHighlightClear,
  66. currentUserIsAgent = true,
  67. disableAutoScroll = false,
  68. conversationId = null,
  69. onMarkMessagesRead,
  70. bottomSlot,
  71. internalChatMode = false,
  72. leftAvatarBySenderId,
  73. }: MessageListProps) {
  74. const containerRef = useRef<HTMLDivElement>(null);
  75. const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
  76. const shouldStickToBottomRef = useRef(true);
  77. const lastConversationIdRef = useRef<number | null>(null);
  78. const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
  79. const lastMarkedReadRef = useRef<number>(0);
  80. const lastMessageIdRef = useRef<number | null>(null);
  81. const lastMessageCountRef = useRef<number>(0);
  82. const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
  83. // 图片预览状态(必须在所有条件返回之前声明)
  84. const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
  85. const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
  86. useEffect(() => {
  87. if (conversationId !== lastConversationIdRef.current) {
  88. lastConversationIdRef.current = conversationId;
  89. shouldStickToBottomRef.current = true;
  90. lastMessageIdRef.current = null;
  91. lastMessageCountRef.current = 0;
  92. hasInitialScrolledRef.current = false; // 重置初始滚动标记
  93. }
  94. }, [conversationId]);
  95. // 监听滚动事件,当滚动到底部附近时标记消息为已读
  96. // 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
  97. useEffect(() => {
  98. const container = containerRef.current;
  99. if (!container || !conversationId || !onMarkMessagesRead) {
  100. return;
  101. }
  102. const handleScroll = () => {
  103. const { scrollTop, scrollHeight, clientHeight } = container;
  104. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  105. const isNearBottom = distanceToBottom < 100;
  106. shouldStickToBottomRef.current = isNearBottom;
  107. // 当滚动到底部附近时,检查是否有未读消息需要标记为已读
  108. if (isNearBottom) {
  109. // 防抖:延迟 500ms 后标记为已读,避免频繁调用
  110. if (markReadTimerRef.current) {
  111. clearTimeout(markReadTimerRef.current);
  112. }
  113. markReadTimerRef.current = setTimeout(() => {
  114. // 检查是否有未读的消息(对方发送的消息)
  115. const unreadMessages = messages.filter((msg) => {
  116. const isFromOther = internalChatMode
  117. ? msg.sender_is_agent && msg.sender_id === 0 // 内部对话:AI 回复视为对方
  118. : currentUserIsAgent
  119. ? !msg.sender_is_agent
  120. : msg.sender_is_agent;
  121. return isFromOther && !msg.is_read;
  122. });
  123. if (unreadMessages.length > 0) {
  124. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  125. const now = Date.now();
  126. if (now - lastMarkedReadRef.current < 2000) {
  127. return;
  128. }
  129. // 标记为已读
  130. onMarkMessagesRead(conversationId, currentUserIsAgent);
  131. lastMarkedReadRef.current = now;
  132. }
  133. }, 500);
  134. }
  135. };
  136. handleScroll();
  137. container.addEventListener("scroll", handleScroll);
  138. return () => {
  139. container.removeEventListener("scroll", handleScroll);
  140. if (markReadTimerRef.current) {
  141. clearTimeout(markReadTimerRef.current);
  142. }
  143. };
  144. }, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent, internalChatMode]);
  145. useEffect(() => {
  146. if (messages.length === 0) {
  147. return;
  148. }
  149. const container = containerRef.current;
  150. if (!container) {
  151. return;
  152. }
  153. const keyword = highlightKeyword.trim();
  154. const lastMessage = messages[messages.length - 1];
  155. const isLastMessageFromCurrentUser = lastMessage
  156. ? currentUserIsAgent
  157. ? lastMessage.sender_is_agent
  158. : !lastMessage.sender_is_agent
  159. : false;
  160. // 检查是否有新消息(通过比较消息ID或消息数量)
  161. const hasNewMessage =
  162. lastMessage.id !== lastMessageIdRef.current ||
  163. messages.length !== lastMessageCountRef.current;
  164. // 更新记录
  165. lastMessageIdRef.current = lastMessage.id;
  166. lastMessageCountRef.current = messages.length;
  167. // 使用 requestAnimationFrame 确保 DOM 已更新后再检查位置
  168. requestAnimationFrame(() => {
  169. // 重新获取容器引用,确保使用最新的 DOM 元素
  170. const currentContainer = containerRef.current;
  171. if (!currentContainer) {
  172. return;
  173. }
  174. // 对于新消息,需要延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
  175. // 使用双重 requestAnimationFrame + 小延迟,给图片加载留出时间
  176. const checkAndScroll = () => {
  177. const container = containerRef.current;
  178. if (!container) {
  179. return;
  180. }
  181. // 在 DOM 更新后检查当前位置
  182. const { scrollTop, scrollHeight, clientHeight } = container;
  183. const distanceToBottom = scrollHeight - scrollTop - clientHeight;
  184. const isNearBottom = distanceToBottom < 100;
  185. // 更新 shouldStickToBottomRef,确保使用最新的位置信息
  186. shouldStickToBottomRef.current = isNearBottom;
  187. // 检查是否是初始加载(首次加载消息或切换对话后首次加载)
  188. const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
  189. // 滚动逻辑:
  190. // 1. 如果是初始加载(首次加载消息或切换对话),无论什么情况都自动滚动到底部
  191. // 2. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
  192. // 3. 如果最后一条消息是对方发送的:
  193. // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
  194. // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
  195. // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
  196. // 4. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
  197. // 这样确保访客端和客服端的行为一致:初始加载时显示最新消息,当用户在底部附近时,收到新消息会自动滚动到底部
  198. const shouldAutoScroll =
  199. isInitialLoad ||
  200. (hasNewMessage &&
  201. (isLastMessageFromCurrentUser ||
  202. isNearBottom ||
  203. (!currentUserIsAgent && !isLastMessageFromCurrentUser)));
  204. if (keyword) {
  205. const keywordLower = keyword.toLowerCase();
  206. const matchingMessage = messages.find((message) =>
  207. message.content.toLowerCase().includes(keywordLower)
  208. );
  209. if (matchingMessage) {
  210. const scroll = () => {
  211. const target = messageRefs.current[matchingMessage.id];
  212. if (target) {
  213. target.scrollIntoView({
  214. behavior: "smooth",
  215. block: "center",
  216. inline: "nearest",
  217. });
  218. }
  219. setTimeout(onHighlightClear, 3000);
  220. };
  221. setTimeout(scroll, 200);
  222. } else {
  223. if (!shouldAutoScroll) {
  224. return;
  225. }
  226. const scrollBottom = () => {
  227. const container = containerRef.current;
  228. if (!container) {
  229. return;
  230. }
  231. container.scrollTo({
  232. top: container.scrollHeight,
  233. behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
  234. });
  235. // 标记初始滚动已完成
  236. if (isInitialLoad) {
  237. hasInitialScrolledRef.current = true;
  238. }
  239. };
  240. setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
  241. onHighlightClear();
  242. }
  243. } else {
  244. if (!shouldAutoScroll) {
  245. return;
  246. }
  247. const scrollBottom = () => {
  248. const container = containerRef.current;
  249. if (!container) {
  250. return;
  251. }
  252. if (container.scrollHeight === container.clientHeight && container.parentElement) {
  253. const parent = container.parentElement;
  254. const parentHeight = parent.offsetHeight;
  255. container.style.height = `${parentHeight}px`;
  256. container.style.maxHeight = `${parentHeight}px`;
  257. }
  258. // 访客端收到对方(如 AI)的新消息时:从该气泡头部开始显示,长消息无需往上翻
  259. const lastMsgEl = messageRefs.current[lastMessage.id];
  260. if (
  261. lastMsgEl &&
  262. !currentUserIsAgent &&
  263. !isLastMessageFromCurrentUser
  264. ) {
  265. lastMsgEl.scrollIntoView({
  266. block: "start",
  267. behavior: isInitialLoad ? "auto" : "smooth",
  268. inline: "nearest",
  269. });
  270. } else {
  271. container.scrollTo({
  272. top: container.scrollHeight,
  273. behavior: isInitialLoad ? "auto" : "smooth",
  274. });
  275. }
  276. if (isInitialLoad) {
  277. hasInitialScrolledRef.current = true;
  278. }
  279. };
  280. setTimeout(scrollBottom, isInitialLoad ? 0 : 100);
  281. }
  282. // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
  283. // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
  284. if (conversationId && onMarkMessagesRead && messages.length > 0) {
  285. // 延迟标记为已读,确保滚动动画完成
  286. if (markReadTimerRef.current) {
  287. clearTimeout(markReadTimerRef.current);
  288. }
  289. markReadTimerRef.current = setTimeout(() => {
  290. // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
  291. const shouldMarkRead = shouldAutoScroll || isNearBottom;
  292. if (!shouldMarkRead) {
  293. return;
  294. }
  295. const unreadMessages = messages.filter((msg) => {
  296. const isFromOther = internalChatMode
  297. ? msg.sender_is_agent && msg.sender_id === 0
  298. : currentUserIsAgent
  299. ? !msg.sender_is_agent
  300. : msg.sender_is_agent;
  301. return isFromOther && !msg.is_read;
  302. });
  303. if (unreadMessages.length > 0) {
  304. // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
  305. const now = Date.now();
  306. if (now - lastMarkedReadRef.current < 2000) {
  307. return;
  308. }
  309. onMarkMessagesRead(conversationId, currentUserIsAgent);
  310. lastMarkedReadRef.current = now;
  311. }
  312. }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
  313. }
  314. };
  315. // 对于新消息,延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
  316. if (hasNewMessage) {
  317. // 检查最后一条消息是否包含图片/文件
  318. const lastMessageHasFile = lastMessage.file_url;
  319. if (lastMessageHasFile) {
  320. // 如果包含文件,延迟更长时间,确保图片加载完成
  321. requestAnimationFrame(() => {
  322. requestAnimationFrame(() => {
  323. setTimeout(() => {
  324. checkAndScroll();
  325. }, 200); // 给图片加载留出更多时间
  326. });
  327. });
  328. } else {
  329. // 普通消息,正常延迟
  330. requestAnimationFrame(() => {
  331. requestAnimationFrame(() => {
  332. checkAndScroll();
  333. });
  334. });
  335. }
  336. } else {
  337. // 非新消息(如状态更新),直接检查
  338. checkAndScroll();
  339. }
  340. });
  341. }, [
  342. messages,
  343. highlightKeyword,
  344. onHighlightClear,
  345. disableAutoScroll,
  346. currentUserIsAgent,
  347. conversationId,
  348. onMarkMessagesRead,
  349. internalChatMode,
  350. ]);
  351. if (loading) {
  352. return (
  353. <div className="flex-1 flex items-center justify-center bg-muted/30">
  354. <span className="text-sm text-muted-foreground">消息加载中...</span>
  355. </div>
  356. );
  357. }
  358. if (messages.length === 0) {
  359. return (
  360. <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-3 bg-muted/20 scrollbar-auto">
  361. <div className="text-center text-muted-foreground mt-8 text-sm">暂无消息</div>
  362. {bottomSlot ? <div className="mt-4">{bottomSlot}</div> : null}
  363. </div>
  364. );
  365. }
  366. return (
  367. <>
  368. {/* 图片预览对话框 */}
  369. <Dialog open={imagePreviewOpen} onOpenChange={setImagePreviewOpen}>
  370. <DialogContent className="max-w-4xl max-h-[90vh] p-0">
  371. {previewImageUrl && (
  372. <div className="relative">
  373. <Button
  374. variant="ghost"
  375. size="sm"
  376. className="absolute top-2 right-2 z-10"
  377. onClick={() => setImagePreviewOpen(false)}
  378. >
  379. <X className="w-4 h-4" />
  380. </Button>
  381. <img
  382. src={previewImageUrl}
  383. alt="预览"
  384. className="w-full h-auto max-h-[90vh] object-contain"
  385. />
  386. </div>
  387. )}
  388. </DialogContent>
  389. </Dialog>
  390. <div
  391. ref={containerRef}
  392. className="h-full w-full overflow-y-auto p-3 bg-muted/20 scrollbar-auto"
  393. style={{ height: '100%' }}
  394. >
  395. <div className="space-y-3.5">
  396. {messages.map((message) => {
  397. const keyword = highlightKeyword.trim();
  398. const isMatching =
  399. keyword !== "" &&
  400. message.content.toLowerCase().includes(keyword.toLowerCase());
  401. const bubbleContent =
  402. keyword !== "" && isMatching
  403. ? highlightText(message.content, keyword)
  404. : message.content;
  405. const isAIMessage = Boolean(message.sender_is_agent) && message.sender_id === 0;
  406. // 仅当不需要高亮搜索关键词、且该消息为 AI 回复时才启用逐字显示
  407. const shouldTypewriter =
  408. isAIMessage &&
  409. keyword === "" &&
  410. !message.file_url &&
  411. typeof message.content === "string" &&
  412. message.content.length > 0;
  413. if (message.message_type === "system_message") {
  414. return (
  415. <div
  416. key={message.id}
  417. ref={(element) => {
  418. messageRefs.current[message.id] = element;
  419. }}
  420. className="text-center text-xs text-muted-foreground/90"
  421. >
  422. <Badge variant="secondary" className="inline-block border border-border/40 bg-background/70 text-muted-foreground">
  423. {message.content}
  424. </Badge>
  425. </div>
  426. );
  427. }
  428. const isSenderAgent = Boolean(message.sender_is_agent);
  429. // 内部对话(知识库测试):AI 回复 sender_id=0 显示左侧,客服消息显示右侧
  430. const isCurrentUser = internalChatMode
  431. ? isSenderAgent && message.sender_id !== 0
  432. : currentUserIsAgent
  433. ? isSenderAgent
  434. : !isSenderAgent;
  435. const alignment = isCurrentUser ? "justify-end" : "justify-start";
  436. const bubbleColor = isCurrentUser
  437. ? "bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/20"
  438. : "bg-background/95 text-card-foreground border border-border/45 shadow-[0_1px_4px_rgba(15,23,42,0.06)]";
  439. // 拉开双方气泡圆角差异:自己消息更利落、对方消息更柔和,便于快速分辨
  440. const cornerClass = isCurrentUser
  441. ? "rounded-[18px] rounded-br-md"
  442. : "rounded-[18px] rounded-bl-md";
  443. // 计算已读回执的样式类名
  444. // 统一使用相同的样式:蓝色半透明(text-primary/70)
  445. // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
  446. const receiptClass = isCurrentUser ? "text-primary/70" : "";
  447. // 文件相关
  448. const hasFile = Boolean(message.file_url);
  449. const isImage = message.file_type === "image";
  450. const isDocument = message.file_type === "document";
  451. // 获取文件URL(完整URL)
  452. const getFileUrl = (fileUrl: string | null | undefined): string => {
  453. if (!fileUrl) return "";
  454. if (fileUrl.startsWith("http")) return fileUrl;
  455. return `${API_BASE_URL}${fileUrl}`;
  456. };
  457. // 格式化文件大小
  458. const formatFileSize = (bytes: number | null | undefined): string => {
  459. if (!bytes) return "";
  460. if (bytes < 1024) return bytes + " B";
  461. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
  462. return (bytes / (1024 * 1024)).toFixed(1) + " MB";
  463. };
  464. // 打开图片预览
  465. const handleImageClick = (url: string) => {
  466. setPreviewImageUrl(url);
  467. setImagePreviewOpen(true);
  468. };
  469. // 下载文件
  470. const handleDownload = (url: string, fileName: string | null | undefined) => {
  471. const link = document.createElement("a");
  472. link.href = url;
  473. link.download = fileName || "file";
  474. link.target = "_blank";
  475. document.body.appendChild(link);
  476. link.click();
  477. document.body.removeChild(link);
  478. };
  479. const leftAvatarUrl = !isCurrentUser ? getAvatarUrl(leftAvatarBySenderId?.[message.sender_id]) : null;
  480. const showLeftAvatar = !isCurrentUser && Boolean(leftAvatarBySenderId);
  481. return (
  482. <div
  483. key={message.id}
  484. ref={(element) => {
  485. messageRefs.current[message.id] = element;
  486. }}
  487. className={`flex ${alignment} items-end gap-2`}
  488. >
  489. {showLeftAvatar ? (
  490. <div className="w-7 h-7 rounded-full overflow-hidden bg-slate-200 border border-slate-300 flex-shrink-0">
  491. {leftAvatarUrl ? (
  492. <img src={leftAvatarUrl} alt="客服头像" className="w-full h-full object-cover" />
  493. ) : (
  494. <div className="w-full h-full flex items-center justify-center text-[10px] text-slate-600">客</div>
  495. )}
  496. </div>
  497. ) : null}
  498. <div className="max-w-[72%]">
  499. <div
  500. className={`px-3.5 py-2.5 rounded-2xl ${
  501. cornerClass
  502. } ${bubbleColor} transition-shadow hover:shadow-sm`}
  503. >
  504. {/* 文本内容 */}
  505. {message.content && (
  506. <div className="whitespace-pre-wrap break-words text-sm">
  507. {shouldTypewriter ? (
  508. <TypewriterText text={message.content} animateKey={message.id} />
  509. ) : (
  510. bubbleContent
  511. )}
  512. </div>
  513. )}
  514. {/* 文件显示 */}
  515. {hasFile && message.file_url && (
  516. <div className={message.content ? "mt-2" : ""}>
  517. {isImage ? (
  518. // 图片预览
  519. <div
  520. className="cursor-pointer rounded-lg overflow-hidden max-w-[300px] border border-border/30 hover:border-primary/50 transition-colors shadow-sm"
  521. onClick={() => handleImageClick(getFileUrl(message.file_url))}
  522. >
  523. <img
  524. src={getFileUrl(message.file_url)}
  525. alt={message.file_name || "图片"}
  526. className="max-w-full h-auto"
  527. loading="lazy"
  528. />
  529. </div>
  530. ) : isDocument ? (
  531. // 文档显示
  532. <div className="flex items-center gap-2 p-3 bg-background/60 rounded-lg border border-border/30 hover:bg-background/80 transition-colors">
  533. <Paperclip className="w-4 h-4 flex-shrink-0" />
  534. <div className="flex-1 min-w-0">
  535. <div className="text-sm font-medium truncate">
  536. {message.file_name || "文件"}
  537. </div>
  538. {message.file_size && (
  539. <div className="text-xs text-muted-foreground">
  540. {formatFileSize(message.file_size)}
  541. </div>
  542. )}
  543. </div>
  544. <Button
  545. variant="ghost"
  546. size="sm"
  547. onClick={() =>
  548. handleDownload(
  549. getFileUrl(message.file_url),
  550. message.file_name
  551. )
  552. }
  553. className="flex-shrink-0"
  554. >
  555. <Download className="w-4 h-4" />
  556. </Button>
  557. </div>
  558. ) : null}
  559. </div>
  560. )}
  561. </div>
  562. <div className="flex items-center gap-1 mt-1.5 px-0.5 text-[10px] text-muted-foreground/80">
  563. {isCurrentUser && (
  564. <span className={receiptClass}>
  565. {message.is_read ? "✓✓" : "✓"}
  566. </span>
  567. )}
  568. <span>{formatMessageTime(message.created_at)}</span>
  569. </div>
  570. {/* AI 回复的数据源标记(仅对方消息且存在 sources_used 时显示) */}
  571. {!isCurrentUser && message.sources_used && (
  572. <div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-x-2 gap-y-0">
  573. {message.sources_used.split(",").map((s) => s.trim()).filter(Boolean).map((src) => (
  574. <span key={src}>
  575. {src === "knowledge_base" && "已使用知识库"}
  576. {src === "llm" && "已使用大模型"}
  577. {src === "web" && "已使用联网搜索"}
  578. </span>
  579. ))}
  580. </div>
  581. )}
  582. </div>
  583. </div>
  584. );
  585. })}
  586. </div>
  587. {bottomSlot}
  588. </div>
  589. </>
  590. );
  591. }