فهرست منبع

[Feat 0000]ai助手新增聊天中断

wangkeyi 1 روز پیش
والد
کامیت
eb0568ce6d

+ 1 - 0
src/assets/images/ventAI/chatModalBlue/2-7.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786585089912" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6775" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M63.65 512c-0.01 0 0 0 0 0 2.04-148.61 27.84-271.66 103.31-345.05C240.34 91.49 363.39 65.68 512 63.65c148.61 2.04 271.66 27.84 345.05 103.31 75.46 73.38 101.27 196.43 103.3 345.04-2.04 148.61-27.85 271.66-103.31 345.05-73.38 75.46-196.43 101.27-345.04 103.3-148.61-2.04-271.66-27.84-345.05-103.31C91.49 783.66 65.68 660.61 63.65 512z" p-id="6776" fill="#ffffff"></path><path d="M452.66 353.01v317.98c0 24.52-20.06 44.59-44.59 44.59-24.52 0-44.59-20.06-44.59-44.59V353.01c0-24.52 20.06-44.59 44.59-44.59 24.53 0 44.59 20.07 44.59 44.59zM660.51 353.01v317.98c0 24.52-20.06 44.59-44.59 44.59-24.52 0-44.59-20.06-44.59-44.59V353.01c0-24.52 20.06-44.59 44.59-44.59 24.52 0 44.59 20.07 44.59 44.59z" fill="#ffffff" p-id="6777"></path></svg>

+ 0 - 1
src/design/themify/deepblue.less

