useConversations.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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. const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
  62. setConversations(data);
  63. const filtered = listType === "internal" ? data : applyFilter(data);
  64. if (!searchRef.current.trim()) {
  65. setFilteredConversations(filtered);
  66. }
  67. setSelectedConversationId((prev) => {
  68. if (prev) {
  69. return prev;
  70. }
  71. return filtered.length > 0 ? filtered[0].id : null;
  72. });
  73. } catch (error) {
  74. console.error(error);
  75. } finally {
  76. setLoading(false);
  77. setIsInitialLoad(false);
  78. }
  79. }, [applyFilter, agentId, filter, listType]);
  80. useEffect(() => {
  81. loadConversations();
  82. }, [loadConversations]);
  83. // 当 filter / listType 改变时,重新应用过滤(不重新加载数据)
  84. useEffect(() => {
  85. if (isInitialLoad) {
  86. return;
  87. }
  88. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  89. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  90. }, [filter, listType, conversations, isInitialLoad, applyFilter]);
  91. useEffect(() => {
  92. if (isInitialLoad) {
  93. return;
  94. }
  95. const handler = setTimeout(async () => {
  96. const query = searchQuery.trim();
  97. searchRef.current = query;
  98. if (!query) {
  99. const filtered = listType === "internal" ? conversations : applyFilter(conversations);
  100. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  101. return;
  102. }
  103. if (listType === "internal") {
  104. setFilteredConversations(sortByUpdatedAtDesc(conversations.filter((c) => (c.last_message?.content ?? "").toLowerCase().includes(query.toLowerCase()))));
  105. setLoading(false);
  106. return;
  107. }
  108. try {
  109. setLoading(true);
  110. const data = await searchConversations(query, agentId ?? undefined);
  111. const filtered = applyFilter(data);
  112. setFilteredConversations(sortByUpdatedAtDesc(filtered));
  113. } catch (error) {
  114. console.error(error);
  115. setFilteredConversations([]);
  116. } finally {
  117. setLoading(false);
  118. }
  119. }, 300);
  120. return () => clearTimeout(handler);
  121. }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
  122. const selectConversation = useCallback((conversationId: number | null) => {
  123. setSelectedConversationId((prev) =>
  124. prev === conversationId ? prev : conversationId
  125. );
  126. }, []);
  127. const updateConversation = useCallback(
  128. (
  129. conversationId: number,
  130. updater: (conversation: ConversationSummary) => ConversationSummary,
  131. options?: { skipResort?: boolean }
  132. ) => {
  133. const applyUpdate = (list: ConversationSummary[]) => {
  134. let changed = false;
  135. const next = list.map((conv) => {
  136. if (conv.id === conversationId) {
  137. changed = true;
  138. return updater(conv);
  139. }
  140. return conv;
  141. });
  142. if (!changed) {
  143. return list;
  144. }
  145. if (options?.skipResort) {
  146. return next;
  147. }
  148. return sortByUpdatedAtDesc(next);
  149. };
  150. setConversations((prev) => applyUpdate(prev));
  151. setFilteredConversations((prev) => {
  152. if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
  153. return prev;
  154. }
  155. return applyUpdate(prev);
  156. });
  157. },
  158. []
  159. );
  160. const setAllConversations = useCallback((data: ConversationSummary[]) => {
  161. setConversations(data);
  162. if (!searchRef.current.trim()) {
  163. const filtered = applyFilter(data);
  164. setFilteredConversations(filtered);
  165. }
  166. }, [applyFilter]);
  167. const hasConversation = useCallback(
  168. (conversationId: number) => {
  169. return conversations.some((conv) => conv.id === conversationId);
  170. },
  171. [conversations]
  172. );
  173. // 建立全局 WebSocket 连接以接收 visitor_status_update 等全局事件
  174. // 使用第一个对话的 ID(如果存在),否则不建立连接
  175. const globalConversationId = conversations.length > 0 ? conversations[0].id : null;
  176. // 处理 visitor_status_update 事件
  177. const handleVisitorStatusUpdate = useCallback(
  178. (event: WSMessage<ChatWebSocketPayload>) => {
  179. if (event.type === "visitor_status_update" && event.data) {
  180. const payload = event.data as VisitorStatusUpdatePayload;
  181. if (payload?.conversation_id) {
  182. if (payload.is_online === true) {
  183. // 在线:更新为当前时间(实时更新在线状态)
  184. updateConversation(payload.conversation_id, (conv) => ({
  185. ...conv,
  186. last_seen_at: new Date().toISOString(),
  187. }));
  188. }
  189. // 离线时,last_seen_at 会在后端更新,这里不需要特殊处理
  190. // 因为对话列表会定期刷新,或者通过其他方式更新
  191. }
  192. }
  193. },
  194. [updateConversation]
  195. );
  196. // 建立全局 WebSocket 连接(用于接收全局事件)
  197. useWebSocket<ChatWebSocketPayload>({
  198. conversationId: globalConversationId,
  199. enabled: Boolean(globalConversationId && agentId),
  200. isVisitor: false,
  201. agentId: agentId ?? undefined,
  202. onMessage: handleVisitorStatusUpdate,
  203. onError: (error) => {
  204. // 静默处理错误,避免影响用户体验
  205. },
  206. onClose: () => {
  207. // 静默处理关闭,避免影响用户体验
  208. },
  209. });
  210. const contextValue = useMemo(
  211. () => ({
  212. conversations,
  213. filteredConversations,
  214. selectedConversationId,
  215. searchQuery,
  216. loading,
  217. isInitialLoad,
  218. setSearchQuery,
  219. selectConversation,
  220. refresh: loadConversations,
  221. updateConversation,
  222. setAllConversations,
  223. hasConversation,
  224. }),
  225. [
  226. conversations,
  227. filteredConversations,
  228. selectedConversationId,
  229. searchQuery,
  230. loading,
  231. isInitialLoad,
  232. selectConversation,
  233. loadConversations,
  234. updateConversation,
  235. setAllConversations,
  236. setSearchQuery,
  237. hasConversation,
  238. ]
  239. );
  240. return contextValue;
  241. }