useChatTasks.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { computed, ref } from 'vue';
  2. import dayjs from 'dayjs';
  3. import { message } from 'ant-design-vue';
  4. import { buildReportDownloadUrl, editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api';
  5. import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
  6. import type { HistoryMessage } from '/@/views/ventAI/manageAssistent/api';
  7. import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
  8. /**
  9. * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、
  10. * 会话 id 迁移与历史消息转换。
  11. *
  12. * messages 是唯一数据源:直接读写 currentTask.messages,不再维护两份拷贝;
  13. * 切换任务、创建任务这类会牵动面板/额度/滚动的编排逻辑仍留在组件里,此处只提供状态与纯函数。
  14. */
  15. export const useChatTasks = () => {
  16. const taskList = ref<Task[]>([]);
  17. // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
  18. const knownApiSessionIds = new Set<string>();
  19. // 从接口获取会话列表
  20. const fetchSessionList = async () => {
  21. try {
  22. const res = await getHistoryList({});
  23. // getHistoryList 已按真实契约标注 { sessions, total },不再猜 sessions / data / 裸数组三种外壳
  24. const sessions = res?.sessions ?? [];
  25. const apiTasks: Task[] = sessions.map((item) => ({
  26. id: item.session_id,
  27. name: item.title || '未命名会话',
  28. sessionId: item.session_id,
  29. messages: [],
  30. attachedFiles: [],
  31. updatedAt: item.updated_at,
  32. sortOrder: item.sort_order,
  33. }));
  34. // Build lookup maps for O(1) access
  35. const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
  36. const apiTaskIds = new Set(apiTasks.map((t) => t.id));
  37. // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
  38. // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
  39. let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
  40. const mergedTasks: Task[] = [];
  41. for (const apiTask of apiTasks) {
  42. if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
  43. blankStreamingTask.pendingSessionId = apiTask.id;
  44. blankStreamingTask.name = apiTask.name;
  45. blankStreamingTask.updatedAt = apiTask.updatedAt;
  46. blankStreamingTask.sortOrder = apiTask.sortOrder;
  47. blankStreamingTask = null;
  48. continue;
  49. }
  50. const existing = existingTaskMap.get(apiTask.id);
  51. if (existing) {
  52. // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
  53. existing.name = apiTask.name;
  54. existing.updatedAt = apiTask.updatedAt;
  55. existing.sortOrder = apiTask.sortOrder;
  56. mergedTasks.push(existing);
  57. } else {
  58. mergedTasks.push(apiTask);
  59. }
  60. }
  61. apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
  62. const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
  63. taskList.value = [...localOnlyTasks, ...mergedTasks];
  64. return mergedTasks;
  65. } catch (error) {
  66. console.error('获取会话列表失败:', error);
  67. }
  68. return [];
  69. };
  70. const currentTaskId = ref('');
  71. const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
  72. // 当前会话的消息。单一数据源:直接读写 currentTask.messages,不再维护
  73. // 「视图数组 + 任务数组」两份拷贝——历史实现需要在每次流式事件后手工同步两者,
  74. // 是任务切换/流式并存时视图错乱的根源。
  75. // fallbackMessages 仅用于「任务已全部删除」这类没有当前任务的瞬时状态,行为与旧 ref([]) 一致。
  76. const fallbackMessages: Message[] = [];
  77. const messages = computed<Message[]>({
  78. get: () => currentTask.value?.messages ?? fallbackMessages,
  79. set: (value: Message[]) => {
  80. const task = currentTask.value;
  81. if (task) task.messages = value;
  82. },
  83. });
  84. // Non-reactive lookup cache - Vue 3 does not track Set mutations on ref<Set>
  85. const historyLoadedTasks = new Set<string>();
  86. // 正在拉取历史的任务 id(前端任务 id)。
  87. //
  88. // 切换任务时 currentTaskId 立即切过去,而新任务的 messages 要等 getDetail 回来才有内容:
  89. // 这段窗口里消息区会落到 messages.length === 0,若不区分「加载中」与「确实没有消息」,
  90. // 用户会先看到一屏空会话缺省图再突然出现内容。消息区据此显示加载态。
  91. //
  92. // 用数组而非 Set:ref<Set> 的 mutation 不被 Vue 追踪(同 historyLoadedTasks 的注释),
  93. // 整体替换数组才是可靠的响应式写法。
  94. const historyLoadingTaskIds = ref<string[]>([]);
  95. const setHistoryLoading = (taskId: string, loading: boolean) => {
  96. const rest = historyLoadingTaskIds.value.filter((id) => id !== taskId);
  97. historyLoadingTaskIds.value = loading ? [...rest, taskId] : rest;
  98. };
  99. const setCurrentSessionId = (sessionId: string) => {
  100. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  101. if (currentTask && currentTask.id !== sessionId) {
  102. taskList.value = taskList.value.filter((t) => t.id !== sessionId);
  103. currentTask.id = sessionId;
  104. currentTask.sessionId = sessionId;
  105. currentTaskId.value = sessionId;
  106. historyLoadedTasks.add(sessionId);
  107. }
  108. };
  109. const handlePinTask = async (task: Task) => {
  110. if (!task.sessionId) {
  111. message.warning('请先发送消息后再置顶');
  112. return;
  113. }
  114. try {
  115. await pinSession(task.sessionId);
  116. await fetchSessionList();
  117. } catch {
  118. message.error('置顶操作失败');
  119. }
  120. };
  121. // 标题保存(由 ChatHeader 子组件触发,父组件负责 API 调用和状态更新)
  122. const handleSaveTitle = async (newTitle: string) => {
  123. if (!currentTask.value?.sessionId) return;
  124. try {
  125. await editSessionTitle(currentTask.value.sessionId, newTitle);
  126. currentTask.value.name = newTitle;
  127. message.success('标题修改成功');
  128. } catch {
  129. message.error('标题修改失败');
  130. }
  131. };
  132. const getDefaultMessages = (): Message[] => {
  133. return [];
  134. };
  135. /**
  136. * 历史消息 → 前端 Message[]。入参直接用接口声明的 HistoryMessage,
  137. * 不再在本文件另立一份结构副本:两套形状一旦分叉,接口字段变化就不会在这里报错。
  138. */
  139. const transformHistoryToMessages = (data: HistoryMessage[]): Message[] => {
  140. const messages: Message[] = [];
  141. for (const item of data) {
  142. const normalizedContent = normalizeLineBreaks(item.content);
  143. const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
  144. const createdAt = item.created_at || '';
  145. // 新格式:role 字段 (user/assistant)
  146. if (item.role === 'user') {
  147. messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
  148. } else if (item.role === 'assistant') {
  149. const msg: Message = { id: item.id, type: 'ai', content: normalizedContent, time, createdAt };
  150. if (item.report_url && item.report_filename) {
  151. // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一
  152. msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }];
  153. }
  154. // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
  155. const rawCitations = item.citations;
  156. if (Array.isArray(rawCitations)) {
  157. msg.citations = { items: rawCitations, model_only: rawCitations.length === 0 };
  158. } else if (rawCitations) {
  159. // null 与 undefined 对调用方等价(均按「没有引用」处理),不赋值以免把 Message 上的可选字段写成 null
  160. msg.citations = rawCitations;
  161. }
  162. messages.push(msg);
  163. }
  164. // 兼容旧格式:type 字段
  165. else if (item.type === 'user_message') {
  166. messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
  167. } else if (item.type === 'model_thinking') {
  168. const lastAiMsg = messages[messages.length - 1];
  169. if (lastAiMsg && lastAiMsg.type === 'ai') {
  170. lastAiMsg.content += normalizedContent;
  171. lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  172. } else {
  173. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  174. }
  175. }
  176. }
  177. return messages;
  178. };
  179. return {
  180. taskList,
  181. currentTaskId,
  182. currentTask,
  183. messages,
  184. historyLoadedTasks,
  185. historyLoadingTaskIds,
  186. setHistoryLoading,
  187. getDefaultMessages,
  188. transformHistoryToMessages,
  189. fetchSessionList,
  190. handlePinTask,
  191. handleSaveTitle,
  192. setCurrentSessionId,
  193. };
  194. };