@@ -87,7 +87,6 @@ html[data-theme='deepblue'] {
   --img-chat-agent-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
   --img-chat-copy-success-icon: url('/src/assets/images/ventAI/chatModalBlue/2-5.svg');
   --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.svg');
-  --img-chat-close-bg: url('/src/assets/images/ventAI/chatModalBlue/2-7.png');
   --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.png');
   --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/2-13.png');
   --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');

+ 1 - 0
src/design/themify/default.less

@@ -94,6 +94,7 @@ html {
   --img-chat-agent-icon: url('/src/assets/images/ventAI/chatModalBlue/2-4.svg');
   --img-chat-copy-success-icon: url('/src/assets/images/ventAI/chatModalBlue/2-5.svg');
   --img-chat-send-icon: url('/src/assets/images/ventAI/chatModalBlue/2-6.svg');
+  --img-chat-stop-icon: url('/src/assets/images/ventAI/chatModalBlue/2-7.svg');
   --img-chat-download-icon: url('/src/assets/images/ventAI/chatModalBlue/2-8.png');
   --img-chat-input-bg: url('/src/assets/images/ventAI/chatModalBlue/1-7.svg');
   --img-chat-attach-icon: url('/src/assets/images/ventAI/chatModalBlue/3-7.svg');

+ 15 - 6
src/views/ventAI/manageAssistent/api.ts

@@ -108,7 +108,7 @@ export const uploadSkill = (file: File) => {
  * @returns Promise<{ session_id: string }>
  */
 export const unifiedStream = async (
-  params: { message: string; session_id?: string; file?: File; mode?: string },
+  params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -128,6 +128,7 @@ export const unifiedStream = async (
       method: 'POST',
       body: formData,
       headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -178,7 +179,9 @@ export const unifiedStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('统一对话流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('统一对话流式请求失败:', error);
+    }
     throw error;
   }
 };
@@ -192,7 +195,7 @@ export const unifiedStream = async (
  * @returns Promise<{ session_id: string }>
  */
 export const reviewPdfStream = async (
-  params: { file: File; session_id?: string; message?: string; mode?: string },
+  params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -212,6 +215,7 @@ export const reviewPdfStream = async (
       method: 'POST',
       body: formData,
       headers: { 'X-Access-Token': getToken() },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -262,7 +266,9 @@ export const reviewPdfStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('PDF审查流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('PDF审查流式请求失败:', error);
+    }
     throw error;
   }
 };
@@ -276,7 +282,7 @@ export const reviewPdfStream = async (
  * @returns Promise<{ session_id: string }>
  */
 export const chatResumeStream = async (
-  params: { session_id: string; thread_id?: string; action: string },
+  params: { session_id: string; thread_id?: string; action: string; signal?: AbortSignal },
   onChunk: (chunk: string) => void
 ): Promise<{ session_id: string }> => {
   try {
@@ -295,6 +301,7 @@ export const chatResumeStream = async (
         'Content-Type': 'application/json',
         'X-Access-Token': getToken(),
       },
+      signal: params.signal,
     });
 
     if (!response.ok) {
@@ -345,7 +352,9 @@ export const chatResumeStream = async (
 
     return { session_id: sessionId };
   } catch (error) {
-    console.error('恢复会话流式请求失败:', error);
+    if ((error as Error)?.name !== 'AbortError') {
+      console.error('恢复会话流式请求失败:', error);
+    }
     throw error;
   }
 };

+ 102 - 11
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -91,6 +91,7 @@
           v-model="inputMessage"
           :pendingFile="pendingFile"
           :loading="loading || !!pendingApprovalSessionId"
+          :streaming="streaming"
           :contextUsed="contextUsed"
           :contextMax="contextMax"
           :contextPercent="contextPercent"
@@ -100,6 +101,7 @@
           :taskList="taskList"
           :currentTaskId="currentTaskId"
           @send="handleSendMessage"
+          @stop="stopStreaming"
           @file-upload="triggerFileUpload"
           @file-change="handleFileUpload"
           @remove-pending-file="removePendingFile"
@@ -334,8 +336,14 @@
 
   const inputMessage = ref('');
   const loading = ref(false);
+  const streaming = ref(false);
+  const activeAbortController = ref<AbortController | null>(null);
   const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
 
+  const stopStreaming = () => {
+    activeAbortController.value?.abort();
+  };
+
   const getCurrentSessionId = (): string => {
     const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
     return currentTask?.sessionId || '';
@@ -496,12 +504,17 @@
     showTodoBar.value = false;
     currentTodos.value = [];
 
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
+
     try {
       const result = await chatResumeStream(
         {
           session_id: sessionId,
           thread_id: pendingApprovalThreadId.value || undefined,
           action: 'approve',
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, approveTask.id);
@@ -513,13 +526,27 @@
         setCurrentSessionId(result.session_id);
       }
     } catch (error) {
-      console.error('审批继续执行失败:', error);
-      message.error('审批失败,请重试');
-      lastMsg.isLoading = false;
-      if (!lastMsg.content) {
-        lastMsg.content = '抱歉,审批请求失败,请稍后重试。';
+      if ((error as Error)?.name === 'AbortError') {
+        // 用户主动停止生成
+        lastMsg.isLoading = false;
+        // 后端未返回耗时,冻结前端伪计时
+        lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
+        if (!lastMsg.content) {
+          lastMsg.content = '已停止生成。';
+        }
+      } else {
+        console.error('审批继续执行失败:', error);
+        message.error('审批失败,请重试');
+        lastMsg.isLoading = false;
+        if (!lastMsg.content) {
+          lastMsg.content = '抱歉,审批请求失败,请稍后重试。';
+        }
       }
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       loading.value = false;
       approveTask.isStreaming = false;
       pendingApprovalSessionId.value = '';
@@ -549,6 +576,7 @@
 
     rejectTask.messages = [...taskMessages];
 
+    const threadId = pendingApprovalThreadId.value || undefined;
     pendingApprovalSessionId.value = '';
     pendingApprovalThreadId.value = '';
 
@@ -556,7 +584,7 @@
       await chatResumeStream(
         {
           session_id: sessionId,
-          thread_id: pendingApprovalThreadId.value || undefined,
+          thread_id: threadId,
           action: 'reject',
         },
         () => {}
@@ -1058,6 +1086,8 @@
           timestamp: Date.now(),
         });
         aiMsg.isLoading = false;
+        // 后端未返回耗时,冻结前端伪计时
+        aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         break;
 
       case 'word_download':
@@ -1072,9 +1102,9 @@
           aiMsg.sessionId = data.session_id;
           fetchContext(data.session_id);
         }
-        if (data.duration_ms) {
-          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + data.duration_ms;
-        }
+        aiMsg.durationMs = data.duration_ms
+          ? (aiMsg.baseDurationMs || 0) + data.duration_ms
+          : (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
         aiMsg.isLoading = false;
         break;
 
@@ -1085,8 +1115,9 @@
         break;
 
       case 'interrupt': {
-        // 优先使用 agent_done 已设置的后端时间,否则用前端计时
-        const elapsedMs = aiMsg.baseDurationMs || (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        // 后端各段耗时 + 前端当前段计时,吸收进 baseDurationMs 保证审批继续后计时连续
+        const elapsedMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        aiMsg.baseDurationMs = elapsedMs;
         aiMsg.isLoading = false;
         aiMsg.isPendingApproval = true;
         const interruptSessionId = data.session_id || streamingTask.sessionId;
@@ -1174,12 +1205,17 @@
     const originTask = taskList.value.find((t) => t.id === originTaskId);
     if (originTask) originTask.isStreaming = true;
 
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
+
     try {
       const result = await unifiedStream(
         {
           message: userInput,
           session_id: getCurrentSessionId() || undefined,
           mode: editMode.value,
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, originTaskId);
@@ -1202,7 +1238,31 @@
           }
         }
       }
+    } catch (error) {
+      // 用户主动停止生成
+      if ((error as Error)?.name === 'AbortError') {
+        const originTask = taskList.value.find((t) => t.id === originTaskId);
+        const arr = currentTaskId.value === originTaskId ? messages.value : originTask?.messages;
+        const aiMsg = arr?.[aiMsgIndex];
+        if (aiMsg) {
+          aiMsg.isLoading = false;
+          // 后端未返回耗时,冻结前端伪计时
+          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+          if (!aiMsg.content) {
+            aiMsg.content = '已停止生成。';
+          }
+        }
+        if (originTask && currentTaskId.value !== originTaskId) {
+          originTask.messages = [...(arr || [])];
+        }
+        return;
+      }
+      throw error;
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       const originTask = taskList.value.find((t) => t.id === originTaskId);
       if (originTask) originTask.isStreaming = false;
     }
@@ -1251,6 +1311,10 @@
     const originTask = taskList.value.find((t) => t.id === originTaskId);
     if (originTask) originTask.isStreaming = true;
 
+    const controller = new AbortController();
+    activeAbortController.value = controller;
+    streaming.value = true;
+
     try {
       const result = await reviewPdfStream(
         {
@@ -1258,6 +1322,7 @@
           session_id: getCurrentSessionId() || undefined,
           message: userInput || undefined,
           mode: editMode.value,
+          signal: controller.signal,
         },
         (chunk: string) => {
           parseSSEData(chunk, aiMsgIndex, originTaskId);
@@ -1281,7 +1346,31 @@
           }
         }
       }
+    } catch (error) {
+      // 用户主动停止生成
+      if ((error as Error)?.name === 'AbortError') {
+        const originTask = taskList.value.find((t) => t.id === originTaskId);
+        const arr = currentTaskId.value === originTaskId ? messages.value : originTask?.messages;
+        const aiMsg = arr?.[aiMsgIndex];
+        if (aiMsg) {
+          aiMsg.isLoading = false;
+          // 后端未返回耗时,冻结前端伪计时
+          aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+          if (!aiMsg.content) {
+            aiMsg.content = '已停止生成。';
+          }
+        }
+        if (originTask && currentTaskId.value !== originTaskId) {
+          originTask.messages = [...(arr || [])];
+        }
+        return;
+      }
+      throw error;
     } finally {
+      if (activeAbortController.value === controller) {
+        activeAbortController.value = null;
+      }
+      streaming.value = false;
       const originTask = taskList.value.find((t) => t.id === originTaskId);
       if (originTask) originTask.isStreaming = false;
     }
@@ -1461,6 +1550,8 @@
       clearTimeout(downloadConfirmTimer);
       downloadConfirmTimer = null;
     }
+    // 组件卸载时主动断开流式连接
+    activeAbortController.value?.abort();
   });
 </script>
 

+ 17 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -192,8 +192,8 @@
           </div>
         </div>
         <div class="action-divider"></div>
-        <div class="btn-send" :class="{ disabled: !modelValue.trim() || loading }" @click="handleSend">
-          <div class="send-icon"></div>
+        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
+          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
         </div>
       </div>
       <!-- 会话列表弹框 -->
@@ -243,6 +243,7 @@
       modelValue: string;
       pendingFile: AttachedFile | null;
       loading: boolean;
+      streaming?: boolean;
       contextUsed?: number;
       contextMax?: number;
       contextPercent?: number;
@@ -256,6 +257,7 @@
       contextUsed: 0,
       contextMax: 1000000,
       contextPercent: 0,
+      streaming: false,
       contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
       initialThinkLevel: 'off',
       initialEditMode: 'full',
@@ -267,6 +269,7 @@
   const emit = defineEmits<{
     (e: 'update:modelValue', value: string): void;
     (e: 'send'): void;
+    (e: 'stop'): void;
     (e: 'file-upload'): void;
     (e: 'file-change', event: Event): void;
     (e: 'remove-pending-file'): void;
@@ -614,6 +617,14 @@
     }
   };
 
+  const handleSendClick = () => {
+    if (props.streaming) {
+      emit('stop');
+      return;
+    }
+    handleSend();
+  };
+
   defineExpose({ fileInputRef });
 </script>
 
@@ -1272,6 +1283,10 @@
             background-image: var(--img-chat-send-icon);
             background-repeat: no-repeat;
             background-size: 100% 100%;
+
+            &.stop-icon {
+              background-image: var(--img-chat-stop-icon);
+            }
           }
         }
       }