1
0

useChatTasks.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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 { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
  7. /**
  8. * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、
  9. * 会话 id 迁移与历史消息转换。
  10. *
  11. * messages 是唯一数据源:直接读写 currentTask.messages,不再维护两份拷贝;
  12. * 切换任务、创建任务这类会牵动面板/额度/滚动的编排逻辑仍留在组件里,此处只提供状态与纯函数。
  13. */
  14. export const useChatTasks = () => {
  15. const taskList = ref<Task[]>([]);
  16. // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
  17. const knownApiSessionIds = new Set<string>();
  18. // 从接口获取会话列表
  19. const fetchSessionList = async () => {
  20. try {
  21. const res = await getHistoryList({});
  22. const sessions = res?.sessions || res?.data || [];
  23. if (Array.isArray(sessions)) {
  24. const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string; sort_order?: number }) => ({
  25. id: item.session_id,
  26. name: item.title || '未命名会话',
  27. sessionId: item.session_id,
  28. messages: [],
  29. attachedFiles: [],
  30. updatedAt: item.updated_at,
  31. sortOrder: item.sort_order,
  32. }));
  33. // Build lookup maps for O(1) access
  34. const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
  35. const apiTaskIds = new Set(apiTasks.map((t) => t.id));
  36. // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
  37. // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
  38. let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
  39. const mergedTasks: Task[] = [];
  40. for (const apiTask of apiTasks) {
  41. if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
  42. blankStreamingTask.pendingSessionId = apiTask.id;
  43. blankStreamingTask.name = apiTask.name;
  44. blankStreamingTask.updatedAt = apiTask.updatedAt;
  45. blankStreamingTask.sortOrder = apiTask.sortOrder;
  46. blankStreamingTask = null;
  47. continue;
  48. }
  49. const existing = existingTaskMap.get(apiTask.id);
  50. if (existing) {
  51. // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
  52. existing.name = apiTask.name;
  53. existing.updatedAt = apiTask.updatedAt;
  54. existing.sortOrder = apiTask.sortOrder;
  55. mergedTasks.push(existing);
  56. } else {
  57. mergedTasks.push(apiTask);
  58. }
  59. }
  60. apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
  61. const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
  62. taskList.value = [...localOnlyTasks, ...mergedTasks];
  63. return mergedTasks;
  64. }
  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. const setCurrentSessionId = (sessionId: string) => {
  87. const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
  88. if (currentTask && currentTask.id !== sessionId) {
  89. taskList.value = taskList.value.filter((t) => t.id !== sessionId);
  90. currentTask.id = sessionId;
  91. currentTask.sessionId = sessionId;
  92. currentTaskId.value = sessionId;
  93. historyLoadedTasks.add(sessionId);
  94. }
  95. };
  96. const handlePinTask = async (task: Task) => {
  97. if (!task.sessionId) {
  98. message.warning('请先发送消息后再置顶');
  99. return;
  100. }
  101. try {
  102. await pinSession(task.sessionId);
  103. await fetchSessionList();
  104. } catch {
  105. message.error('置顶操作失败');
  106. }
  107. };
  108. // 标题保存(由 ChatHeader 子组件触发,父组件负责 API 调用和状态更新)
  109. const handleSaveTitle = async (newTitle: string) => {
  110. if (!currentTask.value?.sessionId) return;
  111. try {
  112. await editSessionTitle(currentTask.value.sessionId, newTitle);
  113. currentTask.value.name = newTitle;
  114. message.success('标题修改成功');
  115. } catch {
  116. message.error('标题修改失败');
  117. }
  118. };
  119. const getDefaultMessages = (): Message[] => {
  120. return [];
  121. };
  122. const transformHistoryToMessages = (
  123. data: Array<{
  124. id?: number;
  125. role?: string;
  126. type?: string;
  127. content: string;
  128. created_at?: string;
  129. report_url?: string;
  130. report_filename?: string;
  131. citations?: any;
  132. }>
  133. ): Message[] => {
  134. const messages: Message[] = [];
  135. for (const item of data) {
  136. const normalizedContent = normalizeLineBreaks(item.content);
  137. const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
  138. const createdAt = item.created_at || '';
  139. // 新格式:role 字段 (user/assistant)
  140. if (item.role === 'user') {
  141. messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
  142. } else if (item.role === 'assistant') {
  143. const msg: Message = { id: item.id, type: 'ai', content: normalizedContent, time, createdAt };
  144. if (item.report_url && item.report_filename) {
  145. // 后端返回的报告地址前缀不稳定(绝对地址 / 缺 /ventAI 的相对路径),入前端状态前统一归一
  146. msg.downloadFiles = [{ url: buildReportDownloadUrl(item.report_url), filename: item.report_filename }];
  147. }
  148. if (item.citations) {
  149. // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
  150. const raw = item.citations;
  151. if (Array.isArray(raw)) {
  152. msg.citations = { items: raw, model_only: raw.length === 0 };
  153. } else {
  154. msg.citations = raw;
  155. }
  156. }
  157. messages.push(msg);
  158. }
  159. // 兼容旧格式:type 字段
  160. else if (item.type === 'user_message') {
  161. messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
  162. } else if (item.type === 'model_thinking') {
  163. const lastAiMsg = messages[messages.length - 1];
  164. if (lastAiMsg && lastAiMsg.type === 'ai') {
  165. lastAiMsg.content += normalizedContent;
  166. lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
  167. } else {
  168. messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
  169. }
  170. }
  171. }
  172. return messages;
  173. };
  174. return {
  175. taskList,
  176. currentTaskId,
  177. currentTask,
  178. messages,
  179. historyLoadedTasks,
  180. getDefaultMessages,
  181. transformHistoryToMessages,
  182. fetchSessionList,
  183. handlePinTask,
  184. handleSaveTitle,
  185. setCurrentSessionId,
  186. };
  187. };