useConversations.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. fetchConversations,
  5. searchConversations,
  6. } from "../../agent/services/conversationApi";
  7. import type { ConversationListType } from "../../agent/services/conversationApi";
  8. import type { ConversationStatus } from "../../agent/services/conversationApi";
  9. import {
  10. ConversationSummary,
  11. MessageItem,
  12. VisitorStatusUpdatePayload,
  13. } from "../../agent/types";
  14. import { useWebSocket } from "./useWebSocket";
  15. import { WSMessage } from "@/lib/websocket";
  16. import { ChatWebSocketPayload } from "../../agent/types";
  17. import { buildMessagePreview } from "@/utils/format";
  18. import { getAgentWSToken } from "@/utils/storage";
  19. const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
  20. [...list].sort(
  21. (a, b) =>
  22. new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
  23. );
  24. import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
  25. interface UseConversationsOptions {
  26. agentId?: number | null;
  27. filter?: ConversationFilter;
  28. /** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
  29. listType?: ConversationListType;
  30. /** 会话状态:open(进行中)/ closed(历史) */
  31. status?: ConversationStatus;
  32. }
  33. export function useConversations(options?: UseConversationsOptions) {
  34. const { agentId, filter = "all", listType = "visitor", status = "open" } = options || {};
  35. const [conversations, setConversations] = useState<ConversationSummary[]>([]);
  36. const [filteredConversations, setFilteredConversations] = useState<
  37. ConversationSummary[]
  38. >([]);
  39. const [selectedConversationId, setSelectedConversationId] = useState<
  40. number | null
  41. >(null);
  42. const [searchQuery, setSearchQuery] = useState("");
  43. const [loading, setLoading] = useState(true);
  44. const [isInitialLoad, setIsInitialLoad] = useState(true);
  45. const searchRef = useRef("");
  46. const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  47. const wsToken = getAgentWSToken() ?? undefined;
  48. // 根据 filter 过滤会话
  49. const applyFilter = useCallback(
  50. (conversations: ConversationSummary[]): ConversationSummary[] => {
  51. if (!agentId) {
  52. return conversations;
  53. }
  54. switch (filter) {
  55. case "mine":
  56. // 只显示当前用户参与过的会话(has_participated === true)
  57. // 即当前用户在该会话中发送过消息的会话
  58. return conversations.filter((conv) => conv.has_participated === true);
  59. case "others":
  60. // 显示除了当前用户参与过的其他人的会话(has_participated !== true)
  61. return conversations.filter((conv) => conv.has_participated !== true);
  62. case "all":
  63. default:
  64. return conversations;
  65. }
  66. },
  67. [agentId, filter]
  68. );
  69. const loadConversations = useCallback(async () => {
  70. setLoading(true);
  71. try {
  72. // 内部对话(知识库测试)必须带 user_id,后端否则返回 400;未登录或 agentId 未就绪时不请求
  73. if (listType === "internal" && !agentId) {
  74. setConversations([]);
  75. setFilteredConversations([]);
  76. setSelectedConversationId(null);
  77. return;
  78. }
  79. const data = await fetchConversations(
  80. agentId ?? undefined,
  81. listType === "internal" ? { type: "internal", status } : { status }
  82. );
  83. setConversations(data);
  84. const filtered = listType === "internal" ? data : applyFilter(data);
  85. if (!searchRef.current.trim()) {
  86. setFilteredConversations(filtered);
  87. }
  88. setSelectedConversationId((prev) => {
  89. if (prev) {
  90. return prev;
  91. }
  92. return filtered.length > 0 ? filtered[0].id : null;
  93. });
  94. } catch (error) {
  95. console.error(error);
  96. } finally {
  97. setLoading(false);
  98. setIsInitialLoad(false);
  99. }
  100. }, [applyFilter, agentId, filter, listType, status]);
  101. useEffect(() => {
  102. loadConversations();
  103. }, [loadConversations]);
  104. // 兜底定时刷新:防止 WebSocket 漏事件/无会话时无法建立全局 WS 导致列表长期不更新。
  105. useEffect(() => {
  106. if (!agentId) {
  107. return;
  108. }
  109. const interval = setInterval(() => {
  110. void loadConversations();
  111. }, 15000);
  112. return () => clearInterval(interval);
  113. }, [agentId, loadConversations]);
  114. // 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
  115. useEffect(() => {
  116. if (isInitialLoad) {
  117. return;
  118. }
  119. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  120. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  121. }, [filter, listType, conversations, isInitialLoad, applyFilter]);
  122. useEffect(() => {
  123. if (isInitialLoad) {
  124. return;
  125. }
  126. const handler = setTimeout(async () => {
  127. const query = searchQuery.trim();
  128. searchRef.current = query;
  129. if (!query) {
  130. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  131. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  132. return;
  133. }
  134. if (listType === "internal") {
  135. setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
  136. setLoading(false);
  137. return;
  138. }
  139. try {
  140. setLoading(true);
  141. const data = await searchConversations(query, agentId ?? undefined, { status });
  142. const filtered = applyFilter(data);
  143. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  144. } catch (error) {
  145. console.error(error);
  146. setFilteredConversations([]);
  147. } finally {
  148. setLoading(false);
  149. }
  150. }, 300);
  151. return () => clearTimeout(handler);
  152. }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType, status]);
  153. const selectConversation = useCallback((conversationId: number | null) => {
  154. setSelectedConversationId((prev) =>
  155. prev === conversationId ? prev : conversationId
  156. );
  157. }, []);
  158. const updateConversation = useCallback(
  159. (
  160. conversationId: number,
  161. updater: (conversation: ConversationSummary) => ConversationSummary,
  162. options?: { skipResort?: boolean }
  163. ) => {
  164. const applyUpdate = (list: ConversationSummary[]) => {
  165. let changed = false;
  166. const next = list.map((conv) => {
  167. if (conv.id === conversationId) {
  168. changed = true;
  169. return updater(conv);
  170. }
  171. return conv;
  172. });
  173. if (!changed) {
  174. return list;
  175. }
  176. if (options?.skipResort) {
  177. return next;
  178. }
  179. return sortByUpdatedAtDesc(next);
  180. };
  181. setConversations((prev) => applyUpdate(prev));
  182. setFilteredConversations((prev) => {
  183. if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
  184. return prev;
  185. }
  186. return applyUpdate(prev);
  187. });
  188. },
  189. []
  190. );
  191. const setAllConversations = useCallback((data: ConversationSummary[]) => {
  192. setConversations(data);
  193. if (!searchRef.current.trim()) {
  194. const filtered = applyFilter(data);
  195. setFilteredConversations(filtered);
  196. }
  197. }, [applyFilter]);
  198. const hasConversation = useCallback(
  199. (conversationId: number) => {
  200. return conversations.some((conv) => conv.id === conversationId);
  201. },
  202. [conversations]
  203. );
  204. const scheduleRefreshConversations = useCallback(() => {
  205. if (refreshTimerRef.current) {
  206. clearTimeout(refreshTimerRef.current);
  207. }
  208. refreshTimerRef.current = setTimeout(() => {
  209. void loadConversations();
  210. }, 500);
  211. }, [loadConversations]);
  212. // 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
  213. // 使用第一个对话的 ID(如果存在),否则不建立连接
  214. const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
  215. // 处理全局 WebSocket 事件:访客在线状态 + 新消息摘要
  216. const handleGlobalWebSocketMessage = useCallback(
  217. (event: WSMessage<ChatWebSocketPayload>) => {
  218. if (event.type === "visitor_status_update" && event.data) {
  219. const payload = event.data as VisitorStatusUpdatePayload;
  220. if (payload?.conversation_id) {
  221. if (payload.is_online === true) {
  222. // 在线:更新为当前时间(实时更新在线状态)
  223. updateConversation(payload.conversation_id, (conv) => ({
  224. ...conv,
  225. last_seen_at: new Date().toISOString(),
  226. }));
  227. }
  228. // 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
  229. // 因为对话列表会定期刷新,或者通过其他方式更新
  230. }
  231. } else if (event.type === "new_message" && event.data) {
  232. const message = event.data as MessageItem;
  233. if (typeof message?.conversation_id !== "number") {
  234. return;
  235. }
  236. const isConversationExists = hasConversation(message.conversation_id);
  237. if (!isConversationExists) {
  238. // 新会话(当前列表里还没有)时,延迟刷新把它拉进来
  239. scheduleRefreshConversations();
  240. return;
  241. }
  242. const isSystemMessage =
  243. (message.message_type ?? "user_message") === "system_message";
  244. const isVisitorMessage = !message.sender_is_agent && !isSystemMessage;
  245. const preview = buildMessagePreview(message.content ?? "");
  246. updateConversation(message.conversation_id, (conv) => ({
  247. ...conv,
  248. updated_at: message.created_at,
  249. last_seen_at: isVisitorMessage
  250. ? message.created_at
  251. : conv.last_seen_at ?? null,
  252. unread_count: isVisitorMessage
  253. ? message.conversation_id === selectedConversationId
  254. ? 0
  255. : (conv.unread_count ?? 0) + 1
  256. : conv.unread_count ?? 0,
  257. last_message: {
  258. id: message.id,
  259. content: preview,
  260. sender_is_agent: message.sender_is_agent,
  261. message_type: message.message_type ?? "user_message",
  262. is_read: Boolean(message.is_read),
  263. read_at: message.read_at ?? null,
  264. created_at: message.created_at,
  265. },
  266. }));
  267. }
  268. },
  269. [
  270. hasConversation,
  271. scheduleRefreshConversations,
  272. selectedConversationId,
  273. updateConversation,
  274. ]
  275. );
  276. useEffect(() => {
  277. return () => {
  278. if (refreshTimerRef.current) {
  279. clearTimeout(refreshTimerRef.current);
  280. }
  281. };
  282. }, []);
  283. // 建立全局 WebSocket 连接(用于接收全局事件)
  284. useWebSocket<ChatWebSocketPayload>({
  285. conversationId: globalConversationId,
  286. enabled: Boolean(globalConversationId && agentId),
  287. isVisitor: false,
  288. agentId: agentId ?? undefined,
  289. wsToken,
  290. onMessage: handleGlobalWebSocketMessage,
  291. onError: (error) => {
  292. // 静默处理错误,避免影响用户体验
  293. },
  294. onClose: () => {
  295. // 静默处理关闭,避免影响用户体验
  296. },
  297. });
  298. const contextValue = useMemo(
  299. () => ({
  300. conversations,
  301. filteredConversations,
  302. selectedConversationId,
  303. searchQuery,
  304. loading,
  305. isInitialLoad,
  306. setSearchQuery,
  307. selectConversation,
  308. refresh: loadConversations,
  309. updateConversation,
  310. setAllConversations,
  311. hasConversation,
  312. }),
  313. [
  314. conversations,
  315. filteredConversations,
  316. selectedConversationId,
  317. searchQuery,
  318. loading,
  319. isInitialLoad,
  320. selectConversation,
  321. loadConversations,
  322. updateConversation,
  323. setAllConversations,
  324. setSearchQuery,
  325. hasConversation,
  326. ]
  327. );
  328. return contextValue;
  329. }