MessageList.tsx 28 KB

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