useChatSend.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { computed, nextTick } from 'vue';
  2. import dayjs from 'dayjs';
  3. import { message } from 'ant-design-vue';
  4. import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api';
  5. import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
  6. import { getCssUrl, normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
  7. /**
  8. * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
  9. * session_id 迁移与异常清理;另含停止生成、卸载断开、历史消息 id 回填、重新生成、编辑重发。
  10. *
  11. * 输入框交互状态(草稿、待发附件、引用)留在组件里,这里只负责「消息落库 + 起流」;任务/消息读写、
  12. * SSE 事件处理、滚动跟随、待办重置与审批 key 迁移等能力通过 deps 注入(一律延迟求值,避免 setup 期 TDZ)。
  13. */
  14. export interface ChatSendDeps {
  15. getTaskList: () => Task[];
  16. getCurrentTaskId: () => string;
  17. setCurrentTaskId: (taskId: string) => void;
  18. getMessages: () => Message[];
  19. setTaskList: (tasks: Task[]) => void;
  20. getEditMode: () => string;
  21. getPendingApproval: () => unknown;
  22. getChatMessages: () => { forceScrollToBottom?: () => void } | undefined;
  23. handleStreamEvent: (data: SseEvent, aiMsg: Message, taskId: string) => void;
  24. scrollToBottomThrottled: (force?: boolean) => void;
  25. resetTodos: () => void;
  26. migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
  27. historyLoadedTasks: Set<string>;
  28. transformHistoryToMessages: (data: Array<Record<string, unknown>>) => Message[];
  29. }
  30. export const useChatSend = (deps: ChatSendDeps) => {
  31. const {
  32. getTaskList,
  33. getCurrentTaskId,
  34. setCurrentTaskId,
  35. getMessages,
  36. setTaskList,
  37. getEditMode,
  38. getPendingApproval,
  39. getChatMessages,
  40. handleStreamEvent,
  41. scrollToBottomThrottled,
  42. resetTodos,
  43. migrateTaskApproval,
  44. historyLoadedTasks,
  45. transformHistoryToMessages,
  46. } = deps;
  47. // 当前任务是否正在流式输出(原 streaming 别名与 loading 完全等价,统一用 loading)
  48. const loading = computed(() => {
  49. const currentTask = getTaskList().find((t) => t.id === getCurrentTaskId());
  50. return currentTask?.isStreaming ?? false;
  51. });
  52. // Non-reactive Map for AbortControllers - only used imperatively, no need for Vue reactivity
  53. const taskAbortControllers = new Map<string, AbortController>();
  54. // 设置任务的AbortController
  55. const setTaskAbortController = (taskId: string, controller: AbortController | null) => {
  56. if (controller) {
  57. taskAbortControllers.set(taskId, controller);
  58. } else {
  59. taskAbortControllers.delete(taskId);
  60. }
  61. };
  62. // 停止指定任务的流式传输
  63. const stopTaskStreaming = (taskId: string) => {
  64. const controller = taskAbortControllers.get(taskId);
  65. controller?.abort();
  66. };
  67. const stopStreaming = () => {
  68. stopTaskStreaming(getCurrentTaskId());
  69. };
  70. // 组件卸载/清理时断开所有任务的流式连接
  71. const abortAllStreams = () => {
  72. taskAbortControllers.forEach((controller) => controller.abort());
  73. taskAbortControllers.clear();
  74. };
  75. const getSkillRefIconUrl = () => getCssUrl('--img-chat-options-skill-icon');
  76. const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
  77. // targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
  78. // 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
  79. const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
  80. const originTaskId = targetTaskId || getCurrentTaskId();
  81. const originTask = getTaskList().find((t) => t.id === originTaskId);
  82. if (!originTask) return;
  83. const now = dayjs();
  84. const newMsg: Message = {
  85. type: 'ai',
  86. content: '',
  87. time: now.format('HH:mm'),
  88. createdAt: now.toISOString(),
  89. isLoading: true,
  90. thinkingSteps: [],
  91. generateStartTime: Date.now(),
  92. };
  93. originTask.messages.push(newMsg);
  94. // 从数组里读回这一条:任务状态是 reactive 的,push 进去的原始对象由代理包裹,
  95. // 之后必须通过代理读写才会触发视图更新——直接改上面的 newMsg,界面会一直停在“正在输入”。
  96. const aiMsg = originTask.messages[originTask.messages.length - 1];
  97. // 进度条属于「正在看的任务」,不要为别的任务清空它
  98. if (getCurrentTaskId() === originTaskId) resetTodos();
  99. originTask.isStreaming = true;
  100. const controller = new AbortController();
  101. setTaskAbortController(originTaskId, controller);
  102. const onChunk = (data: SseEvent) => {
  103. handleStreamEvent(data, aiMsg, originTaskId);
  104. if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
  105. };
  106. try {
  107. const originalFile = file?.originalFile;
  108. const result = originalFile
  109. ? await reviewPdfStream(
  110. {
  111. file: originalFile,
  112. session_id: originTask.sessionId || undefined,
  113. message: userInput || undefined,
  114. mode: getEditMode(),
  115. signal: controller.signal,
  116. },
  117. onChunk
  118. )
  119. : await unifiedStream(
  120. {
  121. message: userInput,
  122. session_id: originTask.sessionId || undefined,
  123. mode: getEditMode(),
  124. signal: controller.signal,
  125. },
  126. onChunk
  127. );
  128. if (result.session_id) {
  129. const originTask = getTaskList().find((t) => t.id === originTaskId);
  130. if (originTask && !originTask.sessionId) {
  131. originTask.sessionId = result.session_id;
  132. // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
  133. if (originTask.pendingSessionId !== result.session_id) {
  134. const taskName = userInput || (file ? `文件:${file.name}` : '');
  135. originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
  136. }
  137. originTask.pendingSessionId = undefined;
  138. if (originTask.id !== result.session_id) {
  139. // 将 AbortController 从旧 ID 迁移到新 ID
  140. const controller = taskAbortControllers.get(originTaskId);
  141. if (controller) {
  142. setTaskAbortController(originTaskId, null);
  143. setTaskAbortController(result.session_id, controller);
  144. }
  145. setTaskList(getTaskList().filter((t) => t.id !== result.session_id));
  146. originTask.id = result.session_id;
  147. historyLoadedTasks.add(result.session_id);
  148. // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
  149. migrateTaskApproval(originTaskId, result.session_id);
  150. }
  151. if (getCurrentTaskId() === originTaskId) {
  152. setCurrentTaskId(result.session_id);
  153. }
  154. }
  155. }
  156. } catch (error) {
  157. // 用户主动停止生成
  158. if ((error as Error)?.name === 'AbortError') {
  159. // 目标消息直接用发起流式时创建的那个对象:数组可能已被删除消息重排/变短,按下标取会指错
  160. aiMsg.isLoading = false;
  161. // 后端未返回耗时,冻结前端伪计时
  162. aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
  163. if (!aiMsg.content) {
  164. aiMsg.content = '已停止生成。';
  165. }
  166. return;
  167. }
  168. throw error;
  169. } finally {
  170. // 任务在流结束时可能已被改名为后端 session_id,需要使用最终的 ID 清理状态
  171. const finalTaskId = originTask?.id || originTaskId;
  172. setTaskAbortController(finalTaskId, null);
  173. if (originTask) originTask.isStreaming = false;
  174. }
  175. };
  176. const sendTextOnly = async (userInput: string, displayContent?: string) => {
  177. const now = dayjs();
  178. const userMsg: Message = {
  179. type: 'user',
  180. content: displayContent || userInput,
  181. rawInput: userInput,
  182. time: now.format('HH:mm'),
  183. createdAt: now.toISOString(),
  184. };
  185. getMessages().push(userMsg);
  186. await nextTick();
  187. getChatMessages()?.forceScrollToBottom?.();
  188. await streamAiAnswer(userInput);
  189. };
  190. const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
  191. const originTaskId = getCurrentTaskId();
  192. const currentTask = getTaskList().find((t) => t.id === originTaskId);
  193. if (currentTask) {
  194. currentTask.attachedFiles.push(file);
  195. } else {
  196. throw new Error('未找到当前任务');
  197. }
  198. const now = dayjs();
  199. const userMsg: Message = {
  200. type: 'user',
  201. content: displayContent || userInput || `上传了文件:${file.name}`,
  202. rawInput: userInput,
  203. time: now.format('HH:mm'),
  204. createdAt: now.toISOString(),
  205. attachedFile: file,
  206. };
  207. getMessages().push(userMsg);
  208. await nextTick();
  209. getChatMessages()?.forceScrollToBottom?.();
  210. // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
  211. if (!file.originalFile) {
  212. await streamAiAnswer(userInput);
  213. return;
  214. }
  215. await streamAiAnswer(userInput, file);
  216. };
  217. // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
  218. const stripInjectedRefs = (content: string): string =>
  219. (content || '')
  220. .replace(/\[<img[^>]*>[^\]]*\]/g, '')
  221. .replace(/<img[^>]*>/g, '')
  222. .trim();
  223. // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
  224. //
  225. // sessionId 与 msgs 都由调用方在流程开始时锚定后传入:本函数内部有 await,如果这里再去读
  226. // 「当前会话 / 当前任务」,请求期间用户切走就会把别的会话的消息当成目标,回填出的 id 会让
  227. // 后续的删除打到另一条会话上。
  228. //
  229. // 两边条数必须严格一致才回填。早期实现用「长度差」做尾部对齐,一旦本地存在仅前端的消息
  230. // (例如发送失败的报错气泡,后端没有对应行),长度差为负,之后每条未回填的消息都会拿到
  231. // 前一条的 id —— 删除按钮据此调 DELETE /messages/{id},删掉的是用户根本没想删的那一行。
  232. const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
  233. if (!sessionId || !msgs.length) return;
  234. try {
  235. const res = await getDetail(sessionId);
  236. const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
  237. if (!Array.isArray(raw)) return;
  238. // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
  239. const history = transformHistoryToMessages(raw);
  240. const ids = history.map((m) => m.id).filter((id): id is number => id != null);
  241. if (ids.length !== msgs.length) return;
  242. for (let i = 0; i < msgs.length; i++) {
  243. if (msgs[i].id == null) msgs[i].id = ids[i];
  244. }
  245. } catch (e) {
  246. console.error('补齐消息 id 失败:', e);
  247. }
  248. };
  249. // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)。
  250. // sessionId 同样由调用方锚定传入:若这里读「当前会话」,重新生成期间切走就会去别的会话里
  251. // 按内容匹配并删除 user 记录,可能删掉一条与本次操作无关的正常消息。
  252. const removeDuplicateRegenerateUserMessage = async (sessionId: string, expectedContent: string) => {
  253. if (!sessionId) return;
  254. try {
  255. const res = await getDetail(sessionId);
  256. const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
  257. if (!Array.isArray(raw)) return;
  258. // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
  259. const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
  260. const duplicates = userMsgs.filter(
  261. (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
  262. );
  263. for (const dup of duplicates) {
  264. await deleteMessage(dup.id);
  265. }
  266. } catch (e) {
  267. console.error('删除重发产生的重复用户消息失败:', e);
  268. }
  269. };
  270. // 重新生成最后一条回答
  271. const handleRegenerate = async () => {
  272. const task = getTaskList().find((t) => t.id === getCurrentTaskId());
  273. if (!task || task.isStreaming) return;
  274. if (getPendingApproval()) {
  275. message.warning('请先审批执行计划');
  276. return;
  277. }
  278. const msgs = task.messages;
  279. const lastAiIdx = msgs.length - 1;
  280. if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
  281. // 找前一条用户消息作为重发内容
  282. let userIdx = -1;
  283. for (let i = lastAiIdx - 1; i >= 0; i--) {
  284. if (msgs[i].type === 'user') {
  285. userIdx = i;
  286. break;
  287. }
  288. }
  289. if (userIdx < 0) return;
  290. const userMsg = msgs[userIdx];
  291. const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
  292. const attachedFile = userMsg.attachedFile;
  293. // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成。
  294. // 以下三步都在 await 之后,一律显式锚定发起任务:用「当前任务/当前会话」会在切走后作用于别的会话
  295. await streamAiAnswer(userInput, attachedFile, task.id);
  296. // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
  297. await removeDuplicateRegenerateUserMessage(task.sessionId, userInput);
  298. // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
  299. await hydrateMessageIds(task.sessionId, task.messages);
  300. };
  301. // 编辑用户消息并重发(截断该消息及其后所有消息)
  302. const handleEditResend = async (index: number, newContent: string) => {
  303. const task = getTaskList().find((t) => t.id === getCurrentTaskId());
  304. if (!task || task.isStreaming) return;
  305. if (getPendingApproval()) {
  306. message.warning('请先审批执行计划');
  307. return;
  308. }
  309. const msgs = task.messages;
  310. if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
  311. const trimmed = (newContent || '').trim();
  312. if (!trimmed) {
  313. message.warning('消息内容不能为空');
  314. return;
  315. }
  316. const attachedFile = msgs[index].attachedFile;
  317. // 全程锚定发起任务:这几步都在 await 之后,用「当前任务」会在切走后把别的会话的记录删掉
  318. await hydrateMessageIds(task.sessionId, msgs);
  319. // 从后往前删除后端记录(有 id 的才调接口)
  320. const toDelete = msgs.slice(index).filter((m) => m.id != null);
  321. for (const m of toDelete) {
  322. try {
  323. await deleteMessage(m.id!);
  324. } catch (e) {
  325. console.error('删除消息失败:', e);
  326. }
  327. }
  328. // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
  329. msgs.splice(index);
  330. const now = dayjs();
  331. const userMsg: Message = {
  332. type: 'user',
  333. content: trimmed,
  334. rawInput: trimmed,
  335. time: now.format('HH:mm'),
  336. createdAt: now.toISOString(),
  337. attachedFile,
  338. };
  339. msgs.push(userMsg);
  340. await nextTick();
  341. if (getCurrentTaskId() === task.id) getChatMessages()?.forceScrollToBottom?.();
  342. await streamAiAnswer(trimmed, attachedFile, task.id);
  343. };
  344. return {
  345. loading,
  346. stopStreaming,
  347. setTaskAbortController,
  348. abortAllStreams,
  349. getSkillRefIconUrl,
  350. getSessionRefIconUrl,
  351. streamAiAnswer,
  352. sendTextOnly,
  353. sendWithAttachment,
  354. stripInjectedRefs,
  355. hydrateMessageIds,
  356. removeDuplicateRegenerateUserMessage,
  357. handleRegenerate,
  358. handleEditResend,
  359. };
  360. };