Browse Source

Merge branch 'master' of http://39.97.59.228:8013/hrx/mky-vent-base

bobo04052021@163.com 1 week ago
parent
commit
9ce6963101

+ 1 - 1
.env.development

@@ -6,7 +6,7 @@ VITE_PUBLIC_PATH = /
 
 # 跨域代理,您可以配置多个 ,请注意,没有换行符
 #VITE_PROXY = [["/jeecgboot","http://localhost:8080/jeecg-boot"],["/upload","http://localhost:3300/upload"]]
-VITE_PROXY = [["/sw","http://182.92.126.35:6008"],["/jeecgsystem","http://182.92.126.35:9999"],["/upload","http://182.92.126.35:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/modelreq", "http://182.92.126.35:9999"],["/webRtc", "http://182.92.126.35:8051"], ["/python", "http://127.0.0.1:8008"], ["/res", "http://182.92.126.35:9999/"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://39.97.59.228:8070"]]
+VITE_PROXY = [["/sw","http://182.92.126.35:6008"],["/jeecgsystem","http://182.92.126.35:9999"],["/upload","http://182.92.126.35:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/modelreq", "http://182.92.126.35:9999"],["/webRtc", "http://182.92.126.35:8051"], ["/python", "http://127.0.0.1:8008"], ["/res", "http://182.92.126.35:9999/"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://182.92.126.35:8070"]]
 # VITE_PROXY = [["/jeecgsystem","http://192.168.183.88:9999"],["/upload","http://192.168.183.88:9999/upload"],["/documents", "http://192.168.183.88:9050"],["/modelreq", "http://192.168.183.88:9999"],["/webRtc", "http://192.168.183.88:8051"]]
 # VITE_PROXY = [["/jeecgsystem","http://10.10.150.72:9999"],["/upload","http://localhost:3300/upload"],["/documents", "http://10.10.150.72:9050"],["/modelreq", "http://10.10.150.72:9999"],["/webRtc", "http://192.168.183.216:8051"]]
 # VITE_PROXY = [["/jeecgsystem","http://192.168.1.8:9999"],["/upload","http://localhost:3300/upload"]]

+ 1 - 1
.env.production

@@ -17,7 +17,7 @@ VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
 #VITE_PROXY = [["/jeecgsystem","http://10.248.223.116:9999"],["/upload","http://localhost:3300/upload"]]
 #VITE_PROXY = [["/jeecgsystem","http://10.248.210.152:9999"],["/upload","http://10.248.210.152:9999/upload"]]
 #VITE_PROXY = [["/jeecgsystem","http://182.92.126.35:9999"],["/upload","http://182.92.126.35:9999/upload"]]
-VITE_PROXY = [["/sw","http://10.246.167.205:6008"],["/modelreq","http://172.16.41.171:9999"],["/upload","http://172.16.41.171:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://39.97.59.228:8070"]]
+VITE_PROXY = [["/sw","http://10.246.167.205:6008"],["/modelreq","http://172.16.41.171:9999"],["/upload","http://172.16.41.171:9999/upload"],["/documents", "http://182.92.126.35:9050"],["/dataCenter", "http://39.97.59.228:10999"],["/ventAI", "http://182.92.126.35:8070"]]
 #VITE_PROXY = [["/jeecgsystem","http://172.16.41.171:9999"],["/upload","http://172.16.41.171:9999/upload"]]
 #VITE_PROXY = [["/jeecgsystem","http://10.120.120.163:9999"],["/upload","http://10.120.120.163:9999/upload"]]
 #VITE_PROXY = [["/jeecgsystem","http://192.168.1.4:9999"],["/upload","http://localhost:3300/upload"]]

+ 2 - 0
package.json

@@ -52,6 +52,7 @@
     "dayjs": "^1.11.9",
     "dexie": "^3.2.2",
     "dom-align": "^1.12.4",
+    "dompurify": "^3.4.14",
     "echarts": "^6.0.0",
     "emoji-mart-vue-fast": "^15.0.0",
     "gsap": "^3.11.3",
@@ -106,6 +107,7 @@
     "@rys-fe/vite-plugin-theme": "^0.8.6",
     "@types/codemirror": "^5.60.9",
     "@types/crypto-js": "^4.1.1",
+    "@types/dompurify": "^3.2.0",
     "@types/fs-extra": "^11.0.1",
     "@types/inquirer": "^9.0.3",
     "@types/jest": "^29.5.4",

+ 27 - 0
pnpm-lock.yaml

@@ -95,6 +95,9 @@ importers:
       dom-align:
         specifier: ^1.12.4
         version: 1.12.4
+      dompurify:
+        specifier: ^3.4.14
+        version: 3.4.14
       echarts:
         specifier: ^6.0.0
         version: 6.1.0
@@ -252,6 +255,9 @@ importers:
       '@types/crypto-js':
         specifier: ^4.1.1
         version: 4.2.2
+      '@types/dompurify':
+        specifier: ^3.2.0
+        version: 3.2.0
       '@types/fs-extra':
         specifier: ^11.0.1
         version: 11.0.4
@@ -1950,6 +1956,10 @@ packages:
   '@types/crypto-js@4.2.2':
     resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
 
+  '@types/dompurify@3.2.0':
+    resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
+    deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
+
   '@types/estree@1.0.9':
     resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
 
@@ -2083,6 +2093,9 @@ packages:
   '@types/tinycolor2@1.4.6':
     resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==}
 
+  '@types/trusted-types@2.0.7':
+    resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
   '@types/web-bluetooth@0.0.20':
     resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
 
@@ -3828,6 +3841,9 @@ packages:
   dompurify@2.5.9:
     resolution: {integrity: sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==}
 
+  dompurify@3.4.14:
+    resolution: {integrity: sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==}
+
   domutils@1.7.0:
     resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==}
 
@@ -10526,6 +10542,10 @@ snapshots:
 
   '@types/crypto-js@4.2.2': {}
 
+  '@types/dompurify@3.2.0':
+    dependencies:
+      dompurify: 3.4.14
+
   '@types/estree@1.0.9': {}
 
   '@types/fs-extra@11.0.4':
@@ -10674,6 +10694,9 @@ snapshots:
 
   '@types/tinycolor2@1.4.6': {}
 
+  '@types/trusted-types@2.0.7':
+    optional: true
+
   '@types/web-bluetooth@0.0.20': {}
 
   '@types/webxr@0.5.24': {}
@@ -12672,6 +12695,10 @@ snapshots:
   dompurify@2.5.9:
     optional: true
 
+  dompurify@3.4.14:
+    optionalDependencies:
+      '@types/trusted-types': 2.0.7
+
   domutils@1.7.0:
     dependencies:
       dom-serializer: 0.2.2

+ 11 - 10
src/views/ventAI/manageAssistent/api.ts

@@ -1,5 +1,6 @@
 import { defHttp } from '/@/utils/http/axios';
 import { getToken } from '/@/utils/auth';
+import type { SseEvent } from './components/chatModal/types';
 
 enum Api {
   unified = '/ventAI/api/chat',
@@ -113,7 +114,7 @@ export const uploadSkill = (file: File) => {
  */
 export const unifiedStream = async (
   params: { message: string; session_id?: string; file?: File; mode?: string; signal?: AbortSignal },
-  onChunk: (chunk: string) => void
+  onChunk: (data: SseEvent) => void
 ): Promise<{ session_id: string }> => {
   try {
     const formData = new FormData();
@@ -161,9 +162,9 @@ export const unifiedStream = async (
           const dataStr = line.replace(/^data: /, '');
 
           try {
-            const data = JSON.parse(dataStr);
+            const data = JSON.parse(dataStr) as SseEvent;
 
-            onChunk(line);
+            onChunk(data);
 
             if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
               if (data.session_id) {
@@ -200,7 +201,7 @@ export const unifiedStream = async (
  */
 export const reviewPdfStream = async (
   params: { file: File; session_id?: string; message?: string; mode?: string; signal?: AbortSignal },
-  onChunk: (chunk: string) => void
+  onChunk: (data: SseEvent) => void
 ): Promise<{ session_id: string }> => {
   try {
     const formData = new FormData();
@@ -248,9 +249,9 @@ export const reviewPdfStream = async (
           const dataStr = line.replace(/^data: /, '');
 
           try {
-            const data = JSON.parse(dataStr);
+            const data = JSON.parse(dataStr) as SseEvent;
 
-            onChunk(line);
+            onChunk(data);
 
             if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
               if (data.session_id) {
@@ -288,7 +289,7 @@ export const reviewPdfStream = async (
  */
 export const chatResumeStream = async (
   params: { session_id: string; thread_id?: string; status?: string; answers?: string[]; signal?: AbortSignal },
-  onChunk: (chunk: string) => void
+  onChunk: (data: SseEvent) => void
 ): Promise<{ session_id: string }> => {
   try {
     const body: Record<string, any> = {
@@ -338,11 +339,11 @@ export const chatResumeStream = async (
           const dataStr = line.replace(/^data: /, '');
 
           try {
-            const data = JSON.parse(dataStr);
+            const data = JSON.parse(dataStr) as SseEvent;
 
-            onChunk(line);
+            onChunk(data);
 
-            if (data.type === 'done' || data.type === 'error') {
+            if (data.type === 'done' || data.type === 'error' || data.type === 'interrupt') {
               if (data.session_id) {
                 sessionId = data.session_id;
               }

+ 190 - 141
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -157,7 +157,7 @@
 </template>
 
 <script setup lang="ts">
-  import { ref, nextTick, watch, computed, onMounted, onBeforeUnmount } from 'vue';
+  import { ref, nextTick, watch, computed, onBeforeUnmount } from 'vue';
   import dayjs from 'dayjs';
   import {
     unifiedStream,
@@ -186,7 +186,7 @@
   import SchedulePanel from './chatModal/SchedulePanel.vue';
   import ScheduleDetailPanel from './chatModal/ScheduleDetailPanel.vue';
   import { getScheduleList } from '../api';
-  import type { AttachedFile, Message, Task } from './chatModal/types';
+  import type { AttachedFile, Message, Task, SseEvent } from './chatModal/types';
   import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
 
   interface Props {
@@ -221,6 +221,10 @@
           sortOrder: item.sort_order,
         }));
 
+        // Build lookup maps for O(1) access
+        const existingTaskMap = new Map(taskList.value.map((t) => [t.id, t]));
+        const apiTaskIds = new Set(apiTasks.map((t) => t.id));
+
         // 流式中的空白任务:后端已为其创建会话,但前端要等流结束才拿到 session_id。
         // 该会话在接口里是一条全新记录,归并回空白任务而不是单独插入,避免列表出现重复条目
         let blankStreamingTask: Task | null = taskList.value.find((t) => !t.sessionId && t.isStreaming) || null;
@@ -236,7 +240,7 @@
             continue;
           }
 
-          const existing = taskList.value.find((t) => t.id === apiTask.id);
+          const existing = existingTaskMap.get(apiTask.id);
           if (existing) {
             // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
             existing.name = apiTask.name;
@@ -250,7 +254,7 @@
 
         apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
 
-        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTasks.find((a) => a.id === t.id));
+        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
 
         taskList.value = [...localOnlyTasks, ...mergedTasks];
         return mergedTasks;
@@ -288,6 +292,7 @@
     }
   };
   const pendingFile = ref<AttachedFile | null>(null);
+  const activeFileReader = ref<FileReader | null>(null);
 
   const messages = ref<Message[]>([]);
 
@@ -324,32 +329,19 @@
     panelCollapsed.value = !panelCollapsed.value;
   };
 
-  const toggleSkillPanel = () => {
-    if (activePanel.value === 'skill') {
-      activePanel.value = 'chat';
-    } else {
-      activePanel.value = 'skill';
-      previewFile.value = null;
-    }
-  };
-
-  const toggleSchedulePanel = () => {
-    if (activePanel.value === 'schedules') {
+  const toggleSidePanel = (panel: 'skill' | 'schedules' | 'sub-agents') => {
+    if (activePanel.value === panel) {
       activePanel.value = 'chat';
     } else {
-      activePanel.value = 'schedules';
+      activePanel.value = panel;
       previewFile.value = null;
     }
   };
 
-  const toggleSubAgentPanel = () => {
-    if (activePanel.value === 'sub-agents') {
-      activePanel.value = 'chat';
-    } else {
-      activePanel.value = 'sub-agents';
-      previewFile.value = null;
-    }
-  };
+  // Keep backward-compatible aliases
+  const toggleSkillPanel = () => toggleSidePanel('skill');
+  const toggleSchedulePanel = () => toggleSidePanel('schedules');
+  const toggleSubAgentPanel = () => toggleSidePanel('sub-agents');
 
   const handleOpenScheduleSession = async (sessionId: string, prompt?: string) => {
     activePanel.value = 'chat';
@@ -369,7 +361,7 @@
         attachedFiles: [],
       };
       taskList.value.unshift(newTask);
-      historyLoadedTasks.value.delete(sessionId);
+      historyLoadedTasks.delete(sessionId);
       await switchTask(newTask.id);
     }
 
@@ -470,34 +462,30 @@
   const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
 
   // 按任务维度的状态存储
-  const taskAbortControllers = ref<Map<string, AbortController>>(new Map());
   const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
 
-  // loading 和 streaming 改为计算属性,基于当前任务的 isStreaming 状态
+  // loading 和 streaming 共享同一个计算属性,基于当前任务的 isStreaming 状态
   const loading = computed(() => {
     const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
     return currentTask?.isStreaming ?? false;
   });
-
-  const streaming = computed(() => {
-    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
-    return currentTask?.isStreaming ?? false;
-  });
+  const streaming = loading;
 
   // 获取当前任务的待审批状态
   const currentPendingApproval = computed(() => {
     return taskPendingApprovals.value.get(currentTaskId.value);
   });
 
+  // Non-reactive Map for AbortControllers - only used imperatively, no need for Vue reactivity
+  const taskAbortControllers = new Map<string, AbortController>();
+
   // 设置任务的AbortController
   const setTaskAbortController = (taskId: string, controller: AbortController | null) => {
-    const map = new Map(taskAbortControllers.value);
     if (controller) {
-      map.set(taskId, controller);
+      taskAbortControllers.set(taskId, controller);
     } else {
-      map.delete(taskId);
+      taskAbortControllers.delete(taskId);
     }
-    taskAbortControllers.value = map;
   };
 
   // 设置任务的审批状态
@@ -516,7 +504,7 @@
 
   // 停止指定任务的流式传输
   const stopTaskStreaming = (taskId: string) => {
-    const controller = taskAbortControllers.value.get(taskId);
+    const controller = taskAbortControllers.get(taskId);
     controller?.abort();
   };
 
@@ -529,7 +517,8 @@
     return currentTask?.sessionId || '';
   };
 
-  const historyLoadedTasks = ref<Set<string>>(new Set());
+  // Non-reactive lookup cache - Vue 3 does not track Set mutations on ref<Set>
+  const historyLoadedTasks = new Set<string>();
 
   const setCurrentSessionId = (sessionId: string) => {
     const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
@@ -538,7 +527,7 @@
       currentTask.id = sessionId;
       currentTask.sessionId = sessionId;
       currentTaskId.value = sessionId;
-      historyLoadedTasks.value.add(sessionId);
+      historyLoadedTasks.add(sessionId);
     }
   };
 
@@ -558,7 +547,7 @@
     previewFile.value = null;
     showFileList.value = false;
 
-    if (newTask.sessionId && !historyLoadedTasks.value.has(taskId)) {
+    if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
       if (newTask.isStreaming) return; // 新任务正在流式中,无需加载历史
       try {
         const res = await getDetail(newTask.sessionId);
@@ -566,7 +555,7 @@
         if (Array.isArray(messages)) {
           newTask.messages = transformHistoryToMessages(messages);
         }
-        historyLoadedTasks.value.add(taskId);
+        historyLoadedTasks.add(taskId);
       } catch (error) {
         console.error('加载历史会话失败:', error);
       }
@@ -669,43 +658,63 @@
     }
   };
 
-  const findPendingApprovalTask = async (): Promise<{ taskId: string; approval: { sessionId: string; threadId?: string } } | null> => {
+  const findPendingApprovalTask = (): { taskId: string; approval: { sessionId: string; threadId?: string } } | null => {
+    // 优先用当前任务 ID 查找
     let approval = currentPendingApproval.value;
     if (approval) return { taskId: currentTaskId.value, approval };
+    // 遍历所有审批(可能任务 ID 已变,用 sessionId 做回退匹配)
     for (const [taskId, a] of taskPendingApprovals.value.entries()) {
-      if (currentTaskId.value !== taskId) {
-        await switchTask(taskId);
-      }
       return { taskId, approval: a };
     }
     return null;
   };
 
   const handleAskUserSubmit = async (answers: string[]) => {
-    const found = await findPendingApprovalTask();
-    if (!found) return;
+    // 防重复提交:如果当前任务正在流式中,忽略
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask?.isStreaming) return;
 
-    const { approval } = found;
-    const sessionId = approval.sessionId;
-    if (!sessionId) return;
+    const found = findPendingApprovalTask();
+
+    // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
+    let sessionId = found?.approval.sessionId;
+    let threadId = found?.approval.threadId;
+    const foundTaskId = found?.taskId;
 
-    const task = taskList.value.find((t) => t.sessionId === sessionId);
+    // 查找任务:优先用 foundTaskId,回退到 currentTaskId
+    let task = foundTaskId ? taskList.value.find((t) => t.id === foundTaskId) : null;
+    if (!task) task = taskList.value.find((t) => t.id === currentTaskId.value);
     if (!task) return;
 
-    const taskMessages = task.messages;
-    const lastMsg = taskMessages[taskMessages.length - 1];
+    // 切换到目标任务(如果不在当前视图)
+    if (task.id !== currentTaskId.value) {
+      await switchTask(task.id);
+    }
+
+    // 使用正确的消息数组:当前视图用 messages.value,否则用 task.messages
+    const isViewingTask = currentTaskId.value === task.id;
+    const sourceArr = isViewingTask ? messages.value : task.messages;
+    const lastMsg = sourceArr[sourceArr.length - 1];
     if (!lastMsg || !lastMsg.isPendingApproval) return;
 
+    // 若 taskPendingApprovals 已被清除,从消息的 pendingApprovalData 回退取值
+    if (!sessionId && lastMsg.pendingApprovalData) {
+      sessionId = lastMsg.pendingApprovalData.sessionId;
+      threadId = lastMsg.pendingApprovalData.threadId;
+    }
+    if (!sessionId) return;
+
     lastMsg.isPendingApproval = false;
     lastMsg.pendingApprovalData = undefined;
     lastMsg.isLoading = true;
     lastMsg.generateStartTime = Date.now();
 
-    const aiMsgIndex = taskMessages.length - 1;
-    task.messages = [...taskMessages];
-    if (currentTaskId.value === task.id) {
-      messages.value = task.messages;
+    const aiMsgIndex = sourceArr.length - 1;
+    // 强制触发 Vue 响应式更新
+    if (isViewingTask) {
+      messages.value = [...sourceArr];
     }
+    task.messages = [...sourceArr];
 
     task.isStreaming = true;
     showTodoBar.value = false;
@@ -718,14 +727,14 @@
       const result = await chatResumeStream(
         {
           session_id: sessionId,
-          thread_id: approval.threadId || undefined,
+          thread_id: threadId || undefined,
           status: 'answered',
           answers,
           signal: controller.signal,
         },
-        (chunk: string) => {
-          parseSSEData(chunk, aiMsgIndex, task.id);
-          if (currentTaskId.value === task.id) scrollToBottom();
+        (data: SseEvent) => {
+          parseSSEData(data, aiMsgIndex, task.id);
+          if (currentTaskId.value === task.id) scrollToBottomThrottled();
         }
       );
 
@@ -751,7 +760,10 @@
     } finally {
       setTaskAbortController(task.id, null);
       task.isStreaming = false;
-      clearTaskPendingApproval(task.id);
+      // 仅当消息不再处于审批状态时才清除(避免 resume 流返回新 interrupt 被误清)
+      if (!lastMsg.isPendingApproval) {
+        clearTaskPendingApproval(task.id);
+      }
       if (currentTaskId.value === task.id) {
         await scrollToBottom();
       }
@@ -759,20 +771,40 @@
   };
 
   const handleAskUserCancel = async () => {
-    const found = await findPendingApprovalTask();
-    if (!found) return;
+    // 防重复操作:如果当前任务正在流式中,忽略
+    const currentTask = taskList.value.find((t) => t.id === currentTaskId.value);
+    if (currentTask?.isStreaming) return;
 
-    const { approval } = found;
-    const sessionId = approval.sessionId;
-    if (!sessionId) return;
+    const found = findPendingApprovalTask();
 
-    const task = taskList.value.find((t) => t.sessionId === sessionId);
+    // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
+    let sessionId = found?.approval.sessionId;
+    let threadId = found?.approval.threadId;
+    const foundTaskId = found?.taskId;
+
+    // 查找任务:优先用 foundTaskId,回退到 currentTaskId
+    let task = foundTaskId ? taskList.value.find((t) => t.id === foundTaskId) : null;
+    if (!task) task = taskList.value.find((t) => t.id === currentTaskId.value);
     if (!task) return;
 
-    const taskMessages = task.messages;
-    const lastMsg = taskMessages[taskMessages.length - 1];
+    // 切换到目标任务(如果不在当前视图)
+    if (task.id !== currentTaskId.value) {
+      await switchTask(task.id);
+    }
+
+    // 使用正确的消息数组
+    const isViewingTask = currentTaskId.value === task.id;
+    const sourceArr = isViewingTask ? messages.value : task.messages;
+    const lastMsg = sourceArr[sourceArr.length - 1];
     if (!lastMsg || !lastMsg.isPendingApproval) return;
 
+    // 若 taskPendingApprovals 已被清除,从消息的 pendingApprovalData 回退取值
+    if (!sessionId && lastMsg.pendingApprovalData) {
+      sessionId = lastMsg.pendingApprovalData.sessionId;
+      threadId = lastMsg.pendingApprovalData.threadId;
+    }
+    if (!sessionId) return;
+
     // 冻结累计耗时到 durationMs,避免取消后计时消失
     lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
     lastMsg.isPendingApproval = false;
@@ -780,14 +812,17 @@
     lastMsg.isLoading = false;
     lastMsg.content = (lastMsg.content ? lastMsg.content + '\n\n' : '') + '已取消提问。';
 
-    task.messages = [...taskMessages];
+    task.messages = [...sourceArr];
+    if (isViewingTask) {
+      messages.value = task.messages;
+    }
     clearTaskPendingApproval(task.id);
 
     try {
       await chatResumeStream(
         {
           session_id: sessionId,
-          thread_id: approval.threadId || undefined,
+          thread_id: threadId || undefined,
           status: 'cancelled',
         },
         () => {}
@@ -924,21 +959,31 @@
         originalFile: file,
       };
 
+      // Abort previous FileReader if still in progress
+      if (activeFileReader.value) {
+        activeFileReader.value.abort();
+        activeFileReader.value = null;
+      }
+
       const reader = new FileReader();
-      if (isTextFile(file.name) || isCsvFile(file.name)) {
-        reader.onload = (e) => {
+      activeFileReader.value = reader;
+
+      reader.onload = (e) => {
+        activeFileReader.value = null;
+        if (isTextFile(file.name) || isCsvFile(file.name)) {
           newFile.content = e.target?.result as string;
-        };
+        } else if (isWordFile(file.name) || isExcelFile(file.name)) {
+          newFile.arrayBuffer = e.target?.result as ArrayBuffer;
+        } else {
+          newFile.preview = e.target?.result as string;
+        }
+      };
+
+      if (isTextFile(file.name) || isCsvFile(file.name)) {
         reader.readAsText(file);
       } else if (isWordFile(file.name) || isExcelFile(file.name)) {
-        reader.onload = (e) => {
-          newFile.arrayBuffer = e.target?.result as ArrayBuffer;
-        };
         reader.readAsArrayBuffer(file);
       } else {
-        reader.onload = (e) => {
-          newFile.preview = e.target?.result as string;
-        };
         reader.readAsDataURL(file);
       }
 
@@ -1012,18 +1057,6 @@
     if (nextTask) switchTask(nextTask.id);
   };
 
-  const downloadWordReport = () => {
-    const url = currentWordUrl.value;
-    if (!url) return;
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = '';
-    a.target = '_blank';
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-  };
-
   const downloadWordFile = (url: string) => {
     if (!url) return;
     const a = document.createElement('a');
@@ -1035,6 +1068,8 @@
     document.body.removeChild(a);
   };
 
+  const downloadWordReport = () => downloadWordFile(currentWordUrl.value);
+
   const handleOpenFilePreview = (file: AttachedFile) => {
     previewFile.value = file;
     previewEditable.value = true;
@@ -1400,46 +1435,27 @@
           answers: data.ask_user?.questions?.map(() => '') || [],
         };
         setTaskPendingApproval(taskId, interruptSessionId, data.thread_id || '');
+        // 直接初始化 ask_user 答案状态,不依赖 watch(避免任务切换/时序问题)
+        if (data.ask_user?.questions) {
+          nextTick(() => {
+            chatMessagesRef.value?.initAskUserAnswers(aiMsgIndex, data.ask_user!.questions);
+          });
+        }
         break;
       }
     }
 
     // 保存到流式任务的 messages,并同步 UI
+    // Always create a new array reference to trigger Vue reactivity
+    const updatedMessages = isViewingTask ? [...messages.value] : [...streamingTask.messages];
+    streamingTask.messages = updatedMessages;
     if (isViewingTask) {
-      streamingTask.messages = [...messages.value];
-    } else {
-      streamingTask.messages = [...streamingTask.messages];
+      messages.value = updatedMessages;
     }
   };
 
-  const parseSSEData = (chunk: string, aiMsgIndex: number, taskId: string) => {
-    const lines = chunk.split('\n');
-
-    for (const lineRaw of lines) {
-      const line = lineRaw.trim();
-      if (!line) continue;
-
-      if (line.startsWith('data:')) {
-        try {
-          const jsonStr = line.substring(5).trim();
-          const data = JSON.parse(jsonStr);
-          handleSSEMessage(data, aiMsgIndex, taskId);
-        } catch (error) {
-          console.error('解析 SSE data 行失败:', error, line);
-        }
-        continue;
-      }
-
-      try {
-        const data = JSON.parse(line);
-        handleSSEMessage(data, aiMsgIndex, taskId);
-        continue;
-      } catch {
-        // not json
-      }
-
-      handleSSEMessage({ type: 'model_thinking', content: line }, aiMsgIndex, taskId);
-    }
+  const parseSSEData = (data: SseEvent, aiMsgIndex: number, taskId: string) => {
+    handleSSEMessage(data, aiMsgIndex, taskId);
   };
 
   const sendTextOnly = async (userInput: string, displayContent?: string) => {
@@ -1485,9 +1501,9 @@
           mode: editMode.value,
           signal: controller.signal,
         },
-        (chunk: string) => {
-          parseSSEData(chunk, aiMsgIndex, originTaskId);
-          if (currentTaskId.value === originTaskId) scrollToBottom();
+        (data: SseEvent) => {
+          parseSSEData(data, aiMsgIndex, originTaskId);
+          if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
         }
       );
 
@@ -1502,14 +1518,22 @@
           originTask.pendingSessionId = undefined;
           if (originTask.id !== result.session_id) {
             // 将 AbortController 从旧 ID 迁移到新 ID
-            const controller = taskAbortControllers.value.get(originTaskId);
+            const controller = taskAbortControllers.get(originTaskId);
             if (controller) {
               setTaskAbortController(originTaskId, null);
               setTaskAbortController(result.session_id, controller);
             }
             taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
             originTask.id = result.session_id;
-            historyLoadedTasks.value.add(result.session_id);
+            historyLoadedTasks.add(result.session_id);
+            // 迁移审批状态 key(任务 ID 变了,审批 Map 的 key 也要跟着变)
+            const approvalEntry = taskPendingApprovals.value.get(originTaskId);
+            if (approvalEntry) {
+              const map = new Map(taskPendingApprovals.value);
+              map.delete(originTaskId);
+              map.set(result.session_id, approvalEntry);
+              taskPendingApprovals.value = map;
+            }
           }
           if (currentTaskId.value === originTaskId) {
             currentTaskId.value = result.session_id;
@@ -1599,9 +1623,9 @@
           mode: editMode.value,
           signal: controller.signal,
         },
-        (chunk: string) => {
-          parseSSEData(chunk, aiMsgIndex, originTaskId);
-          if (currentTaskId.value === originTaskId) scrollToBottom();
+        (data: SseEvent) => {
+          parseSSEData(data, aiMsgIndex, originTaskId);
+          if (currentTaskId.value === originTaskId) scrollToBottomThrottled();
         }
       );
 
@@ -1616,14 +1640,22 @@
           originTask.pendingSessionId = undefined;
           if (originTask.id !== result.session_id) {
             // 将 AbortController 从旧 ID 迁移到新 ID
-            const controller = taskAbortControllers.value.get(originTaskId);
+            const controller = taskAbortControllers.get(originTaskId);
             if (controller) {
               setTaskAbortController(originTaskId, null);
               setTaskAbortController(result.session_id, controller);
             }
             taskList.value = taskList.value.filter((t) => t.id !== result.session_id);
             originTask.id = result.session_id;
-            historyLoadedTasks.value.add(result.session_id);
+            historyLoadedTasks.add(result.session_id);
+            // 迁移审批状态 key
+            const approvalEntry = taskPendingApprovals.value.get(originTaskId);
+            if (approvalEntry) {
+              const map = new Map(taskPendingApprovals.value);
+              map.delete(originTaskId);
+              map.set(result.session_id, approvalEntry);
+              taskPendingApprovals.value = map;
+            }
           }
           if (currentTaskId.value === originTaskId) {
             currentTaskId.value = result.session_id;
@@ -1658,6 +1690,22 @@
     }
   };
 
+  // Throttled version for SSE streaming (no await, uses rAF)
+  let scrollRafId: number | null = null;
+  const scrollToBottomThrottled = (force = false) => {
+    if (scrollRafId) cancelAnimationFrame(scrollRafId);
+    scrollRafId = requestAnimationFrame(async () => {
+      scrollRafId = null;
+      await nextTick();
+      if (!force && !chatMessagesRef.value?.isAtBottom) return;
+      const el = chatMessagesRef.value?.messagesRef;
+      if (el) {
+        el.scrollTop = el.scrollHeight - el.clientHeight;
+      }
+    });
+  };
+
+  // Async version for explicit await calls
   const scrollToBottom = async (force = false) => {
     await nextTick();
     if (!force && !chatMessagesRef.value?.isAtBottom) return;
@@ -1710,14 +1758,14 @@
         currentTaskId.value = targetTask.id;
 
         // 如果是历史任务,加载其消息
-        if (targetTask.sessionId && !historyLoadedTasks.value.has(targetTask.id)) {
+        if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
           try {
             const res = await getDetail(targetTask.sessionId);
             const messages = res?.messages || (Array.isArray(res) ? res : res?.data);
             if (Array.isArray(messages)) {
               targetTask.messages = transformHistoryToMessages(messages);
             }
-            historyLoadedTasks.value.add(targetTask.id);
+            historyLoadedTasks.add(targetTask.id);
           } catch (error) {
             console.error('加载历史会话失败:', error);
           }
@@ -1822,15 +1870,16 @@
     }
   );
 
-  onMounted(async () => {
-    // fetchSessionList moved to watch(visible) to avoid unnecessary API calls
-  });
-
   onBeforeUnmount(() => {
     document.removeEventListener('mousemove', onDragMove);
     document.removeEventListener('mouseup', onDragEnd);
     // 组件卸载时主动断开所有任务的流式连接
-    taskAbortControllers.value.forEach((controller) => controller.abort());
+    taskAbortControllers.forEach((controller) => controller.abort());
+    // Abort in-flight FileReader if any
+    if (activeFileReader.value) {
+      activeFileReader.value.abort();
+      activeFileReader.value = null;
+    }
   });
 </script>
 

+ 26 - 5
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -523,6 +523,10 @@
     has_skill_md: boolean;
   }
 
+  // Module-level cache for skill list to avoid redundant API calls across component mounts
+  let skillListCache: SkillItem[] | null = null;
+  let skillListFetchPromise: Promise<void> | null = null;
+
   const skillPopupRef = ref<HTMLElement>();
   const showSkillPopup = ref(false);
   const activeSkillIndex = ref(0);
@@ -532,12 +536,29 @@
   let skillJustSelected = false;
 
   const fetchSkillList = async () => {
-    try {
-      const res = await getSkills();
-      skillList.value = res?.skills || [];
-    } catch {
-      skillList.value = [];
+    // Use cached data if available
+    if (skillListCache) {
+      skillList.value = skillListCache;
+      return;
     }
+    // Deduplicate concurrent requests
+    if (skillListFetchPromise) {
+      await skillListFetchPromise;
+      skillList.value = skillListCache || [];
+      return;
+    }
+    skillListFetchPromise = (async () => {
+      try {
+        const res = await getSkills();
+        skillList.value = res?.skills || [];
+        skillListCache = skillList.value;
+      } catch {
+        skillList.value = [];
+      } finally {
+        skillListFetchPromise = null;
+      }
+    })();
+    await skillListFetchPromise;
   };
 
   onMounted(() => {

+ 34 - 5
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -367,11 +367,21 @@
   // import katex from 'katex';
   // import 'katex/dist/katex.min.css';
 
+  // Cache CSS variable lookups to avoid repeated getComputedStyle calls on every render
+  const cssUrlCache = new Map<string, string>();
   const getCssUrl = (varName: string) => {
-    const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
-    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+    if (!cssUrlCache.has(varName)) {
+      const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
+      cssUrlCache.set(varName, val.replace(/^url\(["']?/, '').replace(/["']?\)$/, ''));
+    }
+    return cssUrlCache.get(varName)!;
   };
 
+  // Clear cache when theme changes (observed via MutationObserver on html class)
+  const themeObserver = new MutationObserver(() => cssUrlCache.clear());
+  themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+  onBeforeUnmount(() => themeObserver.disconnect());
+
   const tableIcons = {
     copy: getCssUrl('--img-chat-copy-icon'),
     download: getCssUrl('--img-chat-download-icon-btn'),
@@ -419,13 +429,23 @@
     messages: Message[];
   }>();
 
+  // Track the last pendingApprovalData reference to detect new interrupts
+  const lastApprovalDataRef = new Map<number, any>();
+
   // 自动初始化 ask_user 答案状态
   watch(
     () => props.messages,
     (msgs) => {
       msgs.forEach((msg, idx) => {
         if (msg.isPendingApproval && msg.pendingApprovalData?.ask_user?.questions) {
-          initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
+          // Only reinitialize if pendingApprovalData object reference changed (new interrupt)
+          const lastRef = lastApprovalDataRef.get(idx);
+          if (lastRef !== msg.pendingApprovalData) {
+            lastApprovalDataRef.set(idx, msg.pendingApprovalData);
+            initAskUserAnswers(idx, msg.pendingApprovalData.ask_user.questions);
+          }
+        } else {
+          lastApprovalDataRef.delete(idx);
         }
       });
     },
@@ -495,7 +515,7 @@
   };
 
   const initAskUserAnswers = (index: number, questions: { question: string; type: string; choices?: any[] }[]) => {
-    if (askUserAnswers[index]) return;
+    // Always reinitialize when called - handles repeated interrupts from backend
     askUserAnswers[index] = questions.map(() => '');
     askUserOtherSelected[index] = questions.map(() => false);
     askUserCurrentStep[index] = 0;
@@ -537,6 +557,15 @@
   };
 
   const handleSubmitAskUser = (index: number) => {
+    // 防御性初始化:如果答案数组未初始化,从 pendingApprovalData 回退
+    if (!askUserAnswers[index]) {
+      const msg = props.messages[index];
+      if (msg?.pendingApprovalData?.ask_user?.questions) {
+        initAskUserAnswers(index, msg.pendingApprovalData.ask_user.questions);
+      } else {
+        return;
+      }
+    }
     const answers = askUserAnswers[index];
     if (!answers) return;
     emit('ask-user-submit', [...answers]);
@@ -760,7 +789,7 @@
     }
   };
 
-  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom });
+  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, initAskUserAnswers });
 </script>
 
 <style scoped lang="less">

+ 20 - 0
src/views/ventAI/manageAssistent/components/chatModal/types.ts

@@ -116,6 +116,26 @@ export interface Task {
 
 export type SectionType = 'select' | 'params' | 'result';
 
+export interface SseEvent {
+  type: string;
+  session_id?: string;
+  content?: string;
+  agent?: string;
+  tool?: string;
+  message?: string;
+  thread_id?: string;
+  elapsed_ms?: number;
+  plan?: string;
+  ask_user?: {
+    questions: AskUserQuestion[];
+    tool_call_id?: string;
+  };
+  interrupts?: any[];
+  citations?: CitationsData;
+  todos?: Array<{ content: string; status: string }>;
+  [key: string]: any;
+}
+
 export interface ThinkingSection {
   label: string;
   content: string;

+ 6 - 0
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -1,5 +1,6 @@
 import { marked } from 'marked';
 import katex from 'katex';
+import DOMPurify from 'dompurify';
 
 const renderLatexInHtml = (html: string): string => {
   // 匹配块级公式($$...$$)
@@ -47,6 +48,11 @@ export const renderMarkdown = (
   let html = marked.parse(processed) as string;
   html = processFootnotes(html, maxCitationId);
   html = renderLatexInHtml(html);
+  // Sanitize HTML to prevent XSS attacks
+  html = DOMPurify.sanitize(html, {
+    ADD_TAGS: ['img'],
+    ADD_ATTR: ['data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n'],
+  });
   return icons ? wrapTables(html, icons) : html;
 };