useConversations.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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 { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
  9. import { useWebSocket } from "./useWebSocket";
  10. import { WSMessage } from "@/lib/websocket";
  11. import { ChatWebSocketPayload } from "../../agent/types";
  12. const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
  13. [...list].sort(
  14. (a, b) =>
  15. new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
  16. );
  17. import type { ConversationFilter } from "@/components/dashboard/ConversationHeader";
  18. interface UseConversationsOptions {
  19. agentId?: number | null;
  20. filter?: ConversationFilter;
  21. /** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
  22. listType?: ConversationListType;
  23. }
  24. export function useConversations(options?: UseConversationsOptions) {
  25. const { agentId, filter = "all", listType = "visitor" } = options || {};
  26. const [conversations, setConversations] = useState<ConversationSummary[]>([]);
  27. const [filteredConversations, setFilteredConversations] = useState<
  28. ConversationSummary[]
  29. >([]);
  30. const [selectedConversationId, setSelectedConversationId] = useState<
  31. number | null
  32. >(null);
  33. const [searchQuery, setSearchQuery] = useState("");
  34. const [loading, setLoading] = useState(true);
  35. const [isInitialLoad, setIsInitialLoad] = useState(true);
  36. const searchRef = useRef("");
  37. // 根据 filter 过滤会话
  38. const applyFilter = useCallback(
  39. (conversations: ConversationSummary[]): ConversationSummary[] => {
  40. if (!agentId) {
  41. return conversations;
  42. }
  43. switch (filter) {
  44. case "mine":
  45. // 只显示当前用户参与过的会话(has_participated === true)
  46. // 即当前用户在该会话中发送过消息的会话
  47. return conversations.filter((conv) => conv.has_participated === true);
  48. case "others":
  49. // 显示除了当前用户参与过的其他人的会话(has_participated !== true)
  50. return conversations.filter((conv) => conv.has_participated !== true);
  51. case "all":
  52. default:
  53. return conversations;
  54. }
  55. },
  56. [agentId, filter]
  57. );
  58. const loadConversations = useCallback(async () => {
  59. setLoading(true);
  60. try {
  61. // 内部对话(知识库测试)必须带 user_id,后端否则返回 400;未登录或 agentId 未就绪时不请求
  62. if (listType === "internal" && !agentId) {
  63. setConversations([]);
  64. setFilteredConversations([]);
  65. setSelectedConversationId(null);
  66. return;
  67. }
  68. const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
  69. setConversations(data);
  70. const filtered = listType === "internal" ? data : applyFilter(data);
  71. if (!searchRef.current.trim()) {
  72. setFilteredConversations(filtered);
  73. }
  74. setSelectedConversationId((prev) => {
  75. if (prev) {
  76. return prev;
  77. }
  78. return filtered.length > 0 ? filtered[0].id : null;
  79. });
  80. } catch (error) {
  81. console.error(error);
  82. } finally {
  83. setLoading(false);
  84. setIsInitialLoad(false);
  85. }
  86. }, [applyFilter, agentId, filter, listType]);
  87. useEffect(() => {
  88. loadConversations();
  89. }, [loadConversations]);
  90. // 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
  91. useEffect(() => {
  92. if (isInitialLoad) {
  93. return;
  94. }
  95. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  96. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  97. }, [filter, listType, conversations, isInitialLoad, applyFilter]);
  98. useEffect(() => {
  99. if (isInitialLoad) {
  100. return;
  101. }
  102. const handler = setTimeout(async () => {
  103. const query = searchQuery.trim();
  104. searchRef.current = query;
  105. if (!query) {
  106. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  107. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  108. return;
  109. }
  110. if (listType === "internal") {
  111. setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
  112. setLoading(false);
  113. return;
  114. }
  115. try {
  116. setLoading(true);
  117. const data = await searchConversations(query, agentId ?? undefined);
  118. const filtered = applyFilter(data);
  119. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  120. } catch (error) {
  121. console.error(error);
  122. setFilteredConversations([]);
  123. } finally {
  124. setLoading(false);
  125. }
  126. }, 300);
  127. return () => clearTimeout(handler);
  128. }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
  129. const selectConversation = useCallback((conversationId: number | null) => {
  130. setSelectedConversationId((prev) =>
  131. prev === conversationId ? prev : conversationId
  132. );
  133. }, []);
  134. const updateConversation = useCallback(
  135. (
  136. conversationId: number,
  137. updater: (conversation: ConversationSummary) => ConversationSummary,
  138. options?: { skipResort?: boolean }
  139. ) => {
  140. const applyUpdate = (list: ConversationSummary[]) => {
  141. let changed = false;
  142. const next = list.map((conv) => {
  143. if (conv.id === conversationId) {
  144. changed = true;
  145. return updater(conv);
  146. }
  147. return conv;
  148. });
  149. if (!changed) {
  150. return list;
  151. }
  152. if (options?.skipResort) {
  153. return next;
  154. }
  155. return sortByUpdatedAtDesc(next);
  156. };
  157. setConversations((prev) => applyUpdate(prev));
  158. setFilteredConversations((prev) => {
  159. if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
  160. return prev;
  161. }
  162. return applyUpdate(prev);
  163. });
  164. },
  165. []
  166. );
  167. const setAllConversations = useCallback((data: ConversationSummary[]) => {
  168. setConversations(data);
  169. if (!searchRef.current.trim()) {
  170. const filtered = applyFilter(data);
  171. setFilteredConversations(filtered);
  172. }
  173. }, [applyFilter]);
  174. const hasConversation = useCallback(
  175. (conversationId: number) => {
  176. return conversations.some((conv) => conv.id === conversationId);
  177. },
  178. [conversations]
  179. );
  180. // 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
  181. // 使用第一个对话的 ID(如果存在),否则不建立连接
  182. const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
  183. // 处理 visitor_status_update 事件
  184. const handleVisitorStatusUpdate = useCallback(
  185. (event: WSMessage<ChatWebSocketPayload>) => {
  186. if (event.type === "visitor_status_update" && event.data) {
  187. const payload = event.data as VisitorStatusUpdatePayload;
  188. if (payload?.conversation_id) {
  189. if (payload.is_online === true) {
  190. // 在线:更新为当前时间(实时更新在线状态)
  191. updateConversation(payload.conversation_id, (conv) => ({
  192. ...conv,
  193. last_seen_at: new Date().toISOString(),
  194. }));
  195. }
  196. // 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
  197. // 因为对话列表会定期刷新,或者通过其他方式更新
  198. }
  199. }
  200. },
  201. [updateConversation]
  202. );
  203. // 建立全局 WebSocket 连接(用于接收全局事件)
  204. useWebSocket<ChatWebSocketPayload>({
  205. conversationId: globalConversationId,
  206. enabled: Boolean(globalConversationId && agentId),
  207. isVisitor: false,
  208. agentId: agentId ?? undefined,
  209. onMessage: handleVisitorStatusUpdate,
  210. onError: (error) => {
  211. // 静默处理错误,避免影响用户体验
  212. },
  213. onClose: () => {
  214. // 静默处理关闭,避免影响用户体验
  215. },
  216. });
  217. const contextValue = useMemo(
  218. () => ({
  219. conversations,
  220. filteredConversations,
  221. selectedConversationId,
  222. searchQuery,
  223. loading,
  224. isInitialLoad,
  225. setSearchQuery,
  226. selectConversation,
  227. refresh: loadConversations,
  228. updateConversation,
  229. setAllConversations,
  230. hasConversation,
  231. }),
  232. [
  233. conversations,
  234. filteredConversations,
  235. selectedConversationId,
  236. searchQuery,
  237. loading,
  238. isInitialLoad,
  239. selectConversation,
  240. loadConversations,
  241. updateConversation,
  242. setAllConversations,
  243. setSearchQuery,
  244. hasConversation,
  245. ]
  246. );
  247. return contextValue;
  248. }