useConversations.ts 8.9 KB

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