Bläddra i källkod

[Mod 0000]修改AI助手在对话过程中点击停止对话后的逻辑

wangkeyi 1 dag sedan
förälder
incheckning
8b5d4188e3

+ 2 - 0
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -748,6 +748,8 @@
           type: 'ai',
           content: isStreamError ? error.message : '抱歉,请求失败,请稍后重试。',
           time: dayjs().format('HH:mm'),
+          // 这轮请求失败了,后端没有对应记录:标记为仅前端消息,回填主键时跳过
+          localOnly: true,
         });
       }
     } finally {

+ 60 - 34
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -85,6 +85,41 @@ export const useChatSend = (deps: ChatSendDeps) => {
 
   const getSessionRefIconUrl = () => getCssUrl('--img-chat-popup-session-icon');
 
+  // 把「刚开出来的空白任务」迁移到后端会话:写 sessionId、任务 id 换成会话 id、AbortController 与
+  // 审批状态跟着搬、列表去重,并把该会话标记为已加载。
+  //
+  // 正常结束与「停止生成」共用这一份:停止生成时后端会话同样已经建好(事件里带着 session_id),
+  // 不迁移的话任务拿不到 sessionId,下一次提问会再开一个新会话 —— 前面那条提问留在旧会话里,
+  // 上下文就断了(现象:侧边栏多出一个只有提问、没有回答的会话)。
+  //
+  // fallbackName 只在没有拿到后端标题时用来命名(传空串表示不命名)。
+  const migrateTaskToSession = (originTaskId: string, sessionId: string, fallbackName: string) => {
+    const task = getTaskList().find((t) => t.id === originTaskId);
+    if (!task || task.sessionId || !sessionId) return;
+    task.sessionId = sessionId;
+    // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
+    if (task.pendingSessionId !== sessionId && fallbackName) {
+      task.name = fallbackName.substring(0, 20) + (fallbackName.length > 20 ? '...' : '');
+    }
+    task.pendingSessionId = undefined;
+    if (task.id !== sessionId) {
+      // 将 AbortController 从旧 ID 迁移到新 ID
+      const controller = taskAbortControllers.get(originTaskId);
+      if (controller) {
+        setTaskAbortController(originTaskId, null);
+        setTaskAbortController(sessionId, controller);
+      }
+      setTaskList(getTaskList().filter((t) => t.id !== sessionId));
+      task.id = sessionId;
+      historyLoadedTasks.add(sessionId);
+      // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
+      migrateTaskApproval(originTaskId, sessionId);
+    }
+    if (getCurrentTaskId() === originTaskId) {
+      setCurrentTaskId(sessionId);
+    }
+  };
+
   // targetTaskId:回答写进哪个任务。默认当前任务;重新生成与编辑重发是在若干次 await 之后才调到这里,
   // 必须显式传入发起任务,否则请求期间切走后新回答会落到别的会话上。
   const streamAiAnswer = async (userInput: string, file?: AttachedFile, targetTaskId?: string) => {
@@ -114,7 +149,13 @@ export const useChatSend = (deps: ChatSendDeps) => {
     const controller = new AbortController();
     setTaskAbortController(originTaskId, controller);
 
+    // 服务端每条事件都带 session_id(连第一条 agent_start 都有)。正常结束时下面用返回值里的,
+    // 但用户中途「停止生成」时流是被 abort 的、拿不到返回值,先把事件里出现的 id 记下来,
+    // 中断后才能把任务锚定到后端已建好的这个会话上(否则下一次提问会另起会话,上下文断掉)。
+    let eventSessionId = '';
+
     const onChunk = (data: SseEvent) => {
+      if (!eventSessionId && data.session_id) eventSessionId = data.session_id;
       handleStreamEvent(data, aiMsg, originTaskId);
       if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
     };
@@ -142,38 +183,11 @@ export const useChatSend = (deps: ChatSendDeps) => {
             onChunk
           );
 
-      if (result.session_id) {
-        const originTask = getTaskList().find((t) => t.id === originTaskId);
-        if (originTask && !originTask.sessionId) {
-          originTask.sessionId = result.session_id;
-          // 流式期间若列表已用后端标题归并过该任务,则保留后端标题;否则用首条消息命名
-          if (originTask.pendingSessionId !== result.session_id) {
-            const taskName = userInput || (file ? `文件:${file.name}` : '');
-            originTask.name = taskName.substring(0, 20) + (taskName.length > 20 ? '...' : '');
-          }
-          originTask.pendingSessionId = undefined;
-          if (originTask.id !== result.session_id) {
-            // 将 AbortController 从旧 ID 迁移到新 ID
-            const controller = taskAbortControllers.get(originTaskId);
-            if (controller) {
-              setTaskAbortController(originTaskId, null);
-              setTaskAbortController(result.session_id, controller);
-            }
-            setTaskList(getTaskList().filter((t) => t.id !== result.session_id));
-            originTask.id = result.session_id;
-            historyLoadedTasks.add(result.session_id);
-            // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
-            migrateTaskApproval(originTaskId, result.session_id);
-          }
-          if (getCurrentTaskId() === originTaskId) {
-            setCurrentTaskId(result.session_id);
-          }
-        }
-      }
+      // 结果里的 session_id 与事件里捕获的是同一个,前者缺失时用后者兜底
+      migrateTaskToSession(originTaskId, result.session_id || eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
 
       // 本轮问答刚由后端持久化,补一次数据库主键:删除按钮依赖它,不补的话刚发出的消息没有删除
       // 入口(旧实现只在重新生成/编辑重发里补,正常发送出来的消息要刷新页面才补上)。
-      // 只能放在流正常结束之后——被中断的那一轮拿不到 session_id(见下面的 AbortError 分支)。
       await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
     } catch (error) {
       // 用户主动停止生成
@@ -185,6 +199,12 @@ export const useChatSend = (deps: ChatSendDeps) => {
         if (!aiMsg.content) {
           aiMsg.content = '已停止生成。';
         }
+        // 这一轮被中断,后端只落了用户消息(回答在正常结束时才落库),本地这条气泡没有对应记录
+        aiMsg.localOnly = true;
+        // 中断同样要把任务锚定到后端已建好的会话上,否则下一次提问会另起一个会话、上下文断掉
+        migrateTaskToSession(originTaskId, eventSessionId, userInput || (file ? `文件:${file.name}` : ''));
+        // 停下的这一轮里用户消息已经落库,补主键以便删除/编辑;占位气泡是 localOnly,配对时会被跳过
+        await hydrateMessageIds(originTask?.sessionId || '', originTask?.messages || []);
         return;
       }
       throw error;
@@ -260,6 +280,9 @@ export const useChatSend = (deps: ChatSendDeps) => {
   //     表现为「刚发出的 AI 消息没有删除按钮,刷新页面才出现」。条数与角色序列一致时,两条序列
   //     就是同一串问答,正文差异不影响一一对应关系。
   //
+  // 两步都跳过 localOnly 的消息(停止生成留下的占位气泡、发送失败的报错气泡):后端没有对应
+  // 记录,留在序列里会把后面的消息顶偏一位,让原本配得上的也整批配不上。
+  //
   // 早期实现要么按条数严格相等整批回填(长度差为负时会整体放弃),要么用长度差做尾部对齐
   // (每条未回填的消息都拿到前一条的 id,删除会删掉用户根本没想删的那一行),都已被这两种取代。
   const hydrateMessageIds = async (sessionId: string, msgs: Message[]) => {
@@ -292,7 +315,8 @@ export const useChatSend = (deps: ChatSendDeps) => {
       }
 
       for (const msg of msgs) {
-        if (msg.id != null) continue;
+        // localOnly(停止生成的占位气泡、发送失败的报错气泡)后端没有记录,不参与配对
+        if (msg.id != null || msg.localOnly) continue;
         // 用户消息要拿「真正发出去的那段文本」比对:气泡里的 content 含注入的引用图标 HTML,
         // 后端存的是纯文本(与编辑重发、重新生成取原始输入用的是同一个函数)
         const text = msg.type === 'user' ? getUserInputText(msg) : msg.content;
@@ -306,12 +330,14 @@ export const useChatSend = (deps: ChatSendDeps) => {
         assigned.add(id);
       }
 
-      const sameShape = history.length === msgs.length && history.every((h, i) => h.type === msgs[i].type);
+      // 位置配对同样剔除 localOnly:它们会把后面的消息整体顶偏一位,导致本可配上的也配不上
+      const pairable = msgs.filter((m) => !m.localOnly);
+      const sameShape = history.length === pairable.length && history.every((h, i) => h.type === pairable[i].type);
       if (!sameShape) return;
-      for (let i = 0; i < msgs.length; i++) {
+      for (let i = 0; i < pairable.length; i++) {
         const id = history[i].id;
-        if (msgs[i].id != null || id == null || assigned.has(id)) continue;
-        msgs[i].id = id;
+        if (pairable[i].id != null || id == null || assigned.has(id)) continue;
+        pairable[i].id = id;
       }
     } catch (e) {
       console.error('补齐消息 id 失败:', e);

+ 3 - 0
src/views/ventAI/manageAssistent/types/chat.ts

@@ -71,6 +71,9 @@ export interface Message {
   durationMs?: number;
   baseDurationMs?: number;
   generateStartTime?: number;
+  // 仅前端存在的消息:服务端没有对应记录(停止生成留下的「已停止生成。」占位、发送失败的错误气泡)。
+  // 回填主键时按形状配对必须跳过它,否则多出的一条会把位置顶偏,整批消息配不上或配错行。
+  localOnly?: boolean;
 }
 
 export interface Task {