Browse Source

[Mod 0000] 对AI助手代码进行文件层面的调整,封装组件、抽取方法、规范代码

wangkeyi 4 ngày trước cách đây
mục cha
commit
46df977e0a
68 tập tin đã thay đổi với 8278 bổ sung và 6755 xóa
  1. 11 4
      src/layouts/default/index.vue
  2. 6 5
      src/views/vent/home/homeAI/index.vue
  3. 1 1
      src/views/ventAI/dataPicker/api.ts
  4. 4 4
      src/views/ventAI/manageAssistent/api/client.ts
  5. 0 0
      src/views/ventAI/manageAssistent/api/sse.ts
  6. 368 1689
      src/views/ventAI/manageAssistent/components/AiAssistantModal.vue
  7. 394 0
      src/views/ventAI/manageAssistent/components/chat/AskUserCard.vue
  8. 2 2
      src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue
  9. 587 0
      src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue
  10. 1316 0
      src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue
  11. 211 0
      src/views/ventAI/manageAssistent/components/chat/CitationsCard.vue
  12. 438 0
      src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue
  13. 212 0
      src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue
  14. 242 0
      src/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue
  15. 209 0
      src/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue
  16. 192 0
      src/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue
  17. 174 0
      src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue
  18. 190 0
      src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue
  19. 266 0
      src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue
  20. 127 0
      src/views/ventAI/manageAssistent/components/chat/MessageActions.vue
  21. 325 0
      src/views/ventAI/manageAssistent/components/chat/ThinkingCard.vue
  22. 0 0
      src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue
  23. 0 2139
      src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue
  24. 0 2568
      src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue
  25. 0 206
      src/views/ventAI/manageAssistent/components/chatModal/types.ts
  26. 2 2
      src/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue
  27. 1 1
      src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue
  28. 2 2
      src/views/ventAI/manageAssistent/components/modals/SkillModal.vue
  29. 2 2
      src/views/ventAI/manageAssistent/components/panels/AgentDetailPanel.vue
  30. 2 2
      src/views/ventAI/manageAssistent/components/panels/FileListPanel.vue
  31. 3 3
      src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue
  32. 3 3
      src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue
  33. 2 2
      src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue
  34. 2 2
      src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue
  35. 2 2
      src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue
  36. 1 1
      src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue
  37. 3 3
      src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue
  38. 2 2
      src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue
  39. 246 0
      src/views/ventAI/manageAssistent/hooks/useChatApproval.ts
  40. 84 0
      src/views/ventAI/manageAssistent/hooks/useChatDrag.ts
  41. 69 0
      src/views/ventAI/manageAssistent/hooks/useChatExport.ts
  42. 81 0
      src/views/ventAI/manageAssistent/hooks/useChatFiles.ts
  43. 266 0
      src/views/ventAI/manageAssistent/hooks/useChatPanels.ts
  44. 103 0
      src/views/ventAI/manageAssistent/hooks/useChatQuota.ts
  45. 34 0
      src/views/ventAI/manageAssistent/hooks/useChatScroll.ts
  46. 402 0
      src/views/ventAI/manageAssistent/hooks/useChatSend.ts
  47. 334 0
      src/views/ventAI/manageAssistent/hooks/useChatStream.ts
  48. 207 0
      src/views/ventAI/manageAssistent/hooks/useChatTasks.ts
  49. 376 0
      src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts
  50. 12 0
      src/views/ventAI/manageAssistent/lib/assistantHost.ts
  51. 3 2
      src/views/ventAI/manageAssistent/lib/capsule.ts
  52. 1 1
      src/views/ventAI/manageAssistent/lib/exportDoc.ts
  53. 42 0
      src/views/ventAI/manageAssistent/lib/fileIcon.ts
  54. 41 0
      src/views/ventAI/manageAssistent/lib/filePreview.ts
  55. 17 0
      src/views/ventAI/manageAssistent/lib/format.ts
  56. 8 0
      src/views/ventAI/manageAssistent/lib/index.ts
  57. 36 107
      src/views/ventAI/manageAssistent/lib/markdown.ts
  58. 30 0
      src/views/ventAI/manageAssistent/lib/messageKey.ts
  59. 29 0
      src/views/ventAI/manageAssistent/lib/modalWrap.ts
  60. 22 0
      src/views/ventAI/manageAssistent/lib/stepRenderer.ts
  61. 17 0
      src/views/ventAI/manageAssistent/lib/theme.ts
  62. 267 0
      src/views/ventAI/manageAssistent/styles/modal.less
  63. 31 0
      src/views/ventAI/manageAssistent/styles/tokens.less
  64. 110 0
      src/views/ventAI/manageAssistent/types/chat.ts
  65. 37 0
      src/views/ventAI/manageAssistent/types/domain.ts
  66. 5 0
      src/views/ventAI/manageAssistent/types/index.ts
  67. 20 0
      src/views/ventAI/manageAssistent/types/panel.ts
  68. 46 0
      src/views/ventAI/manageAssistent/types/resource.ts

+ 11 - 4
src/layouts/default/index.vue

@@ -13,9 +13,9 @@
       </Layout>
     </Layout>
 
-    <!-- AI智能助手悬浮按钮(支持拖动,固定右侧) -->
+    <!-- AI智能助手悬浮按钮(支持拖动,固定右侧);页面自备助手界面时不挂载 -->
     <div
-      v-if="hasPermission('show:ventAI')"
+      v-if="hasPermission('show:ventAI') && !pageProvidesAssistant"
       class="ai-assistant-float"
       :style="{ right: aiBtnPosition.right, bottom: aiBtnPosition.bottom }"
       @mousedown="handleDragStart"
@@ -23,8 +23,13 @@
       <div class="ai-float-icon" @click="openAiAssistant"></div>
     </div>
 
-    <!-- AI助手弹窗 -->
-    <AiAssistantModal :visible="aiAssistantVisible" :get-container="getAiModalContainer" @close="aiAssistantVisible = false" />
+    <!-- AI助手弹窗;页面自备助手界面时不再重复挂载,避免同一助手出现两份实例 -->
+    <AiAssistantModal
+      v-if="!pageProvidesAssistant"
+      :visible="aiAssistantVisible"
+      :get-container="getAiModalContainer"
+      @close="aiAssistantVisible = false"
+    />
   </Layout>
 </template>
 
@@ -38,6 +43,7 @@
   import LayoutSideBar from './sider/index.vue';
   import LayoutMultipleHeader from './header/MultipleHeader.vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
+  import { pageProvidesAssistant } from '/@/views/ventAI/manageAssistent/lib/assistantHost';
 
   import { useMenuSetting } from '/@/hooks/setting/useMenuSetting';
   import { useDesign } from '/@/hooks/web/useDesign';
@@ -185,6 +191,7 @@
         openAiAssistant,
         getAiModalContainer,
         handleDragStart,
+        pageProvidesAssistant,
       };
     },
   });

+ 6 - 5
src/views/vent/home/homeAI/index.vue

@@ -8,7 +8,9 @@
 <script setup lang="ts">
   import { onMounted, onUnmounted, ref } from 'vue';
   import AiAssistantModal from '/@/views/ventAI/manageAssistent/components/AiAssistantModal.vue';
-  const visible = ref(false);
+  import { pageProvidesAssistant } from '/@/views/ventAI/manageAssistent/lib/assistantHost';
+  // 本页助手常驻展开:初始化已改为在挂载时立即执行一次,不再依赖 visible 的 false→true 跳变
+  const visible = ref(true);
 
   // AI 弹框渲染进 #adaptive-container,与页面同坐标系、同缩放:
   // 布局层的 BottomSider 与本页右上用户下拉(z-index 均高于弹框)才能正常盖在弹框之上
@@ -17,12 +19,13 @@
   onMounted(() => {
     // 标记 AI 首页:隐藏布局层右侧操作栏(天气/语音/模式切换等),仅保留本页内嵌的右上用户下拉
     document.body.classList.add('ai-home-page');
-    // 初始 false 再置 true,触发 AiAssistantModal 的 visible watcher 完成会话列表等初始化加载
-    visible.value = true;
+    // 告诉布局层本页已自备助手界面:不再挂载悬浮球与助手弹框,避免同一助手出现两份实例
+    pageProvidesAssistant.value = true;
   });
 
   onUnmounted(() => {
     document.body.classList.remove('ai-home-page');
+    pageProvidesAssistant.value = false;
   });
 </script>
 
@@ -31,8 +34,6 @@
     width: 100%;
     height: 100%;
   }
-
-
 </style>
 
 <!-- <style lang="less">

+ 1 - 1
src/views/ventAI/dataPicker/api.ts

@@ -1,4 +1,4 @@
-import { consumeSse } from '../sse';
+import { consumeSse } from '/@/views/ventAI/manageAssistent/api/sse';
 import type { SSERawEvent } from './types';
 
 enum Api {

+ 4 - 4
src/views/ventAI/manageAssistent/api.ts → src/views/ventAI/manageAssistent/api/client.ts

@@ -1,7 +1,7 @@
-import { defHttp } from '/@/utils/http/axios';
-import { getToken } from '/@/utils/auth';
-import { consumeSse } from '../sse';
-import type { SseEvent } from './components/chatModal/types';
+import { defHttp } from '/@/utils/http/axios/index';
+import { getToken } from '/@/utils/auth/index';
+import { consumeSse } from './sse';
+import type { SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
 
 enum Api {
   usageToday = '/ventAI/api/usage/today',

+ 0 - 0
src/views/ventAI/sse.ts → src/views/ventAI/manageAssistent/api/sse.ts


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 368 - 1689
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue


+ 394 - 0
src/views/ventAI/manageAssistent/components/chat/AskUserCard.vue

@@ -0,0 +1,394 @@
+<template>
+  <!-- ask_user 分步向导:按题逐条问答,可返回上一步;required !== false 的题必须作答才能继续 -->
+  <div class="approval-card">
+    <div class="approval-icon"></div>
+    <div class="approval-content">
+      <div class="approval-title">{{ data.message }}</div>
+
+      <!-- 计划内容 -->
+      <div v-if="data.plan" class="approval-plan">
+        <div class="approval-plan-label">执行计划</div>
+        <div class="approval-plan-content">{{ data.plan }}</div>
+      </div>
+
+      <!-- 步骤指示器 -->
+      <div v-if="questions.length > 1" class="ask-user-stepper">
+        <div
+          v-for="(_, sIdx) in questions"
+          :key="sIdx"
+          :class="['ask-user-step-dot', { active: sIdx === step, completed: sIdx < step || !!(answers[sIdx] && answers[sIdx].trim()) }]"
+          @click="step = sIdx"
+        ></div>
+        <span class="ask-user-step-text">{{ step + 1 }} / {{ questions.length }}</span>
+      </div>
+
+      <!-- 当前问题 -->
+      <div v-if="current" class="ask-user-question">
+        <div class="ask-user-question-text">
+          {{ current.question }}
+          <span v-if="current.required !== false" class="ask-user-required">*</span>
+        </div>
+
+        <!-- multiple_choice 类型 -->
+        <div v-if="current.type === 'multiple_choice'" class="ask-user-choices">
+          <div
+            v-for="(choice, cIdx) in current.choices"
+            :key="cIdx"
+            :class="['ask-user-choice', { selected: answers[step] === getChoiceValue(choice) && !otherSelected[step] }]"
+            @click="selectChoice(step, choice, questions.length)"
+          >
+            <span>{{ getChoiceValue(choice) }}</span>
+          </div>
+          <!-- Other 选项 -->
+          <div :class="['ask-user-choice', { selected: !!otherSelected[step] }]" @click="selectOther(step)">
+            <span>其他</span>
+          </div>
+          <input
+            v-if="otherSelected[step]"
+            class="ask-user-other-input"
+            placeholder="请输入..."
+            :value="answers[step]"
+            @input="answers[step] = ($event.target as HTMLInputElement).value"
+          />
+        </div>
+
+        <!-- text 类型 -->
+        <div v-else-if="current.type === 'text'" class="ask-user-text">
+          <textarea
+            class="ask-user-textarea"
+            placeholder="请输入..."
+            :value="answers[step]"
+            @input="answers[step] = ($event.target as HTMLTextAreaElement).value"
+          ></textarea>
+        </div>
+      </div>
+
+      <!-- 操作按钮 -->
+      <div class="approval-actions">
+        <button v-if="step > 0" class="approval-btn nav-btn" @click="step--">上一步</button>
+        <button
+          v-if="step < questions.length - 1"
+          class="approval-btn approve-btn"
+          :disabled="!isAnswered(step, current)"
+          @click="goNext(questions.length)"
+        >
+          下一步
+        </button>
+        <button v-if="step === questions.length - 1" class="approval-btn approve-btn" :disabled="!isAnswered(step, current)" @click="submit"
+          >提交</button
+        >
+        <button class="approval-btn reject-btn" @click="emit('cancel')">取消</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref, watch } from 'vue';
+  import type { AskUserQuestion, PendingApprovalData } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * ask_user 审批卡(分步向导)。
+   *
+   * 向导状态(答案 / 是否选了“其他” / 当前步骤)由卡片自己持有:它是纯视图状态,
+   * 只属于这一条消息。父组件仍暴露按消息下标初始化答案的入口(SSE 收到 interrupt 时会调用),
+   * 通过组件实例路由到对应卡片,保持原有对外契约。
+   */
+  const props = defineProps<{
+    data: PendingApprovalData;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'submit', answers: string[]): void;
+    (e: 'cancel'): void;
+  }>();
+
+  const answers = ref<string[]>([]);
+  const otherSelected = ref<boolean[]>([]);
+  const step = ref(0);
+
+  const questions = computed<AskUserQuestion[]>(() => props.data.ask_user?.questions ?? []);
+  const current = computed<AskUserQuestion | undefined>(() => questions.value[step.value]);
+
+  const init = (qs: AskUserQuestion[]) => {
+    answers.value = qs.map(() => '');
+    otherSelected.value = qs.map(() => false);
+    step.value = 0;
+  };
+
+  // 每次 interrupt 都会替换 pendingApprovalData 对象,按引用变化重建答案(同一条消息可能被多次中断)
+  watch(
+    () => props.data,
+    (d) => {
+      const qs = d?.ask_user?.questions;
+      if (qs && qs.length) init(qs);
+    },
+    { immediate: true }
+  );
+
+  // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
+  const getChoiceValue = (choice: any): string => {
+    if (typeof choice === 'string') return choice;
+    if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
+    return String(choice);
+  };
+
+  const isAnswered = (qIndex: number, question?: AskUserQuestion): boolean => {
+    if (!question) return false;
+    const value = answers.value[qIndex];
+    if (question.required === false && !value) return true;
+    return !!(value && value.trim() !== '');
+  };
+
+  const selectChoice = (qIndex: number, choice: any, total: number) => {
+    answers.value[qIndex] = getChoiceValue(choice);
+    otherSelected.value[qIndex] = false;
+    // 单选自动跳到下一题(非最后一题时)
+    if (qIndex < total - 1) step.value = qIndex + 1;
+  };
+
+  const selectOther = (qIndex: number) => {
+    otherSelected.value[qIndex] = true;
+    answers.value[qIndex] = '';
+  };
+
+  const goNext = (total: number) => {
+    if (step.value < total - 1) step.value++;
+  };
+
+  const submit = () => {
+    // 防御性兜底:答案数组与题数不一致(未初始化)时按题数重建
+    if (answers.value.length !== questions.value.length) init(questions.value);
+    emit('submit', [...answers.value]);
+  };
+
+  defineExpose({ init });
+</script>
+
+<style scoped lang="less">
+  .approval-card {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    padding: 14px 16px;
+    margin-top: 12px;
+    background: #002f4a;
+    border: 1px solid #005482;
+    border-radius: 8px;
+
+    .approval-icon {
+      width: 36px;
+      height: 36px;
+      background-image: var(--img-chat-session-approve-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      flex-shrink: 0;
+    }
+
+    .approval-content {
+      flex: 1;
+
+      .approval-title {
+        font-size: 14px;
+        font-weight: 600;
+        color: #e0e6ed;
+        margin-bottom: 6px;
+      }
+
+      .approval-plan {
+        margin-bottom: 14px;
+        padding: 10px 12px;
+        background: rgba(10, 132, 255, 0.08);
+        border: 1px solid rgba(10, 132, 255, 0.2);
+        border-radius: 6px;
+
+        .approval-plan-label {
+          font-size: 12px;
+          color: #0a84ff;
+          font-weight: 600;
+          margin-bottom: 6px;
+        }
+
+        .approval-plan-content {
+          font-size: 13px;
+          color: #c9d1d9;
+          line-height: 1.6;
+          white-space: pre-wrap;
+        }
+      }
+
+      .ask-user-question {
+        margin-bottom: 14px;
+
+        .ask-user-question-text {
+          font-size: 13px;
+          color: #c9d1d9;
+          margin-bottom: 8px;
+          line-height: 1.5;
+
+          .ask-user-required {
+            color: #f85149;
+            margin-left: 2px;
+          }
+        }
+
+        .ask-user-choices {
+          display: flex;
+          flex-direction: column;
+          gap: 6px;
+
+          .ask-user-choice {
+            display: flex;
+            align-items: center;
+            padding: 8px 12px;
+            background: rgba(255, 255, 255, 0.04);
+            border: 1px solid rgba(63, 80, 106, 0.4);
+            border-radius: 6px;
+            cursor: pointer;
+            font-size: 13px;
+            color: #c9d1d9;
+            transition: all 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.1);
+              border-color: rgba(10, 132, 255, 0.4);
+            }
+
+            &.selected {
+              background: rgba(10, 132, 255, 0.15);
+              border-color: #0a84ff;
+              color: #fff;
+            }
+          }
+
+          .ask-user-other-input {
+            margin-top: 4px;
+            padding: 8px 12px;
+            font-size: 13px;
+            color: #e0e6ed;
+            background: rgba(255, 255, 255, 0.06);
+            border: 1px solid rgba(63, 80, 106, 0.5);
+            border-radius: 6px;
+            outline: none;
+            width: 100%;
+
+            &:focus {
+              border-color: #0a84ff;
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &::placeholder {
+              color: #6e7681;
+            }
+          }
+        }
+
+        .ask-user-text {
+          .ask-user-textarea {
+            width: 100%;
+            min-height: 60px;
+            padding: 8px 12px;
+            font-size: 13px;
+            color: #e0e6ed;
+            background: rgba(255, 255, 255, 0.06);
+            border: 1px solid rgba(63, 80, 106, 0.5);
+            border-radius: 6px;
+            outline: none;
+            resize: vertical;
+            font-family: inherit;
+
+            &:focus {
+              border-color: #0a84ff;
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &::placeholder {
+              color: #6e7681;
+            }
+          }
+        }
+      }
+
+      .ask-user-stepper {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        margin-bottom: 14px;
+
+        .ask-user-step-dot {
+          width: 8px;
+          height: 8px;
+          border-radius: 50%;
+          background: rgba(63, 80, 106, 0.5);
+          cursor: pointer;
+          transition: all 0.2s;
+
+          &.active {
+            background: #0a84ff;
+            width: 20px;
+            border-radius: 4px;
+          }
+
+          &.completed {
+            background: rgba(10, 132, 255, 0.5);
+          }
+        }
+
+        .ask-user-step-text {
+          margin-left: 6px;
+          font-size: 12px;
+          color: #6e7681;
+        }
+      }
+
+      .approval-actions {
+        display: flex;
+        gap: 10px;
+
+        .approval-btn {
+          padding: 6px 20px;
+          border-radius: 6px;
+          font-size: 13px;
+          font-weight: 500;
+          cursor: pointer;
+          border: none;
+          transition: all 0.2s;
+
+          &.approve-btn {
+            background: #0a84ff;
+            color: #fff;
+
+            &:hover:not(:disabled) {
+              background: #3a9bfd;
+            }
+
+            &:disabled {
+              opacity: 0.4;
+              cursor: not-allowed;
+            }
+          }
+
+          &.nav-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #c9d1d9;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.15);
+            }
+          }
+
+          &.reject-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #e0e6ed;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.15);
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/ChatHeader.vue → src/views/ventAI/manageAssistent/components/chat/ChatHeader.vue

@@ -71,8 +71,8 @@
 <script setup lang="ts">
   import { ref, nextTick } from 'vue';
   import { onClickOutside } from '@vueuse/core';
-  import ResourceCapsule from './ResourceCapsule.vue';
-  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from './types';
+  import ResourceCapsule from '/@/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from '/@/views/ventAI/manageAssistent/types/index';
 
   interface Props {
     currentTask: Task | undefined;

+ 587 - 0
src/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue

@@ -0,0 +1,587 @@
+<template>
+  <div class="chat-input-area">
+    <div v-if="pendingFile" class="pending-file-display">
+      <div class="pending-file-card" @click="emit('pending-file-preview', pendingFile)">
+        <div class="file-icon-pending" :style="{ backgroundImage: getFileIconVar(pendingFile.name) }"></div>
+        <div class="file-info-pending">
+          <div class="file-name-pending">{{ pendingFile.name }}</div>
+          <div class="file-size-pending">{{ formatFileSize(pendingFile.size) }}</div>
+        </div>
+        <div class="remove-pending" @click.stop="emit('remove-pending-file')">
+          <CloseOutlined />
+        </div>
+      </div>
+    </div>
+
+    <!-- 已选会话引用标签 -->
+    <div v-if="selectedSessions.length > 0" class="session-chips">
+      <div v-for="session in selectedSessions" :key="session.id" class="session-chip">
+        <div class="session-chip-icon"></div>
+        <span class="session-chip-name">{{ session.name }}</span>
+        <CloseOutlined class="session-chip-remove" @click.stop="removeSession(session.id)" />
+      </div>
+    </div>
+
+    <!-- 已选技能引用标签 -->
+    <div v-if="selectedSkill" class="skill-chips">
+      <div class="skill-chip">
+        <div class="skill-chip-icon"></div>
+        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
+        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
+      </div>
+    </div>
+
+    <a-textarea
+      ref="textareaRef"
+      :value="modelValue"
+      @update:value="handleTextareaInput"
+      :rows="3"
+      class="chat-input"
+      placeholder="请输入你的问题"
+      :auto-size="{ minRows: 3, maxRows: 6 }"
+      @keydown="handleSessionKeydown"
+      @keydown.enter.prevent="handleEnterKey"
+      @keydown.backspace="handleBackspace"
+      @compositionstart="handleCompositionStart"
+      @compositionend="handleCompositionEnd"
+      @blur="handleCompositionEnd"
+    />
+
+    <div class="input-actions">
+      <div class="action-left">
+        <div class="btn-upload-wrapper">
+          <a-tooltip title="添加上下文">
+            <div class="btn-upload" @click="togglePopup">
+              <div class="plus-icon"></div>
+            </div>
+          </a-tooltip>
+          <div v-if="showPopup" ref="popupRef" class="upload-popup" @click.stop>
+            <div class="popup-item" @click="handleAddAttachment">
+              <div class="popup-item-icon attach-icon"></div>
+              <span class="popup-item-text">添加附件</span>
+            </div>
+            <div class="popup-item" @click="handleInsertSession">
+              <div class="popup-item-icon session-icon"></div>
+              <span class="popup-item-text">插入 # 会话</span>
+            </div>
+            <div class="popup-item" @click="handleInsertSkill">
+              <div class="popup-item-icon skill-icon"></div>
+              <span class="popup-item-text">插入 $ 技能</span>
+            </div>
+          </div>
+        </div>
+        <div class="action-divider"></div>
+        <ModePopup :initial-mode="initialEditMode" @change="emit('edit-mode-change', $event)" />
+        <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" />
+      </div>
+      <div class="action-right">
+        <ContextRing :used="contextUsed" :max="contextMax" :percent="contextPercent" :breakdown="contextBreakdown" />
+        <div class="action-divider"></div>
+        <UsageRing :limit="usageLimit" :spent="usageSpent" :remaining="usageRemaining" :by-model="usageByModel" />
+        <div class="action-divider"></div>
+        <ThinkLevelPopup :initial-level="initialThinkLevel" @change="emit('think-level-change', $event)" />
+        <div class="action-divider"></div>
+        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
+          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
+        </div>
+      </div>
+
+      <SessionPopup
+        v-if="showSessionPopup"
+        :list="filteredSessionList"
+        :active-index="activeSessionIndex"
+        :selected-ids="selectedSessions.map((s) => s.id)"
+        :current-task-id="currentTaskId || ''"
+        @select="handleSelectSession"
+        @close="closeSessionPopup"
+      />
+      <SkillPopup
+        v-if="showSkillPopup"
+        :list="filteredSkillList"
+        :active-index="activeSkillIndex"
+        :selected-name="selectedSkill ? selectedSkill.name : ''"
+        @select="handleSelectSkill"
+        @close="closeSkillPopup"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import { CloseOutlined } from '@ant-design/icons-vue';
+  import { formatFileSize, getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
+  import ContextRing from '/@/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue';
+  import UsageRing from '/@/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue';
+  import ModePopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue';
+  import ThinkLevelPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue';
+  import SessionPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue';
+  import SkillPopup from '/@/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue';
+  import { useMentionPopups } from '/@/views/ventAI/manageAssistent/hooks/useMentionPopups';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api/client';
+  import type { AttachedFile, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+  const props = withDefaults(
+    defineProps<{
+      modelValue: string;
+      pendingFile: AttachedFile | null;
+      loading: boolean;
+      streaming?: boolean;
+      contextUsed?: number;
+      contextMax?: number;
+      contextPercent?: number;
+      contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
+      usageLimit?: number;
+      usageSpent?: number;
+      usageRemaining?: number;
+      usageByModel?: UsageByModel[];
+      initialThinkLevel?: string;
+      initialEditMode?: string;
+      taskList?: Task[];
+      currentTaskId?: string;
+    }>(),
+    {
+      contextUsed: 0,
+      contextMax: 1000000,
+      contextPercent: 0,
+      streaming: false,
+      contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
+      usageLimit: 1000,
+      usageSpent: 0,
+      usageRemaining: 0,
+      usageByModel: () => [],
+      initialThinkLevel: 'off',
+      initialEditMode: 'full',
+      taskList: () => [],
+      currentTaskId: '',
+    }
+  );
+
+  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;
+    (e: 'pending-file-preview', file: AttachedFile): void;
+    (e: 'think-level-change', levelKey: string): void;
+    (e: 'edit-mode-change', modeKey: string): void;
+    (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
+    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
+  }>();
+
+  const fileInputRef = ref<HTMLInputElement>();
+  const popupRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  // 上下文容量
+  // 思考级别
+  onClickOutside(popupRef, () => {
+    showPopup.value = false;
+  });
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  const handleAddAttachment = () => {
+    showPopup.value = false;
+    emit('file-upload');
+  };
+
+  // 会话列表弹框
+  const textareaRef = ref<InstanceType<any>>();
+
+  // —— 会话/技能引用弹层已下沉到 useMentionPopups(模板仍在本组件,状态与交互逻辑在 composable) ——
+  const {
+    showSessionPopup,
+    activeSessionIndex,
+    selectedSessions,
+    showSkillPopup,
+    activeSkillIndex,
+    selectedSkill,
+    filteredSessionList,
+    filteredSkillList,
+    handleSessionKeydown,
+    handleSelectSession,
+    handleSelectSkill,
+    handleInsertSession,
+    handleInsertSkill,
+    removeSession,
+    removeSkill,
+    handleBackspace,
+    syncPopups,
+    consumeJustSelected,
+    closeSessionPopup,
+    closeSkillPopup,
+  } = useMentionPopups({
+    getModelValue: () => props.modelValue,
+    setModelValue: (value) => emit('update:modelValue', value),
+    getTaskList: () => props.taskList || [],
+    getCurrentTaskId: () => props.currentTaskId || '',
+    isComposing: () => composing,
+    closeAttachPopup: () => {
+      showPopup.value = false;
+    },
+    focusTextarea: () => {
+      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
+      if (el) el.focus();
+    },
+    setSelectedSessions: (sessions) => emit('update:selectedSessions', sessions),
+    setSelectedSkill: (skill) => emit('update:selectedSkill', skill),
+  });
+  // 中文等输入法的组合态标记:拼音候选期间按回车是「候选上屏」,不能当成发送/选中列表项。
+  // 浏览器对该次 keydown 的 event.isComposing 支持不一致(Safari 会提前置 false),
+  // 因此用 compositionstart/end 自行记录;blur 时兜底复位,避免组合态卡住导致回车失效。
+  let composing = false;
+
+  const handleCompositionStart = () => {
+    composing = true;
+  };
+
+  const handleCompositionEnd = () => {
+    composing = false;
+  };
+
+  const handleTextareaInput = (val: string) => {
+    emit('update:modelValue', val);
+    syncPopups(val);
+  };
+
+  const handleEnterKey = (e: KeyboardEvent) => {
+    // 输入法组合态下的回车只用于上屏候选词,不能触发发送
+    if (composing || e.isComposing) return;
+    if (showSessionPopup.value || showSkillPopup.value) return;
+    if (consumeJustSelected()) return;
+    if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
+      emit('send');
+    }
+  };
+
+  const handleSend = () => {
+    if (props.modelValue.trim() && !props.loading) {
+      emit('send');
+    }
+  };
+
+  const handleSendClick = () => {
+    if (props.streaming) {
+      emit('stop');
+      return;
+    }
+    handleSend();
+  };
+
+  defineExpose({ fileInputRef });
+</script>
+
+<style scoped lang="less">
+  .chat-input-area {
+    position: relative;
+    padding: 5px 10px;
+    flex-shrink: 0;
+    background-color: #002f4a;
+    border: 1px solid #005482;
+    border-radius: 4px;
+    margin: 10px 16px 16px 16px;
+
+    .pending-file-display {
+      margin-bottom: 5px;
+
+      .pending-file-card {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 10px 12px;
+        background: rgba(10, 132, 255, 0.06);
+        border: 1px solid rgba(63, 80, 106, 0.5);
+        border-radius: 6px;
+        transition: all 0.3s;
+        cursor: pointer;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.1);
+        }
+
+        .file-icon-pending {
+          width: 32px;
+          height: 32px;
+          background-image: var(--img-chat-attach-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .file-info-pending {
+          flex: 1;
+          min-width: 0;
+
+          .file-name-pending {
+            font-size: 12px;
+            font-weight: 400;
+            line-height: 1.4;
+            color: #e0e6ed;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            margin-bottom: 2px;
+          }
+
+          .file-size-pending {
+            font-size: 11px;
+            font-weight: 400;
+            line-height: 1.3;
+            color: #8b949e;
+          }
+        }
+
+        .remove-pending {
+          cursor: pointer;
+          color: #8b949e;
+          font-size: 14px;
+          padding: 4px;
+          border-radius: 4px;
+          transition: all 0.3s;
+
+          &:hover {
+            color: #ff4d4f;
+            background: rgba(220, 38, 38, 0.1);
+          }
+        }
+      }
+    }
+
+    :deep(.chat-input) {
+      color: #e0e6ed;
+      background-color: transparent !important;
+      border: none !important;
+
+      &::placeholder {
+        color: #8b949e !important;
+        opacity: 1 !important;
+      }
+      &:focus,
+      &:hover,
+      &:active {
+        border: none !important;
+        box-shadow: none !important;
+        outline: none !important;
+      }
+    }
+    .input-actions {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-top: 12px;
+
+      .action-left,
+      .action-right {
+        display: flex;
+        gap: 8px;
+        .action-divider {
+          width: 1px;
+          height: 18px;
+          background: rgba(255, 255, 255, 0.15);
+          align-self: center;
+          flex-shrink: 0;
+        }
+        .btn-upload-wrapper {
+          position: relative;
+        }
+        .btn-upload {
+          width: 30px;
+          height: 30px;
+          border-radius: 6px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+        }
+        .plus-icon {
+          width: 20px;
+          height: 20px;
+          background-image: var(--img-chat-input-bg);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          cursor: pointer;
+        }
+        .upload-popup {
+          position: absolute;
+          bottom: 38px;
+          left: 0;
+          background: #0a2a3f;
+          border: 1px solid #1a4a6f;
+          border-radius: 8px;
+          min-width: 160px;
+          z-index: 1000;
+          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+          .popup-item {
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            padding: 9px 16px;
+            cursor: pointer;
+            transition: background 0.2s;
+            border-radius: 8px;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.15);
+            }
+
+            .popup-item-icon {
+              width: 18px;
+              height: 18px;
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              flex-shrink: 0;
+            }
+
+            .attach-icon {
+              background-image: var(--img-chat-popup-attach-icon);
+            }
+
+            .session-icon {
+              background-image: var(--img-chat-popup-session-icon);
+            }
+
+            .skill-icon {
+              background-image: var(--img-chat-options-skill-icon);
+            }
+
+            .popup-item-text {
+              font-size: 13px;
+              color: #e0e6ed;
+              white-space: nowrap;
+            }
+          }
+        }
+        .btn-send {
+          width: 32px;
+          height: 32px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          cursor: pointer;
+          opacity: 1;
+          transition: opacity 0.2s;
+
+          &.disabled {
+            opacity: 0.4;
+            cursor: not-allowed;
+          }
+
+          .send-icon {
+            width: 24px;
+            height: 24px;
+            background-image: var(--img-chat-send-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+
+            &.stop-icon {
+              background-image: var(--img-chat-stop-icon);
+            }
+          }
+        }
+      }
+    }
+
+    .session-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .session-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .session-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-popup-session-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .session-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .session-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+
+    .skill-chips {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 6px;
+      margin-bottom: 5px;
+
+      .skill-chip {
+        display: inline-flex;
+        align-items: center;
+        gap: 4px;
+        padding: 3px 6px 3px 4px;
+        background: rgba(10, 132, 255, 0.15);
+        border: 1px solid rgba(10, 132, 255, 0.3);
+        border-radius: 4px;
+        font-size: 12px;
+        color: #e0e6ed;
+        cursor: default;
+
+        .skill-chip-icon {
+          width: 16px;
+          height: 16px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-chip-name {
+          max-width: 150px;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+
+        .skill-chip-remove {
+          font-size: 10px;
+          color: #8b949e;
+          cursor: pointer;
+          padding: 0 2px;
+          transition: color 0.2s;
+
+          &:hover {
+            color: #ff4d4f;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 1316 - 0
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -0,0 +1,1316 @@
+<template>
+  <div class="chat-messages-container">
+    <!-- 左侧对话缩略导航(悬浮提示与滚动跟随内聚在组件内部) -->
+    <ConversationNav
+      ref="conversationNavRef"
+      :messages="messages"
+      :is-at-bottom="isAtBottom"
+      :get-container="() => messagesRef"
+      :scroll-to-message="scrollToMessage"
+    />
+
+    <div class="chat-messages" ref="messagesRef">
+      <!-- 新对话缺省图 -->
+      <div v-if="messages.length === 0" class="welcome-empty">
+        <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
+      </div>
+      <div
+        v-for="(message, index) in messages"
+        :key="getMessageKey(message)"
+        :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
+        @mouseleave="copiedMessageKey = ''"
+      >
+        <!-- AI消息 -->
+        <div v-if="message.type === 'ai'" class="ai-message">
+          <div class="message-body">
+            <ThinkingCard
+              v-if="message.thinkingSteps && message.thinkingSteps.length > 0"
+              :message="message"
+              :collapsed="!!collapsedThinking[getMessageKey(message)]"
+              :content-collapsed="!!collapsedThinkingContent[getMessageKey(message)]"
+              @update:collapsed="collapsedThinking[getMessageKey(message)] = $event"
+              @update:content-collapsed="collapsedThinkingContent[getMessageKey(message)] = $event"
+              @agent-click="handleAgentGroupClick(index, $event)"
+            />
+
+            <div
+              v-if="message.content"
+              class="message-text"
+              v-html="
+                renderMarkdownCached(
+                  message.content,
+                  tableIcons,
+                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined,
+                  !message.isLoading
+                )
+              "
+              @click="handleFootnoteClick($event, index)"
+            ></div>
+
+            <!-- 数据来源卡片 -->
+            <CitationsCard
+              v-if="message.citations"
+              :message="message"
+              :index="index"
+              :collapsed="!!collapsedCitations[getMessageKey(message)]"
+              @update:collapsed="collapsedCitations[getMessageKey(message)] = $event"
+            />
+
+            <div v-if="message.wordDownloadUrl" class="word-download-area">
+              <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
+            </div>
+
+            <!-- 文件操作卡片 -->
+            <DownloadFiles
+              v-if="message.downloadFiles?.length"
+              :files="message.downloadFiles"
+              @file-preview="emit('file-preview', $event)"
+              @open-file-preview="emit('open-file-preview', $event)"
+            />
+
+            <div v-show="message.isLoading" class="loading-dots">
+              <span class="dot"></span>
+              <span class="dot"></span>
+              <span class="dot"></span>
+            </div>
+
+            <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
+            <AskUserCard
+              v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length"
+              :ref="(el) => setAskCardRef(message, el)"
+              :data="message.pendingApprovalData"
+              @submit="emit('ask-user-submit', $event)"
+              @cancel="emit('ask-user-cancel')"
+            />
+          </div>
+          <!-- AI消息操作栏 -->
+          <MessageActions
+            :message="message"
+            :copied="copiedMessageKey === getMessageKey(message)"
+            :can-regenerate="canRegenerate(index, message)"
+            :show-answer-switcher="showAnswerSwitcher(index)"
+            :can-prev="canPrevAnswer(index)"
+            :can-next="canNextAnswer(index)"
+            :answer-position="answerPositionText(index)"
+            @copy="copyMessageContent(message.content, getMessageKey(message))"
+            @delete="emit('delete-message', message)"
+            @regenerate="emit('regenerate')"
+            @prev-answer="prevAnswer(index)"
+            @next-answer="nextAnswer(index)"
+          />
+        </div>
+
+        <!-- 用户消息 -->
+        <div v-else class="user-message">
+          <div class="message-body">
+            <template v-if="editingKey === getMessageKey(message)">
+              <textarea
+                v-model="editDraft"
+                class="message-edit-textarea"
+                spellcheck="false"
+                @keydown.enter.exact.prevent="submitEdit(index)"
+                @keydown.escape.prevent="cancelEdit"
+              ></textarea>
+              <div class="message-edit-actions">
+                <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
+                <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
+              </div>
+            </template>
+            <template v-else>
+              <div class="message-text" v-html="renderMarkdownCached(message.content, tableIcons)"></div>
+              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
+                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
+                <div class="file-info-card">
+                  <div class="file-name-card">{{ message.attachedFile.name }}</div>
+                  <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
+                </div>
+              </div>
+            </template>
+          </div>
+          <!-- 用户消息操作栏 -->
+          <MessageActions
+            :message="message"
+            :copied="copiedMessageKey === getMessageKey(message)"
+            :can-edit="canEdit(index, message)"
+            @copy="copyMessageContent(message.content, getMessageKey(message))"
+            @delete="emit('delete-message', message)"
+            @edit="startEdit(index)"
+          />
+        </div>
+      </div>
+    </div>
+
+    <!-- 回到底部按钮 -->
+    <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
+      <div class="scroll-to-bottom-icon"></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
+  import ConversationNav from './ConversationNav.vue';
+  import CitationsCard from './CitationsCard.vue';
+  import AskUserCard from './AskUserCard.vue';
+  import DownloadFiles from './DownloadFiles.vue';
+  import MessageActions from './MessageActions.vue';
+  import ThinkingCard from './ThinkingCard.vue';
+  import {
+    renderMarkdownCached,
+    formatFileSize,
+    tableHtmlToMarkdown,
+    tableHtmlToCsv,
+    getFileIconVar,
+    getCssUrl,
+    getMessageKey,
+  } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentDetailData, AskUserQuestion, Message, AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+  export type { AgentDetailData };
+  // import katex from 'katex';
+  // import 'katex/dist/katex.min.css';
+
+  const tableIcons = {
+    copy: getCssUrl('--img-chat-copy-icon'),
+    download: getCssUrl('--img-chat-download-icon-btn'),
+    preview: getCssUrl('--img-chat-preview-icon'),
+    copySuccess: getCssUrl('--img-chat-copy-success-icon'),
+  };
+
+  const props = defineProps<{
+    messages: Message[];
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-word', url: string): void;
+    (e: 'open-file-preview', file: AttachedFile): void;
+    (e: 'ask-user-submit', answers: string[]): void;
+    (e: 'ask-user-cancel'): void;
+    (e: 'agent-detail-click', data: AgentDetailData): void;
+    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
+    (e: 'delete-message', message: Message): void;
+    (e: 'regenerate'): void;
+    (e: 'edit-resend', index: number, newContent: string): void;
+  }>();
+
+  // 用户消息内联编辑状态
+  const editingKey = ref('');
+  const editDraft = ref('');
+
+  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
+  const stripInjectedHtml = (content: string): string =>
+    (content || '')
+      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+      .replace(/<img[^>]*>/g, '')
+      .trim();
+
+  // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
+  interface AnswerGroup {
+    indices: number[];
+  }
+
+  // 将连续的 AI 消息划分为若干“回答组”
+  const answerGroups = computed<AnswerGroup[]>(() => {
+    const groups: AnswerGroup[] = [];
+    let current: AnswerGroup | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'ai') {
+        if (!current) {
+          current = { indices: [] };
+          groups.push(current);
+        }
+        current.indices.push(index);
+      } else {
+        current = null;
+      }
+    });
+    return groups;
+  });
+
+  // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
+  const aiGroupMeta = computed(() => {
+    const meta = new Map<number, { groupKey: string; offset: number; groupLen: number }>();
+    for (const group of answerGroups.value) {
+      const groupKey = getMessageKey(props.messages[group.indices[0]]);
+      const groupLen = group.indices.length;
+      group.indices.forEach((idx, offset) => {
+        meta.set(idx, { groupKey, offset, groupLen });
+      });
+    }
+    return meta;
+  });
+
+  // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
+  const activeAnswerOffset = reactive<Record<string, number>>({});
+
+  const currentAnswerOffset = (groupKey: string, groupLen: number): number => {
+    const val = activeAnswerOffset[groupKey];
+    if (val == null) return groupLen - 1;
+    return Math.min(Math.max(val, 0), groupLen - 1);
+  };
+
+  // 末尾回答组内是否存在正在生成(isLoading)的消息
+  const lastAnswerGroupHasLoading = computed(() => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    let start = msgs.length - 1;
+    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+    for (let i = start; i < msgs.length; i++) {
+      if (msgs[i].isLoading) return true;
+    }
+    return false;
+  });
+
+  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
+  watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
+    if (loading && !wasLoading) {
+      const msgs = props.messages;
+      let start = msgs.length - 1;
+      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+      delete activeAnswerOffset[getMessageKey(msgs[start])];
+    }
+  });
+
+  // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
+  const isHiddenAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
+  };
+
+  // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
+  const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
+
+  // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
+  const showAnswerSwitcher = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta || meta.groupLen <= 1) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
+  };
+
+  const answerPositionText = (index: number): string => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return '';
+    return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
+  };
+
+  const canPrevAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
+  };
+
+  const canNextAnswer = (index: number): boolean => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return false;
+    return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
+  };
+
+  const prevAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
+  };
+
+  const nextAnswer = (index: number) => {
+    const meta = aiGroupMeta.value.get(index);
+    if (!meta) return;
+    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
+    if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
+  };
+
+  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
+  const isInLastAnswerGroup = (index: number): boolean => {
+    const msgs = props.messages;
+    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
+    if (msgs[index]?.type !== 'ai') return false;
+    let start = msgs.length - 1;
+    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
+    return index >= start;
+  };
+
+  const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
+
+  // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
+  const lastUserMessageIndex = computed(() => {
+    for (let i = props.messages.length - 1; i >= 0; i--) {
+      if (props.messages[i].type === 'user') return i;
+    }
+    return -1;
+  });
+
+  const canEdit = (index: number, message: Message): boolean =>
+    message.type === 'user' && index === lastUserMessageIndex.value && editingKey.value !== getMessageKey(message);
+
+  const startEdit = (index: number) => {
+    const msg = props.messages[index];
+    editingKey.value = getMessageKey(msg);
+    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
+  };
+
+  const cancelEdit = () => {
+    editingKey.value = '';
+    editDraft.value = '';
+  };
+
+  const submitEdit = (index: number) => {
+    const content = editDraft.value;
+    editingKey.value = '';
+    editDraft.value = '';
+    emit('edit-resend', index, content);
+  };
+
+  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
+  // ask_user 答案状态(按消息索引存储)
+  // 审批卡实例按消息对象索引:SSE 侧拿到消息下标后据此路由到对应卡片(卡片自身也会在 data 变化时重建答案)
+  const askCardRefs = new Map<Message, InstanceType<typeof AskUserCard>>();
+  const setAskCardRef = (msg: Message, el: unknown) => {
+    if (el) askCardRefs.set(msg, el as InstanceType<typeof AskUserCard>);
+    else askCardRefs.delete(msg);
+  };
+
+  const initAskUserAnswers = (index: number, questions: AskUserQuestion[]) => {
+    const msg = props.messages[index];
+    const card = msg ? askCardRefs.get(msg) : undefined;
+    card?.init(questions);
+  };
+
+  const messagesRef = ref<HTMLElement>();
+  const conversationNavRef = ref<InstanceType<typeof ConversationNav>>();
+  const collapsedThinking = reactive<Record<string, boolean>>({});
+  const collapsedThinkingContent = reactive<Record<string, boolean>>({});
+  const collapsedCitations = reactive<Record<string, boolean>>({});
+  const isAtBottom = ref(true);
+  const copiedMessageKey = ref('');
+
+  const handleTableAction = (e: Event) => {
+    const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
+    if (!target) return;
+
+    const action = target.dataset.action;
+    const encodedTable = target.dataset.table;
+    if (!action || !encodedTable) return;
+
+    const tableHtml = decodeURIComponent(encodedTable);
+
+    switch (action) {
+      case 'copy': {
+        const markdown = tableHtmlToMarkdown(tableHtml);
+        const doCopy = async () => {
+          try {
+            await navigator.clipboard.writeText(markdown);
+          } catch {
+            const ta = document.createElement('textarea');
+            ta.value = markdown;
+            ta.style.position = 'fixed';
+            ta.style.opacity = '0';
+            document.body.appendChild(ta);
+            ta.select();
+            document.execCommand('copy');
+            document.body.removeChild(ta);
+          }
+        };
+        doCopy().then(() => {
+          const btn = target;
+          const img = btn.querySelector('img');
+          const successIcon = btn.dataset.iconSuccess;
+          const originalIcon = btn.dataset.icon;
+          if (img && successIcon) {
+            img.src = successIcon;
+            btn.title = '复制成功';
+            const reset = () => {
+              img.src = originalIcon!;
+              btn.title = '复制Markdown';
+              wrapper.removeEventListener('mouseleave', reset);
+            };
+            const wrapper = btn.closest('.markdown-table-wrapper')!;
+            wrapper.addEventListener('mouseleave', reset);
+          }
+        });
+        break;
+      }
+      case 'csv': {
+        const csv = tableHtmlToCsv(tableHtml);
+        const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = `table_${Date.now()}.csv`;
+        a.click();
+        URL.revokeObjectURL(url);
+        break;
+      }
+      case 'preview': {
+        // 在右侧面板新建标签页展示表格渲染效果
+        emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
+        break;
+      }
+    }
+  };
+
+  const checkScrollBottom = () => {
+    const el = messagesRef.value;
+    if (!el) return;
+    const threshold = 50;
+    isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
+  };
+
+  const scrollToBottom = () => {
+    const el = messagesRef.value;
+    if (el) {
+      el.scrollTop = el.scrollHeight - el.clientHeight;
+    }
+  };
+
+  const forceScrollToBottom = () => {
+    const el = messagesRef.value;
+    if (el) {
+      el.scrollTop = el.scrollHeight - el.clientHeight;
+      // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
+      requestAnimationFrame(() => {
+        el.scrollTop = el.scrollHeight - el.clientHeight;
+      });
+    }
+    isAtBottom.value = true;
+  };
+
+  onMounted(() => {
+    messagesRef.value?.addEventListener('click', handleTableAction);
+    messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
+  });
+
+  onBeforeUnmount(() => {
+    messagesRef.value?.removeEventListener('click', handleTableAction);
+    messagesRef.value?.removeEventListener('scroll', handleMessagesScroll);
+  });
+
+  const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
+    emit('agent-detail-click', {
+      messageIndex,
+      agent: group.agent,
+      agentId: group.agentId,
+    });
+  };
+
+  const handleFootnoteClick = (e: Event, msgIndex: number) => {
+    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
+    if (!target) return;
+    const n = target.dataset.n;
+    if (!n) return;
+    const el = document.getElementById(`citation-${msgIndex}-${n}`);
+    if (el) {
+      scrollMessagesTo(el, 'center');
+      el.classList.add('citation-highlight');
+      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
+    }
+  };
+
+  const copyMessageContent = async (content: string, messageKey: string) => {
+    if (!content) return;
+    try {
+      await navigator.clipboard.writeText(content);
+      copiedMessageKey.value = messageKey;
+    } catch {
+      const textarea = document.createElement('textarea');
+      textarea.value = content;
+      textarea.style.position = 'fixed';
+      textarea.style.opacity = '0';
+      document.body.appendChild(textarea);
+      textarea.select();
+      document.execCommand('copy');
+      document.body.removeChild(textarea);
+      copiedMessageKey.value = messageKey;
+    }
+  };
+
+  // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
+  const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
+    const container = messagesRef.value;
+    if (!container) return;
+    // 容器处于自适应缩放环境:getBoundingClientRect 是缩放后的视口坐标,scrollTop 是未缩放的布局坐标,
+    // 直接混算在缩放比非 1(打包部署后的其他分辨率)时定位偏差,需除以纵向缩放比换算回布局坐标
+    //(与左侧对话缩略导航 tooltip 的换算方式一致)
+    const rect = container.getBoundingClientRect();
+    const scale = container.offsetHeight ? rect.height / container.offsetHeight : 1;
+    const safeScale = scale > 0 && Number.isFinite(scale) ? scale : 1;
+    const top = (el.getBoundingClientRect().top - rect.top) / safeScale + container.scrollTop;
+    const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
+    container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
+  };
+
+  const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
+    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
+    let el = wrappers?.[index] as HTMLElement | undefined;
+    // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
+    if (el && el.offsetParent === null) {
+      el = undefined;
+      for (let i = index - 1; i >= 0; i--) {
+        const w = wrappers?.[i] as HTMLElement | undefined;
+        if (w && w.offsetParent !== null) {
+          el = w;
+          break;
+        }
+      }
+    }
+    if (el) scrollMessagesTo(el, block);
+  };
+
+  const handleMessagesScroll = () => {
+    checkScrollBottom();
+    conversationNavRef.value?.onContainerScroll();
+  };
+
+  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
+</script>
+
+<style scoped lang="less">
+  .chat-messages-container {
+    flex: 1;
+    position: relative;
+    display: flex;
+    flex-direction: row;
+    overflow: hidden;
+  }
+
+  .chat-messages {
+    flex: 1;
+    overflow-y: auto;
+    padding: 20px;
+    position: relative;
+
+    .welcome-empty {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      .welcome-img {
+        max-width: 300px;
+        max-height: 300px;
+        object-fit: contain;
+      }
+    }
+
+    .message-wrapper {
+      // margin-bottom: 20px;
+
+      &.answer-hidden {
+        display: none;
+      }
+
+      &.nav-located-highlight {
+        background: rgba(10, 132, 255, 0.08);
+        border-radius: 6px;
+      }
+
+      .ai-message {
+        display: flex;
+        flex-wrap: wrap;
+        .message-agent {
+          display: flex;
+          margin-bottom: 8px;
+          opacity: 0;
+        }
+
+        .message-body {
+          padding: 12px 12px 4px 12px;
+          border-radius: 6px;
+          width: 100%;
+          min-width: 0;
+          overflow: hidden;
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+            word-break: break-word;
+            overflow-wrap: break-word;
+
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
+            :deep(h1),
+            :deep(h2),
+            :deep(h3),
+            :deep(h4),
+            :deep(h5),
+            :deep(h6) {
+              color: #fff;
+            }
+            :deep(p) {
+              margin: 0;
+            }
+            :deep(code) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 4px;
+              border-radius: 3px;
+              font-size: 12px;
+            }
+            :deep(pre) {
+              background: rgba(10, 132, 255, 0.1);
+              padding: 8px;
+              border-radius: 4px;
+              overflow-x: auto;
+              margin: 6px 0;
+              white-space: pre-wrap;
+            }
+            :deep(pre code) {
+              background: transparent;
+              padding: 0;
+            }
+            :deep(ul),
+            :deep(ol) {
+              padding-left: 18px;
+              margin: 4px 0;
+            }
+            :deep(blockquote) {
+              border-left: 3px solid rgba(10, 132, 255, 0.3);
+              padding-left: 10px;
+              margin: 6px 0;
+              color: #8b949e;
+            }
+            :deep(table) {
+              border-collapse: collapse;
+              margin: 6px 0;
+            }
+            :deep(th),
+            :deep(td) {
+              border: 1px solid rgba(63, 80, 106, 0.5);
+              padding: 4px 8px;
+            }
+            :deep(th) {
+              background: rgba(10, 132, 255, 0.1);
+            }
+            :deep(strong) {
+              font-weight: 600;
+            }
+
+            :deep(.markdown-table-wrapper) {
+              position: relative;
+              margin: 6px 0;
+              max-width: 100%;
+              overflow-x: auto;
+
+              .table-actions {
+                position: absolute;
+                top: 4px;
+                right: 4px;
+                display: flex;
+                gap: 2px;
+                opacity: 0;
+                transition: opacity 0.2s;
+                z-index: 1;
+              }
+
+              &:hover .table-actions {
+                opacity: 1;
+              }
+
+              .table-action-btn {
+                width: 26px;
+                height: 26px;
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                border-radius: 4px;
+                color: #8b949e;
+                cursor: pointer;
+                background: rgba(22, 27, 34, 0.8);
+                backdrop-filter: blur(4px);
+                transition: all 0.2s;
+
+                &:hover {
+                  color: #e0e6ed;
+                  background: rgba(10, 132, 255, 0.3);
+                }
+              }
+            }
+          }
+        }
+        .loading-dots {
+          display: flex;
+          align-items: center;
+          gap: 6px;
+          padding: 4px 0;
+          min-height: 16px;
+
+          .dot {
+            width: 8px;
+            height: 8px;
+            border-radius: 50%;
+            background: rgba(10, 132, 255, 0.4);
+            animation: dot-bounce 1.4s ease-in-out infinite both;
+
+            &:nth-child(1) {
+              animation-delay: 0s;
+            }
+            &:nth-child(2) {
+              animation-delay: 0.2s;
+            }
+            &:nth-child(3) {
+              animation-delay: 0.4s;
+            }
+          }
+        }
+
+        @keyframes dot-bounce {
+          0%,
+          80%,
+          100% {
+            transform: scale(0.6);
+            opacity: 0.4;
+          }
+          40% {
+            transform: scale(1);
+            opacity: 1;
+          }
+        }
+
+        .citations-card {
+          overflow: hidden;
+
+          .citations-header {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 8px 14px;
+            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+            cursor: pointer;
+            user-select: none;
+            transition: background 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.08);
+            }
+
+            &.citations-header-simple {
+              cursor: default;
+
+              &:hover {
+                background: transparent;
+              }
+            }
+
+            .citations-header-left {
+              display: flex;
+              align-items: center;
+              gap: 8px;
+            }
+
+            .citations-title {
+              font-size: 13px;
+              font-weight: 500;
+              color: #8b949e;
+            }
+
+            .citations-count {
+              font-size: 12px;
+              color: #8b949e;
+              background: rgba(10, 132, 255, 0.1);
+              padding: 1px 6px;
+              border-radius: 10px;
+            }
+
+            .thinking-arrow {
+              display: flex;
+              align-items: center;
+              transition: transform 0.2s;
+
+              &.expanded {
+                transform: rotate(180deg);
+              }
+            }
+          }
+
+          .citations-body {
+            padding: 4px 14px;
+
+            .citation-item {
+              padding: 6px 0;
+              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+              &:last-child {
+                border-bottom: none;
+              }
+
+              &.citation-highlight {
+                background: rgba(10, 132, 255, 0.08);
+                border-radius: 4px;
+                padding: 6px 8px;
+                margin: 0 -8px;
+              }
+
+              .citation-item-row {
+                display: flex;
+                align-items: center;
+                gap: 8px;
+                line-height: 1.5;
+
+                .citation-badge {
+                  font-size: 12px;
+                  color: #79c0ff;
+                  font-family: monospace;
+                  background: rgba(10, 132, 255, 0.1);
+                  padding: 1px 5px;
+                  border-radius: 3px;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+
+                .citation-badge-knowledge_base {
+                  color: #3fb950;
+                  background: rgba(46, 160, 67, 0.1);
+                }
+
+                .citation-badge-web {
+                  color: #d29922;
+                  background: rgba(210, 153, 34, 0.1);
+                }
+
+                .citation-badge-calc {
+                  color: #a371f7;
+                  background: rgba(163, 113, 247, 0.1);
+                }
+
+                .citation-title-text {
+                  font-size: 13px;
+                  color: #b0b8c4;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                  min-width: 0;
+                }
+
+                .citation-link {
+                  font-size: 12px;
+                  color: #0a84ff;
+                  text-decoration: none;
+                  white-space: nowrap;
+                  flex-shrink: 0;
+                }
+              }
+
+              .citation-item-attrs {
+                display: flex;
+                align-items: center;
+                flex-wrap: wrap;
+                gap: 4px 10px;
+                padding-left: 0;
+                margin-top: 2px;
+
+                .citation-attr {
+                  font-size: 12px;
+                  color: #8b949e;
+                  white-space: nowrap;
+                }
+
+                .citation-low-score {
+                  color: #d29922;
+                }
+              }
+
+              .citation-snippet {
+                margin-top: 4px;
+                font-size: 12px;
+                color: #8b949e;
+                line-height: 1.5;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+              }
+            }
+          }
+        }
+
+        .download-file-cards {
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+          margin-top: 12px;
+
+          .download-file-card {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 12px 16px;
+            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+            border: 1px solid rgba(0, 84, 130, 0.6);
+            border-radius: 10px;
+            cursor: pointer;
+            transition: all 0.2s ease;
+
+            &:hover {
+              border-color: rgba(10, 132, 255, 0.4);
+              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+            }
+
+            .file-card-info {
+              display: flex;
+              align-items: center;
+              gap: 12px;
+              min-width: 0;
+              flex: 1;
+
+              .file-card-icon {
+                width: 36px;
+                height: 36px;
+                background-image: var(--img-chat-download-icon);
+                background-repeat: no-repeat;
+                background-size: 100% 100%;
+                flex-shrink: 0;
+                opacity: 0.9;
+              }
+
+              .file-card-text {
+                display: flex;
+                flex-direction: column;
+                gap: 2px;
+                min-width: 0;
+
+                .file-card-name {
+                  font-size: 13px;
+                  font-weight: 500;
+                  color: #e0e6ed;
+                  overflow: hidden;
+                  text-overflow: ellipsis;
+                  white-space: nowrap;
+                }
+
+                .file-card-hint {
+                  font-size: 11px;
+                  color: #6e7681;
+                }
+              }
+            }
+
+            .file-card-actions {
+              display: flex;
+              gap: 8px;
+              flex-shrink: 0;
+
+              .file-action-btn {
+                display: inline-flex;
+                align-items: center;
+                gap: 5px;
+                padding: 6px 14px;
+                border-radius: 7px;
+                font-size: 12px;
+                font-weight: 500;
+                cursor: pointer;
+                border: none;
+                transition: all 0.2s ease;
+                white-space: nowrap;
+
+                &.download-btn {
+                  background: rgba(255, 255, 255, 0.08);
+                  color: #c9d1d9;
+                  border: 1px solid rgba(63, 80, 106, 0.5);
+
+                  &:hover {
+                    background: rgba(255, 255, 255, 0.14);
+                    color: #e0e6ed;
+                    border-color: rgba(63, 80, 106, 0.8);
+                  }
+                }
+
+                &.edit-btn {
+                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+                  color: #fff;
+                  border: 1px solid rgba(10, 132, 255, 0.5);
+                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+                  &:hover {
+                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+
+      .user-message {
+        display: flex;
+        flex-wrap: wrap;
+        justify-content: flex-end;
+        align-items: flex-start;
+
+        .message-agent {
+          display: flex;
+          margin-bottom: 8px;
+          margin-left: 5px;
+        }
+
+        .message-body {
+          background: rgba(10, 132, 255, 0.15);
+          padding: 12px;
+          border-radius: 6px 0 6px 6px;
+          max-width: 700px;
+          min-width: 0;
+          overflow: hidden;
+
+          .message-text {
+            font-size: 14px;
+            font-weight: 400;
+            line-height: 1.6;
+            color: #e0e6ed;
+            word-break: break-word;
+            overflow-wrap: break-word;
+
+            :deep(.session-ref-icon) {
+              width: 14px;
+              height: 14px;
+              vertical-align: -2px;
+            }
+
+            :deep(h1, h2, h3, h4, h5, h6) {
+              color: #fff;
+            }
+            :deep(p) {
+              margin: 0;
+            }
+            :deep(code) {
+              background: rgba(0, 0, 0, 0.2);
+              padding: 1px 4px;
+              border-radius: 3px;
+              font-size: 12px;
+            }
+            :deep(pre) {
+              background: rgba(0, 0, 0, 0.2);
+              padding: 8px;
+              border-radius: 4px;
+              overflow-x: auto;
+              margin: 6px 0;
+              white-space: pre-wrap;
+            }
+            :deep(pre code) {
+              background: transparent;
+              padding: 0;
+            }
+          }
+
+          .attached-file-card {
+            margin-top: 10px;
+            background: rgba(255, 255, 255, 0.08);
+            border-radius: 6px;
+            display: flex;
+            align-items: center;
+            gap: 10px;
+            padding: 8px;
+            cursor: pointer;
+            transition: all 0.3s;
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.15);
+            }
+
+            .file-icon-card {
+              width: 32px;
+              height: 32px;
+              background-image: var(--img-chat-attach-icon);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              flex-shrink: 0;
+            }
+
+            .file-info-card {
+              flex: 1;
+              min-width: 0;
+
+              .file-name-card {
+                font-size: 12px;
+                font-weight: 400;
+                line-height: 1.4;
+                color: #e0e6ed;
+                overflow: hidden;
+                text-overflow: ellipsis;
+                white-space: nowrap;
+                margin-bottom: 2px;
+              }
+
+              .file-size-card {
+                font-size: 11px;
+                font-weight: 400;
+                line-height: 1.3;
+                color: #8b949e;
+              }
+            }
+          }
+        }
+      }
+
+      .ai-message,
+      .user-message {
+        .message-actions {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          width: 100%;
+          opacity: 0;
+          transition: opacity 0.2s;
+
+          .action-btn {
+            width: 26px;
+            height: 26px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            border-radius: 4px;
+            color: #8b949e;
+            cursor: pointer;
+            transition: all 0.2s;
+
+            &:hover {
+              color: #e0e6ed;
+              background: rgba(10, 132, 255, 0.15);
+            }
+          }
+
+          .action-time {
+            font-size: 12px;
+            color: #8b949e;
+          }
+
+          .answer-switcher {
+            display: flex;
+            align-items: center;
+            gap: 2px;
+            padding: 0 4px;
+            border-radius: 4px;
+            background: rgba(255, 255, 255, 0.04);
+
+            .answer-nav-btn {
+              width: 22px;
+              height: 22px;
+
+              &.is-disabled {
+                opacity: 0.35;
+                cursor: not-allowed;
+
+                &:hover {
+                  background: transparent;
+                  color: #8b949e;
+                }
+              }
+            }
+
+            .answer-switcher-text {
+              font-size: 12px;
+              color: #8b949e;
+              min-width: 32px;
+              text-align: center;
+              user-select: none;
+            }
+          }
+        }
+
+        &:hover .message-actions {
+          opacity: 1;
+        }
+      }
+
+      .ai-message .message-actions {
+        padding: 0 0 0 10px;
+      }
+
+      .user-message .message-actions {
+        padding: 4px 0;
+        justify-content: flex-end;
+      }
+    }
+  }
+
+  .message-edit-textarea {
+    width: 100%;
+    min-width: 500px;
+    min-height: 80px;
+    padding: 8px 12px;
+    font-size: 14px;
+    line-height: 1.6;
+    color: #e0e6ed;
+    background: rgba(0, 0, 0, 0.2);
+    border: 1px solid rgba(10, 132, 255, 0.5);
+    border-radius: 6px;
+    outline: none;
+    resize: vertical;
+    font-family: inherit;
+
+    &:focus {
+      border-color: #0a84ff;
+      background: rgba(0, 0, 0, 0.3);
+    }
+  }
+
+  .message-edit-actions {
+    display: flex;
+    gap: 8px;
+    margin-top: 8px;
+    justify-content: flex-end;
+
+    .message-edit-btn {
+      padding: 3px 14px;
+      font-size: 12px;
+      border-radius: 4px;
+      cursor: pointer;
+      border: 1px solid transparent;
+      transition: all 0.2s;
+
+      &.confirm {
+        color: #fff;
+        background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
+        border-color: rgba(10, 132, 255, 0.5);
+
+        &:hover {
+          background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+        }
+      }
+
+      &.cancel {
+        color: #8b949e;
+        background: rgba(255, 255, 255, 0.06);
+        border-color: rgba(63, 80, 106, 0.4);
+
+        &:hover {
+          color: #c9d1d9;
+          background: rgba(255, 255, 255, 0.1);
+        }
+      }
+    }
+  }
+
+  .scroll-to-bottom {
+    position: absolute;
+    bottom: 20px;
+    left: 50%;
+    transform: translateX(-50%);
+    z-index: 10;
+
+    .scroll-to-bottom-icon {
+      width: 40px;
+      height: 40px;
+      border-radius: 50%;
+      background: rgba(255, 255, 255, 0.15);
+      backdrop-filter: blur(4px);
+      background-image: var(--img-chat-scroll-bottom);
+      background-repeat: no-repeat;
+      background-size: 20px 20px;
+      background-position: center;
+      cursor: pointer;
+      transition: all 0.2s;
+
+      &:hover {
+        background-color: rgba(255, 255, 255, 0.25);
+      }
+    }
+  }
+</style>

+ 211 - 0
src/views/ventAI/manageAssistent/components/chat/CitationsCard.vue

@@ -0,0 +1,211 @@
+<template>
+  <!-- 数据来源卡片:引用条目可折叠,脚注点击靠父组件的 citation-{消息下标}-{序号} 锚点定位 -->
+  <div class="citations-card">
+    <div v-if="message.citations?.model_only" class="citations-header citations-header-simple">
+      <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
+    </div>
+    <template v-else-if="message.citations?.items?.length">
+      <div class="citations-header" @click="emit('update:collapsed', !collapsed)">
+        <div class="citations-header-left">
+          <span class="citations-title">数据来源</span>
+          <span class="citations-count">{{ message.citations.items.length }}</span>
+        </div>
+        <div :class="['thinking-arrow', { expanded: !collapsed }]">
+          <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+        </div>
+      </div>
+      <div v-show="!collapsed" class="citations-body">
+        <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
+          <div class="citation-item-row">
+            <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
+            <span class="citation-title-text">{{ item.title }}</span>
+            <span v-if="item.search_url" class="citation-link">知识库检索</span>
+            <span v-if="item.url" class="citation-link">链接</span>
+          </div>
+          <div class="citation-item-attrs">
+            <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
+            <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
+          </div>
+          <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
+        </div>
+      </div>
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 数据来源(citations)卡片。折叠态由父组件按消息下标持有,这里受控显示。
+   * index 需要透传:条目上的 citation-{index}-{id} 锚点供正文脚注 [^n] 定位使用。
+   */
+  defineProps<{
+    message: Message;
+    index: number;
+    collapsed: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:collapsed', value: boolean): void;
+  }>();
+</script>
+
+<style scoped lang="less">
+  .citations-card {
+    overflow: hidden;
+
+    .citations-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 14px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      cursor: pointer;
+      user-select: none;
+      transition: background 0.2s;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.08);
+      }
+
+      &.citations-header-simple {
+        cursor: default;
+
+        &:hover {
+          background: transparent;
+        }
+      }
+
+      .citations-header-left {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+
+      .citations-title {
+        font-size: 13px;
+        font-weight: 500;
+        color: #8b949e;
+      }
+
+      .citations-count {
+        font-size: 12px;
+        color: #8b949e;
+        background: rgba(10, 132, 255, 0.1);
+        padding: 1px 6px;
+        border-radius: 10px;
+      }
+
+      .thinking-arrow {
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+    }
+
+    .citations-body {
+      padding: 4px 14px;
+
+      .citation-item {
+        padding: 6px 0;
+        border-bottom: 1px solid rgba(63, 80, 106, 0.15);
+
+        &:last-child {
+          border-bottom: none;
+        }
+
+        &.citation-highlight {
+          background: rgba(10, 132, 255, 0.08);
+          border-radius: 4px;
+          padding: 6px 8px;
+          margin: 0 -8px;
+        }
+
+        .citation-item-row {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          line-height: 1.5;
+
+          .citation-badge {
+            font-size: 12px;
+            color: #79c0ff;
+            font-family: monospace;
+            background: rgba(10, 132, 255, 0.1);
+            padding: 1px 5px;
+            border-radius: 3px;
+            white-space: nowrap;
+            flex-shrink: 0;
+          }
+
+          .citation-badge-knowledge_base {
+            color: #3fb950;
+            background: rgba(46, 160, 67, 0.1);
+          }
+
+          .citation-badge-web {
+            color: #d29922;
+            background: rgba(210, 153, 34, 0.1);
+          }
+
+          .citation-badge-calc {
+            color: #a371f7;
+            background: rgba(163, 113, 247, 0.1);
+          }
+
+          .citation-title-text {
+            font-size: 13px;
+            color: #b0b8c4;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            min-width: 0;
+          }
+
+          .citation-link {
+            font-size: 12px;
+            color: #0a84ff;
+            text-decoration: none;
+            white-space: nowrap;
+            flex-shrink: 0;
+          }
+        }
+
+        .citation-item-attrs {
+          display: flex;
+          align-items: center;
+          flex-wrap: wrap;
+          gap: 4px 10px;
+          padding-left: 0;
+          margin-top: 2px;
+
+          .citation-attr {
+            font-size: 12px;
+            color: #8b949e;
+            white-space: nowrap;
+          }
+
+          .citation-low-score {
+            color: #d29922;
+          }
+        }
+
+        .citation-snippet {
+          margin-top: 4px;
+          font-size: 12px;
+          color: #8b949e;
+          line-height: 1.5;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+</style>

+ 438 - 0
src/views/ventAI/manageAssistent/components/chat/ConversationNav.vue

@@ -0,0 +1,438 @@
+<template>
+  <!-- 左侧对话缩略导航:每条横线一轮问答,悬浮显示问/答摘要,滚动时高亮当前阅读的一组 -->
+  <div
+    v-if="qaPairs.length > 0"
+    class="conversation-nav"
+    ref="navRef"
+    @mouseenter="onNavMove"
+    @mousemove="onNavMove"
+    @mouseleave="onNavLeave"
+    @scroll="onNavScroll"
+  >
+    <div
+      v-for="(pair, pIdx) in qaPairs"
+      :key="pIdx"
+      :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
+      :style="{ transform: `scaleX(${navLineScale(pIdx)})` }"
+      @click="scrollToPair(pair)"
+    ></div>
+  </div>
+  <Transition name="tooltip-fade">
+    <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
+      <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
+      <div v-if="hoveredPair.question" class="tooltip-row">
+        <span class="tooltip-label tooltip-label-q">问</span>
+        <span class="tooltip-text">{{ hoveredPair.question }}</span>
+      </div>
+      <div v-if="hoveredPair.answer" class="tooltip-row">
+        <span class="tooltip-label tooltip-label-a">答</span>
+        <span class="tooltip-text">{{ hoveredPair.answer }}</span>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+
+  /**
+   * 左侧对话缩略导航。
+   *
+   * 消息容器由父组件持有(滚动、缩放换算等逻辑在那边),这里只通过 getContainer / scrollToMessage
+   * 两个回调拿到容器能力,导航自身的状态(悬浮、提示位置、滚动跟随)全部内聚在本组件。
+   */
+  const props = defineProps<{
+    messages: Message[];
+    isAtBottom: boolean;
+    /** 取消息滚动容器(挂载时机原因由父组件提供,不要在本组件内查询 DOM) */
+    getContainer: () => HTMLElement | null | undefined;
+    /** 按消息下标滚动容器(复用父组件的隐藏消息回退逻辑) */
+    scrollToMessage: (index: number, block?: 'start' | 'center') => void;
+  }>();
+
+  interface QaPair {
+    startIndex: number;
+    question: string;
+    answer: string;
+  }
+
+  const getContainer = () => props.getContainer?.() ?? null;
+
+  const toPlainText = (md: string): string =>
+    md
+      .replace(/```[\s\S]*?```/g, ' [代码] ')
+      .replace(/`[^`]*`/g, ' ')
+      .replace(/<[^>]+>/g, '')
+      .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
+      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+      .replace(/^#{1,6}\s*/gm, '')
+      .replace(/[*_~>|]/g, '')
+      .replace(/\s+/g, ' ')
+      .trim();
+
+  const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
+
+  // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
+  const qaPairs = computed<QaPair[]>(() => {
+    const pairs: QaPair[] = [];
+    let current: QaPair | null = null;
+    props.messages.forEach((msg, index) => {
+      if (msg.type === 'user') {
+        if (current) pairs.push(current);
+        current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
+      } else {
+        if (!current) current = { startIndex: index, question: '', answer: '' };
+        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
+        if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
+      }
+    });
+    if (current) pairs.push(current);
+    return pairs;
+  });
+
+  const navRef = ref<HTMLElement>();
+  const tooltipRef = ref<HTMLElement>();
+  const hoveredPair = ref<QaPair | null>(null);
+  const tooltipTop = ref(0);
+  const tooltipArrowTop = ref(0);
+  const activeMessage = ref<Message | null>(null);
+
+  // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
+  const navMouseY = ref<number | null>(null);
+  // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
+  const lineCenters = ref<number[]>([]);
+
+  const updateLineCenters = () => {
+    const nav = navRef.value;
+    if (!nav) {
+      lineCenters.value = [];
+      return;
+    }
+    const centers: number[] = [];
+    nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
+    lineCenters.value = centers;
+  };
+
+  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形;
+  // 用 scaleX 缩放(合成器动画)替代逐帧改 width(逐帧重排),滚动/移动时不再卡顿
+  const navLineScale = (pIdx: number): number => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return 1;
+    const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
+    return 1 + 0.8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
+  };
+
+  const nearestLineIndex = computed(() => {
+    const mouseY = navMouseY.value;
+    if (mouseY === null) return -1;
+    let best = -1;
+    let bestDist = Infinity;
+    lineCenters.value.forEach((center, i) => {
+      const dist = Math.abs(center - mouseY);
+      if (dist < bestDist) {
+        bestDist = dist;
+        best = i;
+      }
+    });
+    return best;
+  });
+
+  let lastTooltipIndex = -1;
+
+  const applyTooltip = async (pIdx: number) => {
+    const pair = qaPairs.value[pIdx];
+    if (!pair) {
+      hoveredPair.value = null;
+      return;
+    }
+    hoveredPair.value = pair;
+    await nextTick();
+    const tooltip = tooltipRef.value;
+    const nav = navRef.value;
+    const container = nav?.parentElement as HTMLElement | null;
+    if (!tooltip || !nav || !container) return;
+    const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
+    const tooltipH = tooltip.offsetHeight;
+    const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
+    tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
+    tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
+  };
+
+  let navMoveRaf = 0;
+  let lastNavMoveEvent: MouseEvent | null = null;
+
+  const applyNavMove = (e: MouseEvent) => {
+    const nav = navRef.value;
+    if (!nav) return;
+    // 横线数量变化(新增问答/首次悬浮)时重建坐标
+    if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
+    // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
+    // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
+    const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
+    navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
+    const nearest = nearestLineIndex.value;
+    if (nearest >= 0 && nearest !== lastTooltipIndex) {
+      lastTooltipIndex = nearest;
+      applyTooltip(nearest);
+    }
+  };
+
+  const onNavMove = (e: MouseEvent) => {
+    // 鼠标移动高频触发,合并到每帧处理一次,避免频繁重排造成卡顿
+    lastNavMoveEvent = e;
+    if (navMoveRaf) return;
+    navMoveRaf = requestAnimationFrame(() => {
+      navMoveRaf = 0;
+      if (lastNavMoveEvent) applyNavMove(lastNavMoveEvent);
+    });
+  };
+
+  const onNavLeave = () => {
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+      lastNavMoveEvent = null;
+    }
+    navMouseY.value = null;
+    hoveredPair.value = null;
+    lastTooltipIndex = -1;
+  };
+
+  let navScrollRaf = 0;
+  const onNavScroll = () => {
+    // 导航列自身滚动时同样按帧合并,保持滚动过程顺滑
+    if (navScrollRaf) return;
+    navScrollRaf = requestAnimationFrame(() => {
+      navScrollRaf = 0;
+      updateLineCenters();
+      if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
+    });
+  };
+
+  const scrollToPair = (pair: QaPair) => {
+    activeMessage.value = props.messages[pair.startIndex];
+    props.scrollToMessage(pair.startIndex, 'start');
+    const pIdx = qaPairs.value.indexOf(pair);
+    if (pIdx >= 0) ensureNavLineVisible(pIdx);
+    const wrappers = getContainer()?.querySelectorAll('.message-wrapper');
+    const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
+    el?.classList.add('nav-located-highlight');
+    setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
+  };
+
+  // 选中线保持在导航列可视区内(长对话时导航列自身可滚动):
+  // 仅在本列内部调整 scrollTop,不调用 scrollIntoView 以免联动祖先容器
+  const ensureNavLineVisible = (pIdx: number) => {
+    const nav = navRef.value;
+    const line = nav?.querySelectorAll<HTMLElement>('.nav-line')[pIdx];
+    if (!nav || !line) return;
+    const margin = 12;
+    const top = line.offsetTop;
+    const bottom = top + line.offsetHeight;
+    if (top < nav.scrollTop + margin) {
+      nav.scrollTop = Math.max(0, top - margin);
+    } else if (bottom > nav.scrollTop + nav.clientHeight - margin) {
+      nav.scrollTop = bottom - nav.clientHeight + margin;
+    }
+  };
+
+  // ===== 滚动跟随:消息滚动时,导航选中项实时跟随当前正在阅读的一组问答 =====
+  let spyRaf = 0;
+  const updateActivePairByScroll = () => {
+    const pairs = qaPairs.value;
+    const container = getContainer();
+    if (!pairs.length || !container) {
+      activeMessage.value = null;
+      return;
+    }
+    const wrappers = container.querySelectorAll('.message-wrapper');
+    // 以视口上部 1/3 处为判定线:起始位置越过判定线的一组即为当前阅读组;触底时强制选中最后一组
+    const mark = container.scrollTop + container.clientHeight * 0.35;
+    let current = 0;
+    for (let p = 0; p < pairs.length; p++) {
+      const el = wrappers[pairs[p].startIndex] as HTMLElement | undefined;
+      if (!el) continue;
+      if (el.offsetTop <= mark) current = p;
+      else break;
+    }
+    if (props.isAtBottom) current = pairs.length - 1;
+    const target = props.messages[pairs[current].startIndex] ?? null;
+    if (activeMessage.value !== target) {
+      activeMessage.value = target;
+      // 选中项变化时把对应横线滚入导航列可视区,跟随才真正可见
+      ensureNavLineVisible(current);
+    }
+  };
+
+  // 滚动事件高频触发,用 rAF 合并到每帧最多计算一次
+  const scheduleSpy = () => {
+    if (spyRaf) return;
+    spyRaf = requestAnimationFrame(() => {
+      spyRaf = 0;
+      updateActivePairByScroll();
+    });
+  };
+
+  // 消息增减(新问答、加载历史、切换任务)后同步一次选中状态
+  watch(
+    () => qaPairs.value.length,
+    () => {
+      nextTick(updateActivePairByScroll);
+    },
+    { immediate: true }
+  );
+
+  onBeforeUnmount(() => {
+    if (spyRaf) {
+      cancelAnimationFrame(spyRaf);
+      spyRaf = 0;
+    }
+    if (navMoveRaf) {
+      cancelAnimationFrame(navMoveRaf);
+      navMoveRaf = 0;
+    }
+    if (navScrollRaf) {
+      cancelAnimationFrame(navScrollRaf);
+      navScrollRaf = 0;
+    }
+  });
+
+  // 父组件在消息容器滚动时调用(容器由父组件监听,挂载时机原因本组件不去绑定)
+  const onContainerScroll = () => {
+    scheduleSpy();
+  };
+
+  defineExpose({ onContainerScroll });
+</script>
+
+<style scoped lang="less">
+  .conversation-nav {
+    position: relative;
+    width: 28px;
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    overflow-y: auto;
+    padding: 6px 0;
+    scrollbar-width: none;
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    .nav-line {
+      width: 10px;
+      height: 3px;
+      border-radius: 2px;
+      background: rgba(139, 148, 158, 0.35);
+      margin: 5px 0;
+      flex-shrink: 0;
+      cursor: pointer;
+      transform-origin: center;
+      transition:
+        transform 0.18s cubic-bezier(0.33, 1, 0.68, 1),
+        background 0.2s,
+        box-shadow 0.2s;
+
+      &:first-child {
+        margin-top: auto;
+      }
+
+      &:last-child {
+        margin-bottom: auto;
+      }
+
+      &.nav-line-hot {
+        background: linear-gradient(90deg, rgba(10, 132, 255, 0.6) 0%, #0a84ff 100%);
+      }
+
+      &.active {
+        background: linear-gradient(90deg, #2f9bff 0%, #0a84ff 100%);
+        box-shadow: 0 0 6px rgba(10, 132, 255, 0.45);
+      }
+    }
+  }
+
+  .conversation-tooltip {
+    position: absolute;
+    left: 36px;
+    width: 260px;
+    background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
+    border: 1px solid #1a4a6f;
+    border-radius: 10px;
+    box-shadow:
+      0 8px 24px rgba(0, 0, 0, 0.5),
+      0 0 14px rgba(10, 132, 255, 0.08);
+    padding: 10px 12px;
+    z-index: 20;
+    pointer-events: none;
+
+    .tooltip-arrow {
+      position: absolute;
+      left: -6px;
+      width: 10px;
+      height: 10px;
+      transform: rotate(45deg);
+      background: #0d2137;
+      border-left: 1px solid #1a4a6f;
+      border-bottom: 1px solid #1a4a6f;
+      border-top-left-radius: 2px;
+    }
+
+    .tooltip-row {
+      display: flex;
+      align-items: flex-start;
+      gap: 8px;
+      font-size: 12px;
+      line-height: 1.6;
+
+      & + .tooltip-row {
+        margin-top: 8px;
+        padding-top: 8px;
+        border-top: 1px solid rgba(63, 80, 106, 0.35);
+      }
+
+      .tooltip-label {
+        flex-shrink: 0;
+        width: 16px;
+        height: 16px;
+        line-height: 16px;
+        margin-top: 2px;
+        text-align: center;
+        border-radius: 4px;
+        font-size: 11px;
+        font-weight: 500;
+
+        &-q {
+          color: #79c0ff;
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &-a {
+          color: #a9b9c9;
+          background: rgba(139, 148, 158, 0.15);
+        }
+      }
+
+      .tooltip-text {
+        color: #d7dee6;
+        word-break: break-word;
+      }
+    }
+  }
+
+  .tooltip-fade-enter-active,
+  .tooltip-fade-leave-active {
+    transition:
+      opacity 0.18s ease,
+      transform 0.18s ease;
+  }
+
+  .tooltip-fade-enter-from,
+  .tooltip-fade-leave-to {
+    opacity: 0;
+    transform: translateX(-4px);
+  }
+</style>

+ 212 - 0
src/views/ventAI/manageAssistent/components/chat/DownloadFiles.vue

@@ -0,0 +1,212 @@
+<template>
+  <!-- AI 回答里的下载文件卡片:点击预览,支持下载与(文本类)编辑 -->
+  <div class="download-file-cards">
+    <div v-for="(file, fIdx) in files" :key="fIdx" class="download-file-card" @click="handlePreviewFile(file)">
+      <div class="file-card-info">
+        <div class="file-card-icon" :style="{ backgroundImage: getFileIconVar(file.filename) }"></div>
+        <div class="file-card-text">
+          <span class="file-card-name">{{ file.filename }}</span>
+          <span class="file-card-hint">点击预览文件</span>
+        </div>
+      </div>
+      <div class="file-card-actions">
+        <button class="file-action-btn download-btn" @click.stop="handleDownloadFile(file.url, file.filename)">
+          <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
+          <span>下载</span>
+        </button>
+        <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click.stop="handleEditFile(file.url, file.filename)">
+          <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+          <span>编辑</span>
+        </button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { message } from 'ant-design-vue';
+  import { getToken } from '/@/utils/auth/index';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+  import { fetchPreviewFile, getCssUrl, getFileIconVar, isMdFile, isTextFile } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 下载文件卡片列表。取内容/预览的副作用在本组件内完成,
+   * 需要打开右侧面板时通过事件交回父组件(面板状态由父组件统一持有)。
+   */
+  defineProps<{
+    files: Array<{ url: string; filename: string }>;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'filePreview', file: AttachedFile): void;
+    (e: 'openFilePreview', file: AttachedFile): void;
+  }>();
+
+  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
+
+  const handleDownloadFile = (url: string, filename: string) => {
+    if (!url) return;
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+  };
+
+  const handleEditFile = async (url: string, filename: string) => {
+    try {
+      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+      const content = await resp.text();
+      const ext = filename.split('.').pop()?.toLowerCase() || '';
+      const mimeMap: Record<string, string> = {
+        md: 'text/markdown',
+        txt: 'text/plain',
+        json: 'application/json',
+        xml: 'text/xml',
+        yaml: 'text/yaml',
+        yml: 'text/yaml',
+        csv: 'text/csv',
+      };
+      const file: AttachedFile = {
+        id: `report-${url}`,
+        name: filename,
+        size: content.length,
+        type: mimeMap[ext] || 'text/plain',
+        content,
+        uploadTime: new Date().toISOString(),
+      };
+      emit('openFilePreview', file);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('获取文件内容失败');
+    }
+  };
+
+  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
+  const handlePreviewFile = async (file: { url: string; filename: string }) => {
+    if (!file.url) return;
+    try {
+      const previewFile = await fetchPreviewFile(file.url, file.filename);
+      if (!previewFile) {
+        // 不支持的格式回退为直接下载
+        handleDownloadFile(file.url, file.filename);
+        return;
+      }
+      emit('filePreview', previewFile);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('文件预览失败,请尝试下载查看');
+    }
+  };
+</script>
+
+<style scoped lang="less">
+  .download-file-cards {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    margin-top: 12px;
+
+    .download-file-card {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 12px 16px;
+      background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
+      border: 1px solid rgba(0, 84, 130, 0.6);
+      border-radius: 10px;
+      cursor: pointer;
+      transition: all 0.2s ease;
+
+      &:hover {
+        border-color: rgba(10, 132, 255, 0.4);
+        box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
+      }
+
+      .file-card-info {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+        min-width: 0;
+        flex: 1;
+
+        .file-card-icon {
+          width: 36px;
+          height: 36px;
+          background-image: var(--img-chat-download-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+          opacity: 0.9;
+        }
+
+        .file-card-text {
+          display: flex;
+          flex-direction: column;
+          gap: 2px;
+          min-width: 0;
+
+          .file-card-name {
+            font-size: 13px;
+            font-weight: 500;
+            color: #e0e6ed;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+          }
+
+          .file-card-hint {
+            font-size: 11px;
+            color: #6e7681;
+          }
+        }
+      }
+
+      .file-card-actions {
+        display: flex;
+        gap: 8px;
+        flex-shrink: 0;
+
+        .file-action-btn {
+          display: inline-flex;
+          align-items: center;
+          gap: 5px;
+          padding: 6px 14px;
+          border-radius: 7px;
+          font-size: 12px;
+          font-weight: 500;
+          cursor: pointer;
+          border: none;
+          transition: all 0.2s ease;
+          white-space: nowrap;
+
+          &.download-btn {
+            background: rgba(255, 255, 255, 0.08);
+            color: #c9d1d9;
+            border: 1px solid rgba(63, 80, 106, 0.5);
+
+            &:hover {
+              background: rgba(255, 255, 255, 0.14);
+              color: #e0e6ed;
+              border-color: rgba(63, 80, 106, 0.8);
+            }
+          }
+
+          &.edit-btn {
+            background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
+            color: #fff;
+            border: 1px solid rgba(10, 132, 255, 0.5);
+            box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
+
+            &:hover {
+              background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
+              box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
+            }
+          }
+        }
+      }
+    }
+  }
+</style>

+ 242 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ContextRing.vue

@@ -0,0 +1,242 @@
+<template>
+  <!-- 上下文容量环:悬浮展开明细,点击也可切换 -->
+  <div class="btn-context-wrapper" ref="wrapperRef" @mouseenter="showPopup = true" @mouseleave="showPopup = false">
+    <div class="btn-context" @click="togglePopup">
+      <svg class="context-ring" viewBox="0 0 32 32">
+        <circle class="context-ring-bg" cx="16" cy="16" r="13" />
+        <circle
+          class="context-ring-fill"
+          cx="16"
+          cy="16"
+          r="13"
+          :stroke-dasharray="circumference"
+          :stroke-dashoffset="offset"
+          :style="{ stroke: color }"
+        />
+      </svg>
+      <span class="context-percent">{{ percent }}%</span>
+    </div>
+    <div v-if="showPopup" class="context-popup" @click.stop>
+      <div class="context-popup-title">上下文容量</div>
+      <div class="context-popup-info">
+        <span class="context-used">{{ displayUsed }}</span>
+        <span class="context-sep">/</span>
+        <span class="context-max">{{ displayMax }}</span>
+        <span class="context-pct">({{ percent }}%)</span>
+      </div>
+      <div class="context-popup-bar-track">
+        <div class="context-popup-bar-fill" :style="{ width: percent + '%', background: color }"></div>
+      </div>
+      <div class="context-breakdown">
+        <div class="breakdown-item">
+          <span class="breakdown-label">历史消息</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.messages) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">MCP工具</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.mcp) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">技能描述</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.skills) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">系统提示词</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.system_prompt) }}</span>
+        </div>
+        <div class="breakdown-item">
+          <span class="breakdown-label">其他</span>
+          <span class="breakdown-value">{{ formatTokens(breakdown.other) }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import { formatTokens } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /** 上下文容量环 + 悬浮明细。数值全部来自父组件(会话维度),弹层开关是本组件内部状态。 */
+  const props = withDefaults(
+    defineProps<{
+      used?: number;
+      max?: number;
+      percent?: number;
+      breakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
+    }>(),
+    {
+      used: 0,
+      max: 1000000,
+      percent: 0,
+      breakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
+    }
+  );
+
+  const wrapperRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  const circumference = computed(() => 2 * Math.PI * 13);
+  const offset = computed(() => circumference.value * (1 - props.percent / 100));
+
+  const color = computed(() => {
+    if (props.percent >= 90) return '#ff4d4f';
+    if (props.percent >= 70) return '#faad14';
+    return '#52c41a';
+  });
+
+  const displayUsed = computed(() => (props.used >= 10000 ? (props.used / 10000).toFixed(1) + '万' : props.used.toLocaleString()));
+  const displayMax = computed(() => (props.max >= 10000 ? (props.max / 10000).toFixed(0) + '万' : props.max.toLocaleString()));
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  .btn-context-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-context {
+    width: 30px;
+    height: 30px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    position: relative;
+    border-radius: 6px;
+    transition: background 0.2s;
+
+    &:hover {
+      background: rgba(10, 132, 255, 0.1);
+    }
+
+    .context-ring {
+      width: 24px;
+      height: 24px;
+      transform: rotate(-90deg);
+
+      .context-ring-bg {
+        fill: none;
+        stroke: rgba(255, 255, 255, 0.1);
+        stroke-width: 3;
+      }
+
+      .context-ring-fill {
+        fill: none;
+        stroke-width: 3;
+        stroke-linecap: round;
+        transition: stroke-dashoffset 0.3s ease;
+      }
+    }
+
+    .context-percent {
+      position: absolute;
+      font-size: 8px;
+      color: #e0e6ed;
+      font-weight: 500;
+      line-height: 1;
+    }
+  }
+  .context-popup {
+    position: absolute;
+    bottom: 42px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    padding: 14px 16px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: -12px;
+      left: 0;
+      right: 0;
+      height: 12px;
+    }
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .context-popup-title {
+      font-size: 13px;
+      color: #8b949e;
+      margin-bottom: 8px;
+    }
+
+    .context-popup-info {
+      display: flex;
+      align-items: baseline;
+      gap: 2px;
+      margin-bottom: 10px;
+
+      .context-used {
+        font-size: 18px;
+        font-weight: 600;
+        color: #e0e6ed;
+      }
+
+      .context-sep {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .context-max {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .context-pct {
+        font-size: 13px;
+        color: #8b949e;
+      }
+    }
+
+    .context-popup-bar-track {
+      width: 100%;
+      height: 6px;
+      background: rgba(255, 255, 255, 0.08);
+      border-radius: 3px;
+      overflow: hidden;
+
+      .context-popup-bar-fill {
+        height: 100%;
+        border-radius: 3px;
+        transition: width 0.3s ease;
+      }
+    }
+
+    .context-breakdown {
+      margin-top: 10px;
+      padding-top: 10px;
+      border-top: 1px solid rgba(255, 255, 255, 0.08);
+
+      .breakdown-item {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        padding: 3px 0;
+
+        .breakdown-label {
+          font-size: 12px;
+          color: #8b949e;
+        }
+
+        .breakdown-value {
+          font-size: 12px;
+          color: #e0e6ed;
+        }
+      }
+    }
+  }
+</style>

+ 209 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ModePopup.vue

@@ -0,0 +1,209 @@
+<template>
+  <!-- 模式选择器:计划模式 / 完全访问 -->
+  <div class="btn-edit-mode-wrapper" ref="wrapperRef">
+    <a-tooltip title="切换模式">
+      <div class="btn-edit-mode" @click="togglePopup">
+        <div class="btn-icon" :class="`mode-icon-${selected.key}`"></div>
+        <span class="btn-text">{{ selected.label }}</span>
+        <div class="btn-arrow"></div>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="edit-mode-popup">
+      <div v-for="item in options" :key="item.key" class="edit-mode-item" :class="{ active: selected.key === item.key }" @click="selectItem(item)">
+        <div class="edit-mode-item-icon" :class="`mode-icon-${item.key}`"></div>
+        <div class="edit-mode-item-main">
+          <span class="edit-mode-item-label">{{ item.label }}</span>
+          <span class="edit-mode-item-desc">{{ item.desc }}</span>
+        </div>
+        <div class="edit-mode-check" :class="{ visible: selected.key === item.key }"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface EditModeOption {
+    key: string;
+    label: string;
+    desc: string;
+  }
+
+  /**
+   * 模式选择器。选中项是本组件状态(仅供展示),切换时通过 change 事件通知父组件:
+   * 父组件持有真正用于请求的 editMode,并负责调接口持久化会话模式。
+   */
+  const props = withDefaults(defineProps<{ initialMode?: string }>(), { initialMode: 'full' });
+  const emit = defineEmits<{
+    (e: 'change', key: string): void;
+  }>();
+
+  const options: EditModeOption[] = [
+    // { key: 'confirm', label: '变更前确认', desc: '改文件前先问我' },
+    // { key: 'auto', label: '自动编辑', desc: '自动编辑文件' },
+    { key: 'plan', label: '计划模式', desc: '编辑前先出计划' },
+    { key: 'full', label: '完全访问', desc: '减少确认次数' },
+  ];
+
+  const selected = ref<EditModeOption>(options[1]);
+  const showPopup = ref(false);
+  const wrapperRef = ref<HTMLElement>();
+
+  watch(
+    () => props.initialMode,
+    (mode) => {
+      const found = options.find((opt) => opt.key === mode);
+      if (found) selected.value = found;
+    },
+    { immediate: true }
+  );
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  const selectItem = (item: EditModeOption) => {
+    selected.value = item;
+    showPopup.value = false;
+    emit('change', item.key);
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  /* 以下 .btn-icon/.btn-text/.btn-arrow/.mode-icon-* 与原输入区内联按钮原语一致:
+     按钮下移到组件后,父组件的 scoped 样式不再作用于这里的后代元素,故随组件复制一份。 */
+  .btn-icon {
+    width: 14px;
+    height: 14px;
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    flex-shrink: 0;
+  }
+  .btn-text {
+    font-size: 12px;
+    color: #e0e6ed;
+    white-space: nowrap;
+  }
+  .btn-arrow {
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 4px solid #8b949e;
+    flex-shrink: 0;
+  }
+  .mode-icon-confirm {
+    background-image: var(--img-chat-mode-confirm-icon);
+  }
+  .mode-icon-auto {
+    background-image: var(--img-chat-mode-auto-icon);
+  }
+  .mode-icon-plan {
+    background-image: var(--img-chat-mode-plan-icon);
+  }
+  .mode-icon-full {
+    background-image: var(--img-chat-mode-full-icon);
+  }
+
+  .btn-edit-mode-wrapper {
+    position: relative;
+  }
+  .btn-edit-mode {
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      border-color: #3a8fd4;
+      background: rgba(10, 132, 255, 0.1);
+    }
+  }
+  .edit-mode-popup {
+    position: absolute;
+    bottom: 38px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .edit-mode-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 12px;
+      cursor: pointer;
+      transition: background 0.2s;
+      border-radius: 6px;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.15);
+      }
+
+      &.active {
+        background: rgba(10, 132, 255, 0.1);
+      }
+
+      .edit-mode-item-icon {
+        width: 18px;
+        height: 18px;
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        margin-right: 10px;
+      }
+
+      .edit-mode-item-main {
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+
+        .edit-mode-item-label {
+          font-size: 13px;
+          color: #e0e6ed;
+          white-space: nowrap;
+        }
+
+        .edit-mode-item-desc {
+          font-size: 11px;
+          color: #8b949e;
+          white-space: nowrap;
+        }
+      }
+
+      .edit-mode-check {
+        width: 16px;
+        height: 16px;
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        visibility: hidden;
+
+        &.visible {
+          visibility: visible;
+        }
+
+        &::after {
+          content: '✓';
+          font-size: 12px;
+          color: #3a8fd4;
+        }
+      }
+    }
+  }
+</style>

+ 192 - 0
src/views/ventAI/manageAssistent/components/chat/Input/SessionPopup.vue

@@ -0,0 +1,192 @@
+<template>
+  <!-- 会话引用选择弹层:由父组件用 # 触发,多选上限 5;已选状态与高亮项都由父组件传入 -->
+  <div ref="rootEl" class="session-popup" @click.stop @pointerdown.stop>
+    <div class="session-popup-header">
+      <span>选择会话</span>
+      <span class="session-count">{{ selectedCount }}/5</span>
+    </div>
+    <div class="session-list">
+      <div
+        v-for="(task, index) in list"
+        :key="task.id"
+        class="session-item"
+        :class="{
+          active: index === activeIndex,
+          selected: isSelected(task.id),
+          disabled: (selectedCount >= 5 && !isSelected(task.id)) || task.id === currentTaskId,
+        }"
+        @click="emit('select', task)"
+      >
+        <div class="session-checkbox" :class="{ checked: isSelected(task.id) }">
+          <span v-if="isSelected(task.id)" class="check-mark">✓</span>
+        </div>
+        <div class="session-item-icon"></div>
+        <span class="session-item-name">{{ task.name }}</span>
+      </div>
+      <div v-if="list.length === 0" class="session-empty">暂无匹配会话</div>
+    </div>
+    <div class="session-popup-footer">
+      <span v-if="selectedCount >= 5" class="max-hint">最多选择5个会话</span>
+      <span v-else>点击选择会话(最多5个)</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface SessionOption {
+    id: string;
+    name: string;
+  }
+
+  const props = defineProps<{
+    list: SessionOption[];
+    activeIndex: number;
+    selectedIds: string[];
+    currentTaskId: string;
+  }>();
+
+  const emit = defineEmits<{ (e: 'select', task: SessionOption): void; (e: 'close'): void }>();
+
+  const selectedCount = computed(() => props.selectedIds.length);
+
+  const isSelected = (id: string) => props.selectedIds.includes(id);
+
+  // 组件自己持有根元素,外部点击由这里判定后回调父组件
+  const rootEl = ref<HTMLElement>();
+  onClickOutside(rootEl, () => emit('close'));
+</script>
+
+<style scoped lang="less">
+  .session-popup {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    right: 0;
+    margin-bottom: 8px;
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    max-height: 300px;
+    display: flex;
+    flex-direction: column;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .session-popup-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 10px 14px 8px;
+      font-size: 13px;
+      color: #e0e6ed;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .session-count {
+        font-size: 12px;
+        color: #3a8fd4;
+        font-weight: 500;
+      }
+    }
+
+    .session-list {
+      flex: 1;
+      overflow-y: auto;
+      padding: 6px;
+
+      .session-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: background 0.2s;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &.active {
+          background: rgba(10, 132, 255, 0.2);
+        }
+
+        &.selected {
+          background: rgba(10, 132, 255, 0.12);
+        }
+
+        &.disabled {
+          opacity: 0.4;
+          cursor: not-allowed;
+
+          &:hover {
+            background: transparent;
+          }
+        }
+
+        .session-checkbox {
+          width: 16px;
+          height: 16px;
+          border: 1.5px solid rgba(255, 255, 255, 0.25);
+          border-radius: 3px;
+          flex-shrink: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          transition: all 0.2s;
+
+          &.checked {
+            background: #3a8fd4;
+            border-color: #3a8fd4;
+          }
+
+          .check-mark {
+            font-size: 11px;
+            color: #fff;
+            line-height: 1;
+          }
+        }
+
+        .session-item-icon {
+          width: 18px;
+          height: 18px;
+          background-image: var(--img-chat-popup-session-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .session-item-name {
+          font-size: 13px;
+          color: #e0e6ed;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+
+      .session-empty {
+        text-align: center;
+        color: #8b949e;
+        font-size: 13px;
+        padding: 20px 0;
+      }
+    }
+
+    .session-popup-footer {
+      padding: 8px 14px;
+      font-size: 12px;
+      color: #8b949e;
+      text-align: center;
+      border-top: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .max-hint {
+        color: #faad14;
+      }
+    }
+  }
+</style>

+ 174 - 0
src/views/ventAI/manageAssistent/components/chat/Input/SkillPopup.vue

@@ -0,0 +1,174 @@
+<template>
+  <!-- 技能引用选择弹层:由父组件用 $ 触发,单选;已选状态与高亮项都由父组件传入 -->
+  <div ref="rootEl" class="skill-popup" @click.stop @pointerdown.stop>
+    <div class="skill-popup-header">
+      <span>选择技能</span>
+      <span class="skill-count">0/1</span>
+    </div>
+    <div class="skill-list">
+      <div
+        v-for="(skill, index) in list"
+        :key="skill.name"
+        class="skill-item"
+        :class="{
+          active: index === activeIndex,
+          selected: isSelected(skill.name),
+        }"
+        @click="emit('select', skill)"
+      >
+        <div class="skill-item-checkbox" :class="{ checked: isSelected(skill.name) }">
+          <span v-if="isSelected(skill.name)" class="check-mark">✓</span>
+        </div>
+        <div class="skill-item-icon"></div>
+        <span class="skill-item-name">{{ skill.display_name }}</span>
+      </div>
+      <div v-if="list.length === 0" class="skill-empty">暂无匹配技能</div>
+    </div>
+    <div class="skill-popup-footer">
+      <span>点击选择技能(仅可选1个)</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface SkillOption {
+    name: string;
+    display_name: string;
+  }
+
+  const props = defineProps<{
+    list: SkillOption[];
+    activeIndex: number;
+    selectedName: string;
+  }>();
+
+  const emit = defineEmits<{ (e: 'select', skill: SkillOption): void; (e: 'close'): void }>();
+
+  const isSelected = (name: string) => props.selectedName === name;
+
+  // 组件自己持有根元素,外部点击由这里判定后回调父组件
+  const rootEl = ref<HTMLElement>();
+  onClickOutside(rootEl, () => emit('close'));
+</script>
+
+<style scoped lang="less">
+  .skill-popup {
+    position: absolute;
+    bottom: 100%;
+    left: 0;
+    right: 0;
+    margin-bottom: 8px;
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    max-height: 300px;
+    display: flex;
+    flex-direction: column;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .skill-popup-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 10px 14px 8px;
+      font-size: 13px;
+      color: #e0e6ed;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+
+      .skill-count {
+        font-size: 12px;
+        color: #3a8fd4;
+        font-weight: 500;
+      }
+    }
+
+    .skill-list {
+      flex: 1;
+      overflow-y: auto;
+      padding: 6px;
+
+      .skill-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+        padding: 8px 10px;
+        cursor: pointer;
+        border-radius: 6px;
+        transition: background 0.2s;
+
+        &:hover {
+          background: rgba(10, 132, 255, 0.15);
+        }
+
+        &.active {
+          background: rgba(10, 132, 255, 0.2);
+        }
+
+        &.selected {
+          background: rgba(10, 132, 255, 0.12);
+        }
+
+        .skill-item-checkbox {
+          width: 16px;
+          height: 16px;
+          border: 1.5px solid rgba(255, 255, 255, 0.25);
+          border-radius: 3px;
+          flex-shrink: 0;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          transition: all 0.2s;
+
+          &.checked {
+            background: #3a8fd4;
+            border-color: #3a8fd4;
+          }
+
+          .check-mark {
+            font-size: 11px;
+            color: #fff;
+            line-height: 1;
+          }
+        }
+
+        .skill-item-icon {
+          width: 18px;
+          height: 18px;
+          background-image: var(--img-chat-options-skill-icon);
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          flex-shrink: 0;
+        }
+
+        .skill-item-name {
+          font-size: 13px;
+          color: #e0e6ed;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+      }
+
+      .skill-empty {
+        text-align: center;
+        color: #8b949e;
+        font-size: 13px;
+        padding: 20px 0;
+      }
+    }
+
+    .skill-popup-footer {
+      padding: 8px 14px;
+      font-size: 12px;
+      color: #8b949e;
+      text-align: center;
+      border-top: 1px solid rgba(63, 80, 106, 0.3);
+      flex-shrink: 0;
+    }
+  }
+</style>

+ 190 - 0
src/views/ventAI/manageAssistent/components/chat/Input/ThinkLevelPopup.vue

@@ -0,0 +1,190 @@
+<template>
+  <!-- 思考级别选择器:关闭 / 高 / 最高 -->
+  <div class="btn-think-wrapper" ref="wrapperRef">
+    <a-tooltip title="思考级别">
+      <div class="btn-think" @click="togglePopup">
+        <div class="btn-icon think-icon"></div>
+        <span class="btn-text">{{ selected.label }}</span>
+        <div class="btn-arrow"></div>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="think-popup" @click.stop>
+      <div v-for="item in options" :key="item.key" class="think-popup-item" :class="{ active: selected.key === item.key }" @click="selectItem(item)">
+        <div class="think-item-main">
+          <span class="think-item-label">{{ item.label }}</span>
+          <span class="think-item-desc">{{ item.desc }}</span>
+        </div>
+        <div class="think-item-check" :class="{ visible: selected.key === item.key }"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+
+  interface ThinkLevelOption {
+    key: string;
+    label: string;
+    desc: string;
+  }
+
+  /**
+   * 思考级别选择器。选中项是本组件状态(仅供展示),切换时通过 change 事件通知父组件调接口。
+   */
+  const props = withDefaults(defineProps<{ initialLevel?: string }>(), { initialLevel: 'off' });
+  const emit = defineEmits<{
+    (e: 'change', key: string): void;
+  }>();
+
+  const options: ThinkLevelOption[] = [
+    { key: 'off', label: '关闭', desc: '不使用深度思考,响应最快' },
+    { key: 'high', label: '高', desc: '深度思考,平衡速度与质量' },
+    { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
+  ];
+
+  const selected = ref<ThinkLevelOption>(options[1]);
+  const showPopup = ref(false);
+  const wrapperRef = ref<HTMLElement>();
+
+  watch(
+    () => props.initialLevel,
+    (level) => {
+      const found = options.find((opt) => opt.key === level);
+      if (found) selected.value = found;
+    },
+    { immediate: true }
+  );
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  const selectItem = (item: ThinkLevelOption) => {
+    selected.value = item;
+    showPopup.value = false;
+    emit('change', item.key);
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  /* 以下 .btn-icon/.btn-text/.btn-arrow/.think-icon 与原输入区内联按钮原语一致:
+     按钮下移到组件后,父组件的 scoped 样式不再作用于这里的后代元素,故随组件复制一份。 */
+  .btn-icon {
+    width: 14px;
+    height: 14px;
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    flex-shrink: 0;
+  }
+  .think-icon {
+    background-image: var(--img-chat-thinking-icon);
+  }
+  .btn-text {
+    font-size: 12px;
+    color: #e0e6ed;
+    white-space: nowrap;
+  }
+  .btn-arrow {
+    width: 0;
+    height: 0;
+    border-left: 4px solid transparent;
+    border-right: 4px solid transparent;
+    border-top: 4px solid #8b949e;
+    flex-shrink: 0;
+  }
+
+  .btn-think-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-think {
+    height: 28px;
+    padding: 0 10px;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    cursor: pointer;
+    transition: all 0.2s;
+
+    &:hover {
+      border-color: #3a8fd4;
+      background: rgba(10, 132, 255, 0.1);
+    }
+  }
+  .think-popup {
+    position: absolute;
+    bottom: 38px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .think-popup-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 12px;
+      cursor: pointer;
+      transition: background 0.2s;
+      border-radius: 6px;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.15);
+      }
+
+      &.active {
+        background: rgba(10, 132, 255, 0.1);
+      }
+
+      .think-item-main {
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+
+        .think-item-label {
+          font-size: 13px;
+          color: #e0e6ed;
+          white-space: nowrap;
+        }
+
+        .think-item-desc {
+          font-size: 11px;
+          color: #8b949e;
+          white-space: nowrap;
+        }
+      }
+
+      .think-item-check {
+        width: 16px;
+        height: 16px;
+        flex-shrink: 0;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        visibility: hidden;
+
+        &.visible {
+          visibility: visible;
+        }
+
+        &::after {
+          content: '✓';
+          font-size: 12px;
+          color: #3a8fd4;
+        }
+      }
+    }
+  }
+</style>

+ 266 - 0
src/views/ventAI/manageAssistent/components/chat/Input/UsageRing.vue

@@ -0,0 +1,266 @@
+<template>
+  <!-- 今日用量环:悬浮展开明细(按模型分组的 token 明细) -->
+  <div class="btn-usage-wrapper" ref="wrapperRef" @mouseenter="showPopup = true" @mouseleave="showPopup = false">
+    <a-tooltip>
+      <div class="btn-usage" @click="togglePopup">
+        <svg class="usage-ring" viewBox="0 0 32 32">
+          <circle class="usage-ring-bg" cx="16" cy="16" r="13" />
+          <circle
+            class="usage-ring-fill"
+            cx="16"
+            cy="16"
+            r="13"
+            :stroke-dasharray="circumference"
+            :stroke-dashoffset="offset"
+            :style="{ stroke: color }"
+          />
+        </svg>
+        <span class="usage-symbol">T</span>
+      </div>
+    </a-tooltip>
+    <div v-if="showPopup" class="usage-popup" @click.stop>
+      <div class="usage-popup-title">今日用量</div>
+      <div class="usage-popup-info">
+        <span class="usage-spent">{{ spentTokens }} </span>
+        <span class="usage-sep">/</span>
+        <span class="usage-limit">{{ limitTokens }} </span>
+        <span class="usage-pct">({{ percent }}%)</span>
+      </div>
+      <div class="usage-popup-bar-track">
+        <div class="usage-popup-bar-fill" :style="{ width: percent + '%', background: color }"></div>
+      </div>
+      <div class="usage-remaining">剩余额度 {{ remainingTokens }}</div>
+      <div v-if="byModel.length > 0" class="usage-breakdown">
+        <div v-for="item in byModel" :key="item.model" class="usage-model-item">
+          <div class="usage-model-row">
+            <span class="usage-model-label">缓存命中</span>
+            <span class="usage-model-value">{{ formatTokens(item.cache_hit_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">缓存未命中</span>
+            <span class="usage-model-value">{{ formatTokens(item.cache_miss_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">输出</span>
+            <span class="usage-model-value">{{ formatTokens(item.output_tokens) }}</span>
+          </div>
+          <div class="usage-model-row">
+            <span class="usage-model-label">请求次数</span>
+            <span class="usage-model-value">{{ item.request_count }} 次</span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, ref } from 'vue';
+  import { onClickOutside } from '@vueuse/core';
+  import type { UsageByModel } from '/@/views/ventAI/manageAssistent/api/client';
+  import { formatTokens } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /** 今日用量环 + 明细弹层。额度数值来自父组件,弹层开关是本组件内部状态。 */
+  const props = withDefaults(
+    defineProps<{
+      limit?: number;
+      spent?: number;
+      remaining?: number;
+      byModel?: UsageByModel[];
+    }>(),
+    {
+      limit: 1000,
+      spent: 0,
+      remaining: 0,
+      byModel: () => [],
+    }
+  );
+
+  const wrapperRef = ref<HTMLElement>();
+  const showPopup = ref(false);
+
+  const percent = computed(() => {
+    if (!props.limit) return 0;
+    return Math.min(100, Math.round((props.spent / props.limit) * 100));
+  });
+
+  const circumference = computed(() => 2 * Math.PI * 13);
+  const offset = computed(() => circumference.value * (1 - percent.value / 100));
+
+  const color = computed(() => {
+    if (percent.value >= 90) return '#ff4d4f';
+    if (percent.value >= 70) return '#faad14';
+    return '#52c41a';
+  });
+
+  const spentTokens = computed(() => formatTokens(props.spent));
+  const limitTokens = computed(() => formatTokens(props.limit));
+  const remainingTokens = computed(() => formatTokens(Math.max(0, props.remaining ?? props.limit - props.spent)));
+
+  const togglePopup = () => {
+    showPopup.value = !showPopup.value;
+  };
+
+  onClickOutside(wrapperRef, () => {
+    showPopup.value = false;
+  });
+</script>
+
+<style scoped lang="less">
+  .btn-usage-wrapper {
+    position: relative;
+    display: flex;
+    align-items: center;
+  }
+  .btn-usage {
+    width: 30px;
+    height: 30px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    position: relative;
+    border-radius: 6px;
+    transition: background 0.2s;
+
+    &:hover {
+      background: rgba(10, 132, 255, 0.1);
+    }
+
+    .usage-ring {
+      width: 24px;
+      height: 24px;
+      transform: rotate(-90deg);
+
+      .usage-ring-bg {
+        fill: none;
+        stroke: rgba(255, 255, 255, 0.1);
+        stroke-width: 3;
+      }
+
+      .usage-ring-fill {
+        fill: none;
+        stroke-width: 3;
+        stroke-linecap: round;
+        transition: stroke-dashoffset 0.3s ease;
+      }
+    }
+
+    .usage-symbol {
+      position: absolute;
+      font-size: 9px;
+      color: #e0e6ed;
+      font-weight: 600;
+      line-height: 1;
+    }
+  }
+  .usage-popup {
+    position: absolute;
+    bottom: 42px;
+    left: 50%;
+    transform: translateX(-50%);
+    background: #0a2a3f;
+    border: 1px solid #1a4a6f;
+    border-radius: 8px;
+    min-width: 200px;
+    padding: 14px 16px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      bottom: -12px;
+      left: 0;
+      right: 0;
+      height: 12px;
+    }
+    z-index: 1000;
+    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+
+    .usage-popup-title {
+      font-size: 13px;
+      color: #8b949e;
+      margin-bottom: 8px;
+    }
+
+    .usage-popup-info {
+      display: flex;
+      align-items: baseline;
+      gap: 2px;
+      margin-bottom: 10px;
+
+      .usage-spent {
+        font-size: 18px;
+        font-weight: 600;
+        color: #e0e6ed;
+      }
+
+      .usage-sep {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .usage-limit {
+        font-size: 14px;
+        color: #8b949e;
+      }
+
+      .usage-pct {
+        font-size: 13px;
+        color: #8b949e;
+      }
+    }
+
+    .usage-popup-bar-track {
+      width: 100%;
+      height: 6px;
+      background: rgba(255, 255, 255, 0.08);
+      border-radius: 3px;
+      overflow: hidden;
+
+      .usage-popup-bar-fill {
+        height: 100%;
+        border-radius: 3px;
+        transition: width 0.3s ease;
+      }
+    }
+
+    .usage-remaining {
+      margin-top: 6px;
+      font-size: 14px;
+      color: #8b949e;
+    }
+
+    .usage-breakdown {
+      margin-top: 10px;
+      padding-top: 10px;
+      border-top: 1px solid rgba(255, 255, 255, 0.08);
+
+      .usage-model-item {
+        padding: 6px 0;
+        border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
+
+        &:last-child {
+          border-bottom: none;
+          padding-bottom: 0;
+        }
+
+        .usage-model-row {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+          padding: 2px 0;
+
+          .usage-model-label {
+            font-size: 12px;
+            color: #8b949e;
+          }
+
+          .usage-model-value {
+            font-size: 12px;
+            color: #e0e6ed;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 127 - 0
src/views/ventAI/manageAssistent/components/chat/MessageActions.vue

@@ -0,0 +1,127 @@
+<template>
+  <!-- 消息操作栏:AI 与用户消息共用,按传入能力位决定显示哪些按钮 -->
+  <div class="message-actions">
+    <div class="action-btn" :title="copied ? '复制成功' : '复制'" @click="emit('copy')">
+      <img :src="copied ? successIcon : copyIcon" width="14" height="14" />
+    </div>
+    <div v-if="canRegenerate" class="action-btn" @click="emit('regenerate')" title="重新生成">
+      <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
+    </div>
+    <div v-if="canEdit" class="action-btn" @click="emit('edit')" title="编辑">
+      <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
+    </div>
+    <div v-if="message.id" class="action-btn" @click="emit('delete')" title="删除消息">
+      <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
+    </div>
+    <div v-if="showAnswerSwitcher" class="answer-switcher">
+      <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrev }" @click="emit('prevAnswer')" title="上一个回答">
+        <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
+      </div>
+      <span class="answer-switcher-text">{{ answerPosition }}</span>
+      <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNext }" @click="emit('nextAnswer')" title="下一个回答">
+        <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
+      </div>
+    </div>
+    <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import dayjs from 'dayjs';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 消息操作栏。显示与否(能否重新生成/编辑、多回答切换器、是否已复制)都是父组件的状态,
+   * 这里只做呈现,所有点击以事件形式交回父组件。
+   */
+  defineProps<{
+    message: Message;
+    copied: boolean;
+    canEdit?: boolean;
+    canRegenerate?: boolean;
+    showAnswerSwitcher?: boolean;
+    canPrev?: boolean;
+    canNext?: boolean;
+    answerPosition?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'copy'): void;
+    (e: 'delete'): void;
+    (e: 'regenerate'): void;
+    (e: 'edit'): void;
+    (e: 'prevAnswer'): void;
+    (e: 'nextAnswer'): void;
+  }>();
+
+  const copyIcon = getCssUrl('--img-chat-copy-icon');
+  const successIcon = getCssUrl('--img-chat-copy-success-icon');
+
+  const formatFullTime = (createdAt: string) => dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
+</script>
+
+<style scoped lang="less">
+  .message-actions {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    width: 100%;
+    opacity: 0;
+    transition: opacity 0.2s;
+
+    .action-btn {
+      width: 26px;
+      height: 26px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+      color: #8b949e;
+      cursor: pointer;
+      transition: all 0.2s;
+
+      &:hover {
+        color: #e0e6ed;
+        background: rgba(10, 132, 255, 0.15);
+      }
+    }
+
+    .action-time {
+      font-size: 12px;
+      color: #8b949e;
+    }
+
+    .answer-switcher {
+      display: flex;
+      align-items: center;
+      gap: 2px;
+      padding: 0 4px;
+      border-radius: 4px;
+      background: rgba(255, 255, 255, 0.04);
+
+      .answer-nav-btn {
+        width: 22px;
+        height: 22px;
+
+        &.is-disabled {
+          opacity: 0.35;
+          cursor: not-allowed;
+
+          &:hover {
+            background: transparent;
+            color: #8b949e;
+          }
+        }
+      }
+
+      .answer-switcher-text {
+        font-size: 12px;
+        color: #8b949e;
+        min-width: 32px;
+        text-align: center;
+        user-select: none;
+      }
+    }
+  }
+</style>

+ 325 - 0
src/views/ventAI/manageAssistent/components/chat/ThinkingCard.vue

@@ -0,0 +1,325 @@
+<template>
+  <!-- 思考过程折叠卡片:耗时条 + 思考内容 + 按智能体聚合的步骤列表 -->
+  <div class="thinking-card">
+    <div class="thinking-duration-bar" @click="toggleCollapsed">
+      <span v-if="durationText" class="thinking-duration">{{ durationText }}</span>
+      <div :class="['thinking-arrow', { expanded: !collapsed }]">
+        <img :src="getCssUrl('--img-chat-arrow-icon')" width="12" height="12" />
+      </div>
+    </div>
+    <Transition name="collapse">
+      <div v-show="!collapsed" class="thinking-collapse-wrap">
+        <div class="thinking-header" @click="toggleContentCollapsed">
+          <div class="thinking-header-left">
+            <div class="thinking-icon"></div>
+            <span class="thinking-title">思考过程</span>
+          </div>
+          <div v-if="message.thinkingContent" :class="['thinking-arrow', { expanded: !contentCollapsed }]">
+            <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
+          </div>
+        </div>
+
+        <!-- thinking_token 思考内容 -->
+        <Transition name="collapse">
+          <div v-if="message.thinkingContent && !contentCollapsed" class="thinking-content-body">
+            {{ message.thinkingContent }}
+          </div>
+        </Transition>
+
+        <div class="thinking-body">
+          <div v-for="(group, groupIdx) in agentGroups" :key="groupIdx" class="agent-group" @click="emit('agentClick', group)">
+            <div class="agent-group-header">
+              <div class="agent-group-header-left">
+                <div class="agent-group-icon"></div>
+                <span class="agent-group-name">{{ group.agent }}</span>
+              </div>
+              <span v-if="getLatestStepMessage(group)" class="agent-group-latest">
+                {{ getLatestStepMessage(group) }}
+              </span>
+              <div class="thinking-arrow agent-arrow">
+                <img :src="getCssUrl('--img-chat-next-icon')" width="10" height="10" />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+  import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
+  import { getCssUrl, groupStepsByAgent } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AgentGroup } from '/@/views/ventAI/manageAssistent/lib/index';
+
+  /**
+   * 思考过程卡片。折叠态由父组件持有(按消息下标存),这里通过 props/emit 受控;
+   * 耗时文案依赖的 1 秒计时器与步骤聚合都在本组件内部,不再让整个消息列表跟着每秒重渲染。
+   */
+  const props = defineProps<{
+    message: Message;
+    collapsed: boolean;
+    contentCollapsed: boolean;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'update:collapsed', value: boolean): void;
+    (e: 'update:contentCollapsed', value: boolean): void;
+    (e: 'agentClick', group: AgentGroup): void;
+  }>();
+
+  const toggleCollapsed = () => emit('update:collapsed', !props.collapsed);
+  const toggleContentCollapsed = () => emit('update:contentCollapsed', !props.contentCollapsed);
+
+  // 每秒刷新一次,用于实时显示思考耗时
+  const nowTs = ref(Date.now());
+  let timer: ReturnType<typeof setInterval> | null = null;
+
+  onMounted(() => {
+    timer = setInterval(() => {
+      nowTs.value = Date.now();
+    }, 1000);
+  });
+
+  onBeforeUnmount(() => {
+    if (timer) {
+      clearInterval(timer);
+      timer = null;
+    }
+  });
+
+  const durationText = computed<string>(() => {
+    const message = props.message;
+    // 优先使用后端返回的 duration_ms(已包含 baseDurationMs 累加)
+    if (message.durationMs) {
+      return formatDuration(message.durationMs);
+    }
+    // 审批等待中 - 暂停显示中断前的累计时长
+    if (message.isPendingApproval && message.pendingApprovalData?.elapsedMs) {
+      return formatDuration(message.pendingApprovalData.elapsedMs);
+    }
+    // 前端实时计时(含中断前累计时长)
+    if (message.generateStartTime && message.isLoading) {
+      const baseMs = message.baseDurationMs || 0;
+      return formatDuration(nowTs.value - message.generateStartTime + baseMs);
+    }
+    return '';
+  });
+
+  const formatDuration = (ms: number): string => {
+    const totalSec = Math.round(ms / 1000);
+    const min = Math.floor(totalSec / 60);
+    const sec = totalSec % 60;
+    return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
+  };
+
+  const agentGroups = computed<AgentGroup[]>(() => groupStepsByAgent(props.message.thinkingSteps || []));
+
+  const getLatestStepMessage = (group: AgentGroup): string => {
+    if (group.steps.length === 0) return '';
+    const last = group.steps[group.steps.length - 1];
+    if (last.type === 'executing') return last.message || '正在执行...';
+    if (last.type === 'tool_call') return last.message || last.tool || '';
+    if (last.type === 'tool_result') return last.message || '';
+    if (last.type === 'agent_start') return last.message || '';
+    if (last.type === 'agent_done') return last.message || '';
+    if (last.type === 'updated_todo_list') return '任务列表已更新';
+    return '';
+  };
+</script>
+
+<style scoped lang="less">
+  .thinking-card {
+    margin-bottom: 12px;
+    overflow: hidden;
+
+    .thinking-duration-bar {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 14px;
+      border-bottom: 1px solid rgba(63, 80, 106, 0.3);
+      cursor: pointer;
+      user-select: none;
+      transition: background 0.2s;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.12);
+      }
+
+      .thinking-duration {
+        font-size: 16px;
+        color: #8b949e;
+      }
+
+      .thinking-arrow {
+        color: #8b949e;
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+    }
+
+    .thinking-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 10px 14px;
+      cursor: pointer;
+      user-select: none;
+      transition: background 0.2s;
+
+      &:hover {
+        background: rgba(10, 132, 255, 0.05);
+      }
+
+      .thinking-header-left {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+
+      .thinking-arrow {
+        display: flex;
+        align-items: center;
+        transition: transform 0.2s;
+
+        &.expanded {
+          transform: rotate(180deg);
+        }
+      }
+
+      .thinking-icon {
+        width: 16px;
+        height: 16px;
+        background-image: var(--img-chat-thinking-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+
+      .thinking-title {
+        font-size: 13px;
+        font-weight: 500;
+        color: #8b949e;
+      }
+
+      .thinking-count {
+        font-size: 12px;
+        color: #8b949e;
+        background: rgba(10, 132, 255, 0.1);
+        padding: 1px 6px;
+        border-radius: 10px;
+      }
+    }
+
+    .thinking-content-body {
+      margin: 0 14px 8px;
+      padding: 8px 12px;
+      max-height: 200px;
+      overflow-y: auto;
+      font-size: 13px;
+      line-height: 1.6;
+      color: #8b949e;
+      white-space: pre-wrap;
+      word-break: break-word;
+      background: rgba(10, 13, 23, 0.3);
+      border-radius: 6px;
+    }
+
+    .thinking-body {
+      padding: 8px 14px;
+
+      .agent-group {
+        margin-bottom: 8px;
+        border: 1px solid transparent;
+        border-radius: 6px;
+        overflow: hidden;
+        transition: border-color 0.2s;
+        cursor: pointer;
+
+        &:hover {
+          border-color: rgba(63, 80, 106, 0.25);
+          background: rgba(10, 132, 255, 0.05);
+        }
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .agent-group-header {
+          display: flex;
+          align-items: center;
+          justify-content: space-between;
+          padding: 8px 12px;
+          user-select: none;
+
+          .agent-group-header-left {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+          }
+
+          .agent-group-icon {
+            width: 14px;
+            height: 14px;
+            background-image: var(--img-chat-agent-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .agent-group-name {
+            font-size: 13px;
+            font-weight: 500;
+            color: #fff;
+          }
+
+          .agent-group-latest {
+            font-size: 12px;
+            color: #8b949e;
+            margin-left: 8px;
+            overflow: hidden;
+            text-overflow: ellipsis;
+            white-space: nowrap;
+            flex: 1;
+            min-width: 0;
+          }
+
+          .agent-arrow {
+            margin-left: 8px;
+            flex-shrink: 0;
+            display: flex;
+            align-items: center;
+          }
+        }
+      }
+    }
+  }
+
+  .collapse-enter-active,
+  .collapse-leave-active {
+    transition: all 0.25s ease;
+    overflow: hidden;
+  }
+
+  .collapse-enter-from,
+  .collapse-leave-to {
+    opacity: 0;
+    max-height: 0;
+    padding-top: 0;
+    padding-bottom: 0;
+    margin-top: 0;
+    margin-bottom: 0;
+  }
+
+  .collapse-enter-to,
+  .collapse-leave-from {
+    opacity: 1;
+    max-height: 500px;
+  }
+</style>

+ 0 - 0
src/views/ventAI/manageAssistent/components/chatModal/TodoListBar.vue → src/views/ventAI/manageAssistent/components/chat/TodoListBar.vue


+ 0 - 2139
src/views/ventAI/manageAssistent/components/chatModal/ChatInputArea.vue

@@ -1,2139 +0,0 @@
-<template>
-  <div class="chat-input-area">
-    <div v-if="pendingFile" class="pending-file-display">
-      <div class="pending-file-card" @click="emit('pending-file-preview', pendingFile)">
-        <div class="file-icon-pending" :style="{ backgroundImage: getFileIconVar(pendingFile.name) }"></div>
-        <div class="file-info-pending">
-          <div class="file-name-pending">{{ pendingFile.name }}</div>
-          <div class="file-size-pending">{{ formatFileSize(pendingFile.size) }}</div>
-        </div>
-        <div class="remove-pending" @click.stop="emit('remove-pending-file')">
-          <CloseOutlined />
-        </div>
-      </div>
-    </div>
-
-    <!-- 已选会话引用标签 -->
-    <div v-if="selectedSessions.length > 0" class="session-chips">
-      <div v-for="session in selectedSessions" :key="session.id" class="session-chip">
-        <div class="session-chip-icon"></div>
-        <span class="session-chip-name">{{ session.name }}</span>
-        <CloseOutlined class="session-chip-remove" @click.stop="removeSession(session.id)" />
-      </div>
-    </div>
-
-    <!-- 已选技能引用标签 -->
-    <div v-if="selectedSkill" class="skill-chips">
-      <div class="skill-chip">
-        <div class="skill-chip-icon"></div>
-        <span class="skill-chip-name">{{ selectedSkill.display_name }}</span>
-        <CloseOutlined class="skill-chip-remove" @click.stop="removeSkill()" />
-      </div>
-    </div>
-
-    <a-textarea
-      ref="textareaRef"
-      :value="modelValue"
-      @update:value="handleTextareaInput"
-      :rows="3"
-      class="chat-input"
-      placeholder="请输入你的问题"
-      :auto-size="{ minRows: 3, maxRows: 6 }"
-      @keydown="handleSessionKeydown"
-      @keydown.enter.prevent="handleEnterKey"
-      @keydown.backspace="handleBackspace"
-    />
-
-    <div class="input-actions">
-      <div class="action-left">
-        <div class="btn-upload-wrapper">
-          <a-tooltip title="添加上下文">
-            <div class="btn-upload" @click="togglePopup">
-              <div class="plus-icon"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showPopup" ref="popupRef" class="upload-popup" @click.stop>
-            <div class="popup-item" @click="handleAddAttachment">
-              <div class="popup-item-icon attach-icon"></div>
-              <span class="popup-item-text">添加附件</span>
-            </div>
-            <!-- <div class="popup-item" @click="handleInsertMention">
-              <div class="popup-item-icon mention-icon"></div>
-              <span class="popup-item-text">插入 @ 提及</span>
-            </div> -->
-            <div class="popup-item" @click="handleInsertSession">
-              <div class="popup-item-icon session-icon"></div>
-              <span class="popup-item-text">插入 # 会话</span>
-            </div>
-            <div class="popup-item" @click="handleInsertSkill">
-              <div class="popup-item-icon skill-icon"></div>
-              <span class="popup-item-text">插入 $ 技能</span>
-            </div>
-            <!-- <div class="popup-item" @click="handleInsertCommand">
-              <div class="popup-item-icon command-icon"></div>
-              <span class="popup-item-text">插入 / 命令</span>
-            </div> -->
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-edit-mode-wrapper">
-          <a-tooltip title="切换模式">
-            <div class="btn-edit-mode" @click="toggleEditModePopup">
-              <div class="btn-icon" :class="`mode-icon-${selectedEditMode.key}`"></div>
-              <span class="btn-text">{{ selectedEditMode.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showEditModePopup" ref="editModePopupRef" class="edit-mode-popup">
-            <div
-              v-for="item in editModeOptions"
-              :key="item.key"
-              class="edit-mode-item"
-              :class="{ active: selectedEditMode.key === item.key }"
-              @click="handleSelectEditMode(item)"
-            >
-              <div class="edit-mode-item-icon" :class="`mode-icon-${item.key}`"></div>
-              <div class="edit-mode-item-main">
-                <span class="edit-mode-item-label">{{ item.label }}</span>
-                <span class="edit-mode-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="edit-mode-check" :class="{ visible: selectedEditMode.key === item.key }"></div>
-            </div>
-          </div>
-        </div>
-        <input ref="fileInputRef" type="file" style="display: none" @change="emit('file-change', $event)" />
-      </div>
-      <div class="action-right">
-        <div class="btn-context-wrapper" ref="contextPopupRef" @mouseenter="showContextPopup = true" @mouseleave="showContextPopup = false">
-          <div class="btn-context" @click="toggleContextPopup">
-            <svg class="context-ring" viewBox="0 0 32 32">
-              <circle class="context-ring-bg" cx="16" cy="16" r="13" />
-              <circle
-                class="context-ring-fill"
-                cx="16"
-                cy="16"
-                r="13"
-                :stroke-dasharray="contextCircumference"
-                :stroke-dashoffset="contextOffset"
-                :style="{ stroke: contextColor }"
-              />
-            </svg>
-            <span class="context-percent">{{ contextPercent }}%</span>
-          </div>
-          <div v-if="showContextPopup" class="context-popup" @click.stop>
-            <div class="context-popup-title">上下文容量</div>
-            <div class="context-popup-info">
-              <span class="context-used">{{ contextDisplayUsed }}</span>
-              <span class="context-sep">/</span>
-              <span class="context-max">{{ contextDisplayMax }}</span>
-              <span class="context-pct">({{ contextPercent }}%)</span>
-            </div>
-            <div class="context-popup-bar-track">
-              <div class="context-popup-bar-fill" :style="{ width: contextPercent + '%', background: contextColor }"></div>
-            </div>
-            <div class="context-breakdown">
-              <div class="breakdown-item">
-                <span class="breakdown-label">历史消息</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.messages) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">MCP工具</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.mcp) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">技能描述</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.skills) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">系统提示词</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.system_prompt) }}</span>
-              </div>
-              <div class="breakdown-item">
-                <span class="breakdown-label">其他</span>
-                <span class="breakdown-value">{{ formatTokens(props.contextBreakdown.other) }}</span>
-              </div>
-            </div>
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-usage-wrapper" ref="usagePopupRef" @mouseenter="showUsagePopup = true" @mouseleave="showUsagePopup = false">
-          <a-tooltip>
-            <div class="btn-usage" @click="toggleUsagePopup">
-              <svg class="usage-ring" viewBox="0 0 32 32">
-                <circle class="usage-ring-bg" cx="16" cy="16" r="13" />
-                <circle
-                  class="usage-ring-fill"
-                  cx="16"
-                  cy="16"
-                  r="13"
-                  :stroke-dasharray="usageCircumference"
-                  :stroke-dashoffset="usageOffset"
-                  :style="{ stroke: usageColor }"
-                />
-              </svg>
-              <span class="usage-symbol">T</span>
-            </div>
-          </a-tooltip>
-          <div v-if="showUsagePopup" class="usage-popup" @click.stop>
-            <div class="usage-popup-title">今日用量</div>
-            <div class="usage-popup-info">
-              <span class="usage-spent">{{ usageSpentTokens }} </span>
-              <span class="usage-sep">/</span>
-              <span class="usage-limit">{{ usageLimitTokens }} </span>
-              <span class="usage-pct">({{ usagePercent }}%)</span>
-            </div>
-            <div class="usage-popup-bar-track">
-              <div class="usage-popup-bar-fill" :style="{ width: usagePercent + '%', background: usageColor }"></div>
-            </div>
-            <div class="usage-remaining">剩余额度 {{ usageRemainingTokens }}</div>
-            <div v-if="usageByModel.length > 0" class="usage-breakdown">
-              <div v-for="item in usageByModel" :key="item.model" class="usage-model-item">
-                <!-- <div class="usage-model-header">
-                  <span class="usage-model-name">{{ item.model }}</span>
-                  <span class="usage-model-cost">{{ formatTokens(item.tokens) }}</span>
-                </div> -->
-                <div class="usage-model-row">
-                  <span class="usage-model-label">缓存命中</span>
-                  <span class="usage-model-value">{{ formatTokens(item.cache_hit_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">缓存未命中</span>
-                  <span class="usage-model-value">{{ formatTokens(item.cache_miss_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">输出</span>
-                  <span class="usage-model-value">{{ formatTokens(item.output_tokens) }}</span>
-                </div>
-                <div class="usage-model-row">
-                  <span class="usage-model-label">请求次数</span>
-                  <span class="usage-model-value">{{ item.request_count }} 次</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
-        <!-- <div class="action-divider"></div>
-        <div class="btn-model-wrapper" ref="modelPopupRef">
-          <a-tooltip title="选择模型">
-            <div class="btn-model" @click="toggleModelPopup">
-              <span class="btn-text">{{ selectedModel.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showModelPopup" class="model-popup" @click.stop>
-            <div
-              v-for="item in modelOptions"
-              :key="item.key"
-              class="model-popup-item"
-              :class="{ active: selectedModel.key === item.key }"
-              @click="handleSelectModel(item)"
-            >
-              <div class="model-item-main">
-                <span class="model-item-label">{{ item.label }}</span>
-                <span class="model-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="model-item-check" :class="{ visible: selectedModel.key === item.key }"></div>
-            </div>
-          </div>
-        </div> -->
-        <div class="action-divider"></div>
-        <div class="btn-think-wrapper" ref="thinkPopupRef">
-          <a-tooltip title="思考级别">
-            <div class="btn-think" @click="toggleThinkPopup">
-              <div class="btn-icon think-icon"></div>
-              <span class="btn-text">{{ selectedThinkLevel.label }}</span>
-              <div class="btn-arrow"></div>
-            </div>
-          </a-tooltip>
-          <div v-if="showThinkPopup" class="think-popup" @click.stop>
-            <div
-              v-for="item in thinkLevelOptions"
-              :key="item.key"
-              class="think-popup-item"
-              :class="{ active: selectedThinkLevel.key === item.key }"
-              @click="handleSelectThinkLevel(item)"
-            >
-              <div class="think-item-main">
-                <span class="think-item-label">{{ item.label }}</span>
-                <span class="think-item-desc">{{ item.desc }}</span>
-              </div>
-              <div class="think-item-check" :class="{ visible: selectedThinkLevel.key === item.key }"></div>
-            </div>
-          </div>
-        </div>
-        <div class="action-divider"></div>
-        <div class="btn-send" :class="{ disabled: !streaming && (!modelValue.trim() || loading) }" @click="handleSendClick">
-          <div class="send-icon" :class="{ 'stop-icon': streaming }"></div>
-        </div>
-      </div>
-      <!-- 会话列表弹框 -->
-      <div v-if="showSessionPopup" ref="sessionPopupRef" class="session-popup" @click.stop @pointerdown.stop>
-        <div class="session-popup-header">
-          <span>选择会话</span>
-          <span class="session-count">{{ selectedSessions.length }}/5</span>
-        </div>
-        <div class="session-list">
-          <div
-            v-for="(task, index) in filteredSessionList"
-            :key="task.id"
-            class="session-item"
-            :class="{
-              active: index === activeSessionIndex,
-              selected: isSessionSelected(task.id),
-              disabled: (selectedSessions.length >= 5 && !isSessionSelected(task.id)) || task.id === props.currentTaskId,
-            }"
-            @click="handleSelectSession(task)"
-          >
-            <div class="session-checkbox" :class="{ checked: isSessionSelected(task.id) }">
-              <span v-if="isSessionSelected(task.id)" class="check-mark">✓</span>
-            </div>
-            <div class="session-item-icon"></div>
-            <span class="session-item-name">{{ task.name }}</span>
-          </div>
-          <div v-if="filteredSessionList.length === 0" class="session-empty">暂无匹配会话</div>
-        </div>
-        <div class="session-popup-footer">
-          <span v-if="selectedSessions.length >= 5" class="max-hint">最多选择5个会话</span>
-          <span v-else>点击选择会话(最多5个)</span>
-        </div>
-      </div>
-      <!-- 技能列表弹框 -->
-      <div v-if="showSkillPopup" ref="skillPopupRef" class="skill-popup" @click.stop @pointerdown.stop>
-        <div class="skill-popup-header">
-          <span>选择技能</span>
-          <span class="skill-count">0/1</span>
-        </div>
-        <div class="skill-list">
-          <div
-            v-for="(skill, index) in filteredSkillList"
-            :key="skill.name"
-            class="skill-item"
-            :class="{
-              active: index === activeSkillIndex,
-              selected: isSkillSelected(skill.name),
-            }"
-            @click="handleSelectSkill(skill)"
-          >
-            <div class="skill-item-checkbox" :class="{ checked: isSkillSelected(skill.name) }">
-              <span v-if="isSkillSelected(skill.name)" class="check-mark">✓</span>
-            </div>
-            <div class="skill-item-icon"></div>
-            <span class="skill-item-name">{{ skill.display_name }}</span>
-          </div>
-          <div v-if="filteredSkillList.length === 0" class="skill-empty">暂无匹配技能</div>
-        </div>
-        <div class="skill-popup-footer">
-          <span>点击选择技能(仅可选1个)</span>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-  import { ref, computed, watch, nextTick, onMounted } from 'vue';
-  import { onClickOutside } from '@vueuse/core';
-  import { CloseOutlined } from '@ant-design/icons-vue';
-  import { formatFileSize, getFileIconVar } from './utils';
-  import { getSkills } from '../../api';
-  import type { UsageByModel } from '../../api';
-  import type { AttachedFile, Task } from './types';
-
-  const props = withDefaults(
-    defineProps<{
-      modelValue: string;
-      pendingFile: AttachedFile | null;
-      loading: boolean;
-      streaming?: boolean;
-      contextUsed?: number;
-      contextMax?: number;
-      contextPercent?: number;
-      contextBreakdown?: { messages: number; mcp: number; skills: number; system_prompt: number; other: number };
-      usageLimit?: number;
-      usageSpent?: number;
-      usageRemaining?: number;
-      usageByModel?: UsageByModel[];
-      initialThinkLevel?: string;
-      initialEditMode?: string;
-      taskList?: Task[];
-      currentTaskId?: string;
-    }>(),
-    {
-      contextUsed: 0,
-      contextMax: 1000000,
-      contextPercent: 0,
-      streaming: false,
-      contextBreakdown: () => ({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 }),
-      usageLimit: 1000,
-      usageSpent: 0,
-      usageRemaining: 0,
-      usageByModel: () => [],
-      initialThinkLevel: 'off',
-      initialEditMode: 'full',
-      taskList: () => [],
-      currentTaskId: '',
-    }
-  );
-
-  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;
-    (e: 'pending-file-preview', file: AttachedFile): void;
-    (e: 'model-change', modelKey: string): void;
-    (e: 'think-level-change', levelKey: string): void;
-    (e: 'edit-mode-change', modeKey: string): void;
-    (e: 'update:selectedSessions', sessions: Array<{ id: string; name: string }>): void;
-    (e: 'update:selectedSkill', skill: { name: string; display_name: string } | null): void;
-  }>();
-
-  const handleSelectModel = (item: ModelOption) => {
-    selectedModel.value = item;
-    showModelPopup.value = false;
-    emit('model-change', item.key);
-  };
-
-  const fileInputRef = ref<HTMLInputElement>();
-  const popupRef = ref<HTMLElement>();
-  const showPopup = ref(false);
-
-  // 上下文容量
-  const contextPopupRef = ref<HTMLElement>();
-  const showContextPopup = ref(false);
-
-  const contextPercent = computed(() => {
-    return props.contextPercent;
-  });
-
-  const contextCircumference = computed(() => 2 * Math.PI * 13);
-  const contextOffset = computed(() => contextCircumference.value * (1 - contextPercent.value / 100));
-
-  const contextColor = computed(() => {
-    if (contextPercent.value >= 90) return '#ff4d4f';
-    if (contextPercent.value >= 70) return '#faad14';
-    return '#52c41a';
-  });
-
-  const contextDisplayUsed = computed(() => {
-    if (props.contextUsed >= 10000) return (props.contextUsed / 10000).toFixed(1) + '万';
-    return props.contextUsed.toLocaleString();
-  });
-
-  const contextDisplayMax = computed(() => {
-    if (props.contextMax >= 10000) return (props.contextMax / 10000).toFixed(0) + '万';
-    return props.contextMax.toLocaleString();
-  });
-
-  const formatTokens = (tokens: number) => {
-    if (tokens >= 10000) return (tokens / 10000).toFixed(1) + '万';
-    return tokens.toLocaleString();
-  };
-
-  const toggleContextPopup = () => {
-    showContextPopup.value = !showContextPopup.value;
-  };
-
-  onClickOutside(contextPopupRef, () => {
-    showContextPopup.value = false;
-  });
-
-  // 今日用量
-  const usagePopupRef = ref<HTMLElement>();
-  const showUsagePopup = ref(false);
-
-  const usagePercent = computed(() => {
-    if (!props.usageLimit) return 0;
-    return Math.min(100, Math.round((props.usageSpent / props.usageLimit) * 100));
-  });
-
-  const usageCircumference = computed(() => 2 * Math.PI * 13);
-  const usageOffset = computed(() => usageCircumference.value * (1 - usagePercent.value / 100));
-
-  const usageColor = computed(() => {
-    if (usagePercent.value >= 90) return '#ff4d4f';
-    if (usagePercent.value >= 70) return '#faad14';
-    return '#52c41a';
-  });
-
-  const usageSpentTokens = computed(() => formatTokens(props.usageSpent));
-  const usageLimitTokens = computed(() => formatTokens(props.usageLimit));
-  const usageRemainingTokens = computed(() => formatTokens(Math.max(0, props.usageRemaining ?? props.usageLimit - props.usageSpent)));
-
-  const toggleUsagePopup = () => {
-    showUsagePopup.value = !showUsagePopup.value;
-  };
-
-  onClickOutside(usagePopupRef, () => {
-    showUsagePopup.value = false;
-  });
-
-  // 模型选择
-  const modelPopupRef = ref<HTMLElement>();
-  const showModelPopup = ref(false);
-
-  interface ModelOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const modelOptions: ModelOption[] = [
-    { key: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro', desc: '默认模型,综合能力强' },
-    { key: 'deepseek-v4', label: 'DeepSeek-V4', desc: '轻量快速,适合简单任务' },
-    { key: 'gpt-4o', label: 'GPT-4o', desc: 'OpenAI 多模态模型' },
-    { key: 'gpt-4o-mini', label: 'GPT-4o Mini', desc: 'OpenAI 轻量模型' },
-    { key: 'claude-sonnet-4', label: 'Claude Sonnet 4', desc: 'Anthropic 高性能模型' },
-    { key: 'qwen-max', label: '通义千问 Max', desc: '阿里云旗舰模型' },
-  ];
-
-  const selectedModel = ref<ModelOption>(modelOptions[0]);
-
-  const toggleModelPopup = () => {
-    showModelPopup.value = !showModelPopup.value;
-  };
-
-  onClickOutside(modelPopupRef, () => {
-    showModelPopup.value = false;
-  });
-
-  // 思考级别
-  const thinkPopupRef = ref<HTMLElement>();
-  const showThinkPopup = ref(false);
-
-  interface ThinkLevelOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const thinkLevelOptions: ThinkLevelOption[] = [
-    { key: 'off', label: '关闭', desc: '不使用深度思考,响应最快' },
-    { key: 'high', label: '高', desc: '深度思考,平衡速度与质量' },
-    { key: 'highest', label: '最高', desc: '极致思考,质量最高但较慢' },
-  ];
-
-  const selectedThinkLevel = ref<ThinkLevelOption>(thinkLevelOptions[1]);
-
-  watch(
-    () => props.initialThinkLevel,
-    (level) => {
-      const found = thinkLevelOptions.find((opt) => opt.key === level);
-      if (found) selectedThinkLevel.value = found;
-    },
-    { immediate: true }
-  );
-
-  const toggleThinkPopup = () => {
-    showThinkPopup.value = !showThinkPopup.value;
-  };
-
-  const handleSelectThinkLevel = (item: ThinkLevelOption) => {
-    selectedThinkLevel.value = item;
-    showThinkPopup.value = false;
-    emit('think-level-change', item.key);
-  };
-
-  onClickOutside(thinkPopupRef, () => {
-    showThinkPopup.value = false;
-  });
-
-  const editModePopupRef = ref<HTMLElement>();
-  const showEditModePopup = ref(false);
-
-  interface EditModeOption {
-    key: string;
-    label: string;
-    desc: string;
-  }
-
-  const editModeOptions: EditModeOption[] = [
-    // { key: 'confirm', label: '变更前确认', desc: '改文件前先问我' },
-    // { key: 'auto', label: '自动编辑', desc: '自动编辑文件' },
-    { key: 'plan', label: '计划模式', desc: '编辑前先出计划' },
-    { key: 'full', label: '完全访问', desc: '减少确认次数' },
-  ];
-
-  const selectedEditMode = ref<EditModeOption>(editModeOptions[1]);
-
-  watch(
-    () => props.initialEditMode,
-    (mode) => {
-      const found = editModeOptions.find((opt) => opt.key === mode);
-      if (found) selectedEditMode.value = found;
-    },
-    { immediate: true }
-  );
-
-  onClickOutside(editModePopupRef, () => {
-    showEditModePopup.value = false;
-  });
-
-  const toggleEditModePopup = () => {
-    showEditModePopup.value = !showEditModePopup.value;
-  };
-
-  const handleSelectEditMode = (item: EditModeOption) => {
-    selectedEditMode.value = item;
-    showEditModePopup.value = false;
-    emit('edit-mode-change', item.key);
-  };
-
-  onClickOutside(popupRef, () => {
-    showPopup.value = false;
-  });
-
-  const togglePopup = () => {
-    showPopup.value = !showPopup.value;
-  };
-
-  const handleAddAttachment = () => {
-    showPopup.value = false;
-    emit('file-upload');
-  };
-
-  const handleInsertMention = () => {
-    showPopup.value = false;
-  };
-
-  // 会话列表弹框
-  const textareaRef = ref<InstanceType<any>>();
-  const sessionPopupRef = ref<HTMLElement>();
-  const showSessionPopup = ref(false);
-  const activeSessionIndex = ref(0);
-  const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
-  const sessionPopupManual = ref(false);
-  let isRemovingTag = false;
-  let sessionJustSelected = false;
-
-  // 技能列表弹框
-  interface SkillItem {
-    name: string;
-    display_name: string;
-    path: string;
-    description: string;
-    enabled: boolean;
-    agents: string[];
-    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);
-  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
-  const skillPopupManual = ref(false);
-  const skillList = ref<SkillItem[]>([]);
-  let skillJustSelected = false;
-
-  const fetchSkillList = async () => {
-    // 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(() => {
-    fetchSkillList();
-  });
-
-  const isSkillSelected = (name: string) => {
-    return selectedSkill.value?.name === name;
-  };
-
-  const isSessionSelected = (sessionId: string) => {
-    return selectedSessions.value.some((s) => s.id === sessionId);
-  };
-
-  const sessionSearchText = computed(() => {
-    const val = props.modelValue || '';
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) return '';
-    const afterHash = val.substring(hashIndex + 1);
-    if (afterHash.includes(' ')) return '';
-    return afterHash.toLowerCase();
-  });
-
-  const filteredSessionList = computed(() => {
-    const list = props.taskList.filter((t) => t.sessionId);
-    if (!sessionSearchText.value) return list;
-    return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value));
-  });
-
-  watch(filteredSessionList, () => {
-    activeSessionIndex.value = 0;
-  });
-
-  watch(showSessionPopup, (val) => {
-    if (val) activeSessionIndex.value = 0;
-  });
-
-  const skillSearchText = computed(() => {
-    const val = props.modelValue || '';
-    const dollarIndex = val.lastIndexOf('$');
-    if (dollarIndex === -1) return '';
-    const afterDollar = val.substring(dollarIndex + 1);
-    if (afterDollar.includes(' ')) return '';
-    return afterDollar.toLowerCase();
-  });
-
-  const filteredSkillList = computed(() => {
-    if (!skillSearchText.value) return skillList.value;
-    return skillList.value.filter(
-      (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
-    );
-  });
-
-  watch(filteredSkillList, () => {
-    activeSkillIndex.value = 0;
-  });
-
-  watch(showSkillPopup, (val) => {
-    if (val) activeSkillIndex.value = 0;
-  });
-
-  const handleSessionKeydown = (e: KeyboardEvent) => {
-    if (showSkillPopup.value && filteredSkillList.value.length > 0) {
-      if (e.key === 'ArrowDown') {
-        e.preventDefault();
-        activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
-      } else if (e.key === 'ArrowUp') {
-        e.preventDefault();
-        activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
-      } else if (e.key === 'Enter') {
-        e.preventDefault();
-        const skill = filteredSkillList.value[activeSkillIndex.value];
-        if (skill) {
-          skillJustSelected = true;
-          handleSelectSkill(skill);
-        }
-      }
-      return;
-    }
-    if (showSessionPopup.value && filteredSessionList.value.length > 0) {
-      if (e.key === 'ArrowDown') {
-        e.preventDefault();
-        activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
-      } else if (e.key === 'ArrowUp') {
-        e.preventDefault();
-        activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
-      } else if (e.key === 'Enter') {
-        e.preventDefault();
-        const task = filteredSessionList.value[activeSessionIndex.value];
-        if (task) {
-          sessionJustSelected = true;
-          handleSelectSession(task);
-        }
-      }
-    }
-  };
-
-  const handleInsertSession = () => {
-    showPopup.value = false;
-    sessionPopupManual.value = true;
-    showSessionPopup.value = true;
-    const cur = props.modelValue || '';
-    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
-    emit('update:modelValue', `${cur}${needsSpace}#`);
-    nextTick(() => {
-      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
-      if (el) el.focus();
-    });
-  };
-
-  const handleSelectSession = (task: Task) => {
-    if (task.id === props.currentTaskId) return;
-    const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
-    if (idx >= 0) {
-      selectedSessions.value.splice(idx, 1);
-    } else {
-      if (selectedSessions.value.length >= 5) return;
-      selectedSessions.value.push({ id: task.id, name: task.name });
-    }
-    removeSearchTag();
-    emit('update:selectedSessions', [...selectedSessions.value]);
-  };
-
-  const handleInsertSkill = () => {
-    showPopup.value = false;
-    showSessionPopup.value = false;
-    skillPopupManual.value = true;
-    showSkillPopup.value = true;
-    const cur = props.modelValue || '';
-    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
-    emit('update:modelValue', `${cur}${needsSpace}$`);
-    nextTick(() => {
-      const el = textareaRef.value?.$el?.querySelector('textarea') || textareaRef.value?.$el;
-      if (el) el.focus();
-    });
-  };
-
-  const handleSelectSkill = (skill: SkillItem) => {
-    selectedSkill.value = { name: skill.name, display_name: skill.display_name };
-    removeSkillSearchTag();
-    showSkillPopup.value = false;
-    skillPopupManual.value = false;
-    emit('update:selectedSkill', { name: skill.name, display_name: skill.display_name });
-  };
-
-  const removeSkillSearchTag = () => {
-    isRemovingTag = true;
-    const val = props.modelValue || '';
-    const dollarIndex = val.lastIndexOf('$');
-    if (dollarIndex === -1) {
-      isRemovingTag = false;
-      return;
-    }
-    emit('update:modelValue', val.substring(0, dollarIndex).trimEnd());
-    nextTick(() => {
-      isRemovingTag = false;
-    });
-  };
-
-  const removeSkill = () => {
-    selectedSkill.value = null;
-    emit('update:selectedSkill', null);
-  };
-
-  const removeSearchTag = () => {
-    isRemovingTag = true;
-    const val = props.modelValue || '';
-    const hashIndex = val.lastIndexOf('#');
-    if (hashIndex === -1) {
-      isRemovingTag = false;
-      return;
-    }
-    emit('update:modelValue', val.substring(0, hashIndex).trimEnd());
-    nextTick(() => {
-      isRemovingTag = false;
-    });
-  };
-
-  watch(
-    () => props.modelValue,
-    (newVal, oldVal) => {
-      if (isRemovingTag) return;
-      if (oldVal && !newVal) {
-        selectedSessions.value = [];
-        selectedSkill.value = null;
-      }
-    }
-  );
-
-  const removeSession = (sessionId: string) => {
-    selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
-    emit('update:selectedSessions', [...selectedSessions.value]);
-  };
-
-  const handleTextareaInput = (val: string) => {
-    emit('update:modelValue', val);
-
-    // 技能弹框 $ 检测
-    if (!skillPopupManual.value) {
-      const dollarIndex = val.lastIndexOf('$');
-      if (dollarIndex === -1) {
-        showSkillPopup.value = false;
-      } else {
-        const afterDollar = val.substring(dollarIndex + 1);
-        if (afterDollar.includes(' ')) {
-          showSkillPopup.value = false;
-        } else {
-          showSkillPopup.value = true;
-          showSessionPopup.value = false;
-        }
-      }
-    }
-
-    // 会话弹框 # 检测
-    if (!sessionPopupManual.value) {
-      const hashIndex = val.lastIndexOf('#');
-      if (hashIndex === -1) {
-        showSessionPopup.value = false;
-      } else {
-        const afterHash = val.substring(hashIndex + 1);
-        if (afterHash.includes(' ')) {
-          showSessionPopup.value = false;
-        } else {
-          showSessionPopup.value = true;
-          showSkillPopup.value = false;
-        }
-      }
-    }
-  };
-
-  const handleBackspace = (e: KeyboardEvent) => {
-    const val = props.modelValue || '';
-    if (val) return;
-    if (selectedSkill.value) {
-      e.preventDefault();
-      selectedSkill.value = null;
-      emit('update:selectedSkill', null);
-    } else if (selectedSessions.value.length > 0) {
-      e.preventDefault();
-      selectedSessions.value.pop();
-      emit('update:selectedSessions', [...selectedSessions.value]);
-    }
-  };
-
-  onClickOutside(sessionPopupRef, () => {
-    showSessionPopup.value = false;
-    sessionPopupManual.value = false;
-  });
-
-  onClickOutside(skillPopupRef, () => {
-    showSkillPopup.value = false;
-    skillPopupManual.value = false;
-  });
-
-  const handleInsertCommand = () => {
-    showPopup.value = false;
-  };
-
-  const handleEnterKey = (e: KeyboardEvent) => {
-    if (showSessionPopup.value || showSkillPopup.value) return;
-    if (sessionJustSelected) {
-      sessionJustSelected = false;
-      return;
-    }
-    if (skillJustSelected) {
-      skillJustSelected = false;
-      return;
-    }
-    if (!e.shiftKey && props.modelValue.trim() && !props.loading) {
-      emit('send');
-    }
-  };
-
-  const handleSend = () => {
-    if (props.modelValue.trim() && !props.loading) {
-      emit('send');
-    }
-  };
-
-  const handleSendClick = () => {
-    if (props.streaming) {
-      emit('stop');
-      return;
-    }
-    handleSend();
-  };
-
-  defineExpose({ fileInputRef });
-</script>
-
-<style scoped lang="less">
-  .chat-input-area {
-    position: relative;
-    padding: 5px 10px;
-    flex-shrink: 0;
-    background-color: #002f4a;
-    border: 1px solid #005482;
-    border-radius: 4px;
-    margin: 10px 16px 16px 16px;
-
-    .pending-file-display {
-      margin-bottom: 5px;
-
-      .pending-file-card {
-        display: flex;
-        align-items: center;
-        gap: 10px;
-        padding: 10px 12px;
-        background: rgba(10, 132, 255, 0.06);
-        border: 1px solid rgba(63, 80, 106, 0.5);
-        border-radius: 6px;
-        transition: all 0.3s;
-        cursor: pointer;
-
-        &:hover {
-          background: rgba(10, 132, 255, 0.1);
-        }
-
-        .file-icon-pending {
-          width: 32px;
-          height: 32px;
-          background-image: var(--img-chat-attach-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-
-        .file-info-pending {
-          flex: 1;
-          min-width: 0;
-
-          .file-name-pending {
-            font-size: 12px;
-            font-weight: 400;
-            line-height: 1.4;
-            color: #e0e6ed;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-            margin-bottom: 2px;
-          }
-
-          .file-size-pending {
-            font-size: 11px;
-            font-weight: 400;
-            line-height: 1.3;
-            color: #8b949e;
-          }
-        }
-
-        .remove-pending {
-          cursor: pointer;
-          color: #8b949e;
-          font-size: 14px;
-          padding: 4px;
-          border-radius: 4px;
-          transition: all 0.3s;
-
-          &:hover {
-            color: #ff4d4f;
-            background: rgba(220, 38, 38, 0.1);
-          }
-        }
-      }
-    }
-
-    :deep(.chat-input) {
-      color: #e0e6ed;
-      background-color: transparent !important;
-      border: none !important;
-
-      &::placeholder {
-        color: #8b949e !important;
-        opacity: 1 !important;
-      }
-      &:focus,
-      &:hover,
-      &:active {
-        border: none !important;
-        box-shadow: none !important;
-        outline: none !important;
-      }
-    }
-    .input-actions {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-      margin-top: 12px;
-
-      .mode-icon-confirm {
-        background-image: var(--img-chat-mode-confirm-icon);
-      }
-      .mode-icon-auto {
-        background-image: var(--img-chat-mode-auto-icon);
-      }
-      .mode-icon-plan {
-        background-image: var(--img-chat-mode-plan-icon);
-      }
-      .mode-icon-full {
-        background-image: var(--img-chat-mode-full-icon);
-      }
-
-      .action-left,
-      .action-right {
-        display: flex;
-        gap: 8px;
-        .action-divider {
-          width: 1px;
-          height: 18px;
-          background: rgba(255, 255, 255, 0.15);
-          align-self: center;
-          flex-shrink: 0;
-        }
-        .btn-icon {
-          width: 14px;
-          height: 14px;
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-        .think-icon {
-          background-image: var(--img-chat-thinking-icon);
-        }
-        .btn-text {
-          font-size: 12px;
-          color: #e0e6ed;
-          white-space: nowrap;
-        }
-        .btn-arrow {
-          width: 0;
-          height: 0;
-          border-left: 4px solid transparent;
-          border-right: 4px solid transparent;
-          border-top: 4px solid #8b949e;
-          flex-shrink: 0;
-        }
-        .btn-upload-wrapper {
-          position: relative;
-        }
-        .btn-upload {
-          width: 30px;
-          height: 30px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-        }
-        .plus-icon {
-          width: 20px;
-          height: 20px;
-          background-image: var(--img-chat-input-bg);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          cursor: pointer;
-        }
-        .upload-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 0;
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 160px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .popup-item {
-            display: flex;
-            align-items: center;
-            gap: 10px;
-            padding: 9px 16px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 8px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            .popup-item-icon {
-              width: 18px;
-              height: 18px;
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-            }
-
-            .attach-icon {
-              background-image: var(--img-chat-popup-attach-icon);
-            }
-
-            .mention-icon {
-              background-image: var(--img-chat-popup-mention-icon);
-            }
-
-            .session-icon {
-              background-image: var(--img-chat-popup-session-icon);
-            }
-
-            .skill-icon {
-              background-image: var(--img-chat-options-skill-icon);
-            }
-
-            .command-icon {
-              background-image: var(--img-chat-popup-command-icon);
-            }
-
-            .popup-item-text {
-              font-size: 13px;
-              color: #e0e6ed;
-              white-space: nowrap;
-            }
-          }
-        }
-        .btn-edit-mode-wrapper {
-          position: relative;
-        }
-        .btn-edit-mode {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .edit-mode-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .edit-mode-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .edit-mode-item-icon {
-              width: 18px;
-              height: 18px;
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-              margin-right: 10px;
-            }
-
-            .edit-mode-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .edit-mode-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .edit-mode-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .edit-mode-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .btn-context-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-context {
-          width: 30px;
-          height: 30px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-          position: relative;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.1);
-          }
-
-          .context-ring {
-            width: 24px;
-            height: 24px;
-            transform: rotate(-90deg);
-
-            .context-ring-bg {
-              fill: none;
-              stroke: rgba(255, 255, 255, 0.1);
-              stroke-width: 3;
-            }
-
-            .context-ring-fill {
-              fill: none;
-              stroke-width: 3;
-              stroke-linecap: round;
-              transition: stroke-dashoffset 0.3s ease;
-            }
-          }
-
-          .context-percent {
-            position: absolute;
-            font-size: 8px;
-            color: #e0e6ed;
-            font-weight: 500;
-            line-height: 1;
-          }
-        }
-        .context-popup {
-          position: absolute;
-          bottom: 42px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          padding: 14px 16px;
-
-          &::before {
-            content: '';
-            position: absolute;
-            bottom: -12px;
-            left: 0;
-            right: 0;
-            height: 12px;
-          }
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .context-popup-title {
-            font-size: 13px;
-            color: #8b949e;
-            margin-bottom: 8px;
-          }
-
-          .context-popup-info {
-            display: flex;
-            align-items: baseline;
-            gap: 2px;
-            margin-bottom: 10px;
-
-            .context-used {
-              font-size: 18px;
-              font-weight: 600;
-              color: #e0e6ed;
-            }
-
-            .context-sep {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .context-max {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .context-pct {
-              font-size: 13px;
-              color: #8b949e;
-            }
-          }
-
-          .context-popup-bar-track {
-            width: 100%;
-            height: 6px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 3px;
-            overflow: hidden;
-
-            .context-popup-bar-fill {
-              height: 100%;
-              border-radius: 3px;
-              transition: width 0.3s ease;
-            }
-          }
-
-          .context-breakdown {
-            margin-top: 10px;
-            padding-top: 10px;
-            border-top: 1px solid rgba(255, 255, 255, 0.08);
-
-            .breakdown-item {
-              display: flex;
-              justify-content: space-between;
-              align-items: center;
-              padding: 3px 0;
-
-              .breakdown-label {
-                font-size: 12px;
-                color: #8b949e;
-              }
-
-              .breakdown-value {
-                font-size: 12px;
-                color: #e0e6ed;
-              }
-            }
-          }
-        }
-        .btn-usage-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-usage {
-          width: 30px;
-          height: 30px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-          position: relative;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.1);
-          }
-
-          .usage-ring {
-            width: 24px;
-            height: 24px;
-            transform: rotate(-90deg);
-
-            .usage-ring-bg {
-              fill: none;
-              stroke: rgba(255, 255, 255, 0.1);
-              stroke-width: 3;
-            }
-
-            .usage-ring-fill {
-              fill: none;
-              stroke-width: 3;
-              stroke-linecap: round;
-              transition: stroke-dashoffset 0.3s ease;
-            }
-          }
-
-          .usage-symbol {
-            position: absolute;
-            font-size: 9px;
-            color: #e0e6ed;
-            font-weight: 600;
-            line-height: 1;
-          }
-        }
-        .usage-popup {
-          position: absolute;
-          bottom: 42px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          padding: 14px 16px;
-
-          &::before {
-            content: '';
-            position: absolute;
-            bottom: -12px;
-            left: 0;
-            right: 0;
-            height: 12px;
-          }
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .usage-popup-title {
-            font-size: 13px;
-            color: #8b949e;
-            margin-bottom: 8px;
-          }
-
-          .usage-popup-info {
-            display: flex;
-            align-items: baseline;
-            gap: 2px;
-            margin-bottom: 10px;
-
-            .usage-spent {
-              font-size: 18px;
-              font-weight: 600;
-              color: #e0e6ed;
-            }
-
-            .usage-sep {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .usage-limit {
-              font-size: 14px;
-              color: #8b949e;
-            }
-
-            .usage-pct {
-              font-size: 13px;
-              color: #8b949e;
-            }
-          }
-
-          .usage-popup-bar-track {
-            width: 100%;
-            height: 6px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 3px;
-            overflow: hidden;
-
-            .usage-popup-bar-fill {
-              height: 100%;
-              border-radius: 3px;
-              transition: width 0.3s ease;
-            }
-          }
-
-          .usage-remaining {
-            margin-top: 6px;
-            font-size: 14px;
-            color: #8b949e;
-          }
-
-          .usage-breakdown {
-            margin-top: 10px;
-            padding-top: 10px;
-            border-top: 1px solid rgba(255, 255, 255, 0.08);
-
-            .usage-model-item {
-              padding: 6px 0;
-              border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
-
-              &:last-child {
-                border-bottom: none;
-                padding-bottom: 0;
-              }
-
-              .usage-model-header {
-                display: flex;
-                justify-content: space-between;
-                align-items: center;
-                gap: 12px;
-                margin-bottom: 4px;
-
-                .usage-model-name {
-                  font-size: 14px;
-                  font-weight: 600;
-                  color: #e0e6ed;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                }
-
-                .usage-model-cost {
-                  font-size: 14px;
-                  font-weight: 600;
-                  color: #3a8fd4;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-              }
-
-              .usage-model-row {
-                display: flex;
-                justify-content: space-between;
-                align-items: center;
-                padding: 2px 0;
-
-                .usage-model-label {
-                  font-size: 12px;
-                  color: #8b949e;
-                }
-
-                .usage-model-value {
-                  font-size: 12px;
-                  color: #e0e6ed;
-                }
-              }
-            }
-          }
-        }
-        .btn-model-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-model {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-          border: 1px solid rgba(255, 255, 255, 0.12);
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .model-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 220px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .model-popup-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .model-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .model-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .model-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .model-item-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .btn-think-wrapper {
-          position: relative;
-          display: flex;
-          align-items: center;
-        }
-        .btn-think {
-          height: 28px;
-          padding: 0 10px;
-          border-radius: 6px;
-          display: flex;
-          align-items: center;
-          gap: 4px;
-          cursor: pointer;
-          transition: all 0.2s;
-
-          &:hover {
-            border-color: #3a8fd4;
-            background: rgba(10, 132, 255, 0.1);
-          }
-        }
-        .think-popup {
-          position: absolute;
-          bottom: 38px;
-          left: 50%;
-          transform: translateX(-50%);
-          background: #0a2a3f;
-          border: 1px solid #1a4a6f;
-          border-radius: 8px;
-          min-width: 200px;
-          z-index: 1000;
-          box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-          .think-popup-item {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 10px 12px;
-            cursor: pointer;
-            transition: background 0.2s;
-            border-radius: 6px;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.15);
-            }
-
-            &.active {
-              background: rgba(10, 132, 255, 0.1);
-            }
-
-            .think-item-main {
-              display: flex;
-              flex-direction: column;
-              gap: 2px;
-
-              .think-item-label {
-                font-size: 13px;
-                color: #e0e6ed;
-                white-space: nowrap;
-              }
-
-              .think-item-desc {
-                font-size: 11px;
-                color: #8b949e;
-                white-space: nowrap;
-              }
-            }
-
-            .think-item-check {
-              width: 16px;
-              height: 16px;
-              flex-shrink: 0;
-              display: flex;
-              align-items: center;
-              justify-content: center;
-              visibility: hidden;
-
-              &.visible {
-                visibility: visible;
-              }
-
-              &::after {
-                content: '✓';
-                font-size: 12px;
-                color: #3a8fd4;
-              }
-            }
-          }
-        }
-        .btn-send {
-          width: 32px;
-          height: 32px;
-          display: flex;
-          align-items: center;
-          justify-content: center;
-          cursor: pointer;
-          opacity: 1;
-          transition: opacity 0.2s;
-
-          &.disabled {
-            opacity: 0.4;
-            cursor: not-allowed;
-          }
-
-          .send-icon {
-            width: 24px;
-            height: 24px;
-            background-image: var(--img-chat-send-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-
-            &.stop-icon {
-              background-image: var(--img-chat-stop-icon);
-            }
-          }
-        }
-      }
-    }
-
-    .session-chips {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      margin-bottom: 5px;
-
-      .session-chip {
-        display: inline-flex;
-        align-items: center;
-        gap: 4px;
-        padding: 3px 6px 3px 4px;
-        background: rgba(10, 132, 255, 0.15);
-        border: 1px solid rgba(10, 132, 255, 0.3);
-        border-radius: 4px;
-        font-size: 12px;
-        color: #e0e6ed;
-        cursor: default;
-
-        .session-chip-icon {
-          width: 16px;
-          height: 16px;
-          background-image: var(--img-chat-popup-session-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-
-        .session-chip-name {
-          max-width: 150px;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          white-space: nowrap;
-        }
-
-        .session-chip-remove {
-          font-size: 10px;
-          color: #8b949e;
-          cursor: pointer;
-          padding: 0 2px;
-          transition: color 0.2s;
-
-          &:hover {
-            color: #ff4d4f;
-          }
-        }
-      }
-    }
-
-    .skill-chips {
-      display: flex;
-      flex-wrap: wrap;
-      gap: 6px;
-      margin-bottom: 5px;
-
-      .skill-chip {
-        display: inline-flex;
-        align-items: center;
-        gap: 4px;
-        padding: 3px 6px 3px 4px;
-        background: rgba(10, 132, 255, 0.15);
-        border: 1px solid rgba(10, 132, 255, 0.3);
-        border-radius: 4px;
-        font-size: 12px;
-        color: #e0e6ed;
-        cursor: default;
-
-        .skill-chip-icon {
-          width: 16px;
-          height: 16px;
-          background-image: var(--img-chat-options-skill-icon);
-          background-repeat: no-repeat;
-          background-size: 100% 100%;
-          flex-shrink: 0;
-        }
-
-        .skill-chip-name {
-          max-width: 150px;
-          overflow: hidden;
-          text-overflow: ellipsis;
-          white-space: nowrap;
-        }
-
-        .skill-chip-remove {
-          font-size: 10px;
-          color: #8b949e;
-          cursor: pointer;
-          padding: 0 2px;
-          transition: color 0.2s;
-
-          &:hover {
-            color: #ff4d4f;
-          }
-        }
-      }
-    }
-
-    .session-popup {
-      position: absolute;
-      bottom: 100%;
-      left: 0;
-      right: 0;
-      margin-bottom: 8px;
-      background: #0a2a3f;
-      border: 1px solid #1a4a6f;
-      border-radius: 8px;
-      max-height: 300px;
-      display: flex;
-      flex-direction: column;
-      z-index: 1000;
-      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-      .session-popup-header {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-        padding: 10px 14px 8px;
-        font-size: 13px;
-        color: #e0e6ed;
-        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .session-count {
-          font-size: 12px;
-          color: #3a8fd4;
-          font-weight: 500;
-        }
-      }
-
-      .session-list {
-        flex: 1;
-        overflow-y: auto;
-        padding: 6px;
-
-        .session-item {
-          display: flex;
-          align-items: center;
-          gap: 10px;
-          padding: 8px 10px;
-          cursor: pointer;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.15);
-          }
-
-          &.active {
-            background: rgba(10, 132, 255, 0.2);
-          }
-
-          &.selected {
-            background: rgba(10, 132, 255, 0.12);
-          }
-
-          &.disabled {
-            opacity: 0.4;
-            cursor: not-allowed;
-
-            &:hover {
-              background: transparent;
-            }
-          }
-
-          .session-checkbox {
-            width: 16px;
-            height: 16px;
-            border: 1.5px solid rgba(255, 255, 255, 0.25);
-            border-radius: 3px;
-            flex-shrink: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            transition: all 0.2s;
-
-            &.checked {
-              background: #3a8fd4;
-              border-color: #3a8fd4;
-            }
-
-            .check-mark {
-              font-size: 11px;
-              color: #fff;
-              line-height: 1;
-            }
-          }
-
-          .session-item-icon {
-            width: 18px;
-            height: 18px;
-            background-image: var(--img-chat-popup-session-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .session-item-name {
-            font-size: 13px;
-            color: #e0e6ed;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-          }
-        }
-
-        .session-empty {
-          text-align: center;
-          color: #8b949e;
-          font-size: 13px;
-          padding: 20px 0;
-        }
-      }
-
-      .session-popup-footer {
-        padding: 8px 14px;
-        font-size: 12px;
-        color: #8b949e;
-        text-align: center;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .max-hint {
-          color: #faad14;
-        }
-      }
-    }
-
-    .skill-popup {
-      position: absolute;
-      bottom: 100%;
-      left: 0;
-      right: 0;
-      margin-bottom: 8px;
-      background: #0a2a3f;
-      border: 1px solid #1a4a6f;
-      border-radius: 8px;
-      max-height: 300px;
-      display: flex;
-      flex-direction: column;
-      z-index: 1000;
-      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
-
-      .skill-popup-header {
-        display: flex;
-        justify-content: space-between;
-        align-items: center;
-        padding: 10px 14px 8px;
-        font-size: 13px;
-        color: #e0e6ed;
-        border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-
-        .skill-count {
-          font-size: 12px;
-          color: #3a8fd4;
-          font-weight: 500;
-        }
-      }
-
-      .skill-list {
-        flex: 1;
-        overflow-y: auto;
-        padding: 6px;
-
-        .skill-item {
-          display: flex;
-          align-items: center;
-          gap: 10px;
-          padding: 8px 10px;
-          cursor: pointer;
-          border-radius: 6px;
-          transition: background 0.2s;
-
-          &:hover {
-            background: rgba(10, 132, 255, 0.15);
-          }
-
-          &.active {
-            background: rgba(10, 132, 255, 0.2);
-          }
-
-          &.selected {
-            background: rgba(10, 132, 255, 0.12);
-          }
-
-          .skill-item-checkbox {
-            width: 16px;
-            height: 16px;
-            border: 1.5px solid rgba(255, 255, 255, 0.25);
-            border-radius: 3px;
-            flex-shrink: 0;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            transition: all 0.2s;
-
-            &.checked {
-              background: #3a8fd4;
-              border-color: #3a8fd4;
-            }
-
-            .check-mark {
-              font-size: 11px;
-              color: #fff;
-              line-height: 1;
-            }
-          }
-
-          .skill-item-icon {
-            width: 18px;
-            height: 18px;
-            background-image: var(--img-chat-options-skill-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .skill-item-name {
-            font-size: 13px;
-            color: #e0e6ed;
-            overflow: hidden;
-            text-overflow: ellipsis;
-            white-space: nowrap;
-          }
-        }
-
-        .skill-empty {
-          text-align: center;
-          color: #8b949e;
-          font-size: 13px;
-          padding: 20px 0;
-        }
-      }
-
-      .skill-popup-footer {
-        padding: 8px 14px;
-        font-size: 12px;
-        color: #8b949e;
-        text-align: center;
-        border-top: 1px solid rgba(63, 80, 106, 0.3);
-        flex-shrink: 0;
-      }
-    }
-  }
-</style>

+ 0 - 2568
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -1,2568 +0,0 @@
-<template>
-  <div class="chat-messages-container">
-    <!-- 左侧对话缩略导航 -->
-    <div
-      v-if="qaPairs.length > 0"
-      class="conversation-nav"
-      ref="navRef"
-      @mouseenter="onNavMove"
-      @mousemove="onNavMove"
-      @mouseleave="onNavLeave"
-      @scroll="onNavScroll"
-    >
-      <div
-        v-for="(pair, pIdx) in qaPairs"
-        :key="pIdx"
-        :class="['nav-line', { 'nav-line-hot': nearestLineIndex === pIdx, active: activeMessage && activeMessage === messages[pair.startIndex] }]"
-        :style="{ transform: `scaleX(${navLineScale(pIdx)})` }"
-        @click="scrollToPair(pair)"
-      ></div>
-    </div>
-    <Transition name="tooltip-fade">
-      <div v-if="hoveredPair" ref="tooltipRef" class="conversation-tooltip" :style="{ top: `${tooltipTop}px` }">
-        <span class="tooltip-arrow" :style="{ top: `${tooltipArrowTop}px` }"></span>
-        <div v-if="hoveredPair.question" class="tooltip-row">
-          <span class="tooltip-label tooltip-label-q">问</span>
-          <span class="tooltip-text">{{ hoveredPair.question }}</span>
-        </div>
-        <div v-if="hoveredPair.answer" class="tooltip-row">
-          <span class="tooltip-label tooltip-label-a">答</span>
-          <span class="tooltip-text">{{ hoveredPair.answer }}</span>
-        </div>
-      </div>
-    </Transition>
-    <div class="chat-messages" ref="messagesRef">
-      <!-- 新对话缺省图 -->
-      <div v-if="messages.length === 0" class="welcome-empty">
-        <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
-      </div>
-      <div
-        v-for="(message, index) in messages"
-        :key="index"
-        :class="['message-wrapper', message.type, { 'answer-hidden': isHiddenAnswer(index) }]"
-        @mouseleave="copiedMessageIndex = -1"
-      >
-        <!-- AI消息 -->
-        <div v-if="message.type === 'ai'" class="ai-message">
-          <div class="message-body">
-            <!-- 思考过程折叠卡片 -->
-            <div v-if="message.thinkingSteps && message.thinkingSteps.length > 0" class="thinking-card">
-              <div class="thinking-duration-bar" @click="toggleThinkingCard(index)">
-                <span v-if="getDurationText(message)" class="thinking-duration">{{ getDurationText(message) }}</span>
-                <div :class="['thinking-arrow', { expanded: !collapsedThinking[index] }]">
-                  <img :src="getCssUrl('--img-chat-arrow-icon')" width="12" height="12" />
-                </div>
-              </div>
-              <Transition name="collapse">
-                <div v-show="!collapsedThinking[index]" class="thinking-collapse-wrap">
-                  <div class="thinking-header" @click="toggleThinkingContent(index)">
-                    <div class="thinking-header-left">
-                      <div class="thinking-icon"></div>
-                      <span class="thinking-title">思考过程</span>
-                    </div>
-                    <div v-if="message.thinkingContent" :class="['thinking-arrow', { expanded: !collapsedThinkingContent[index] }]">
-                      <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
-                    </div>
-                  </div>
-
-                  <!-- thinking_token 思考内容 -->
-                  <Transition name="collapse">
-                    <div v-if="message.thinkingContent && !collapsedThinkingContent[index]" class="thinking-content-body">
-                      {{ message.thinkingContent }}
-                    </div>
-                  </Transition>
-
-                  <div class="thinking-body">
-                    <div
-                      v-for="(group, groupIdx) in groupStepsByAgent(message.thinkingSteps)"
-                      :key="groupIdx"
-                      class="agent-group"
-                      @click="handleAgentGroupClick(index, group)"
-                    >
-                      <div class="agent-group-header">
-                        <div class="agent-group-header-left">
-                          <div class="agent-group-icon"></div>
-                          <span class="agent-group-name">{{ group.agent }}</span>
-                        </div>
-                        <span v-if="getLatestStepMessage(group)" class="agent-group-latest">
-                          {{ getLatestStepMessage(group) }}
-                        </span>
-                        <div class="thinking-arrow agent-arrow">
-                          <img :src="getCssUrl('--img-chat-next-icon')" width="10" height="10" />
-                        </div>
-                      </div>
-                    </div>
-                  </div>
-                </div>
-              </Transition>
-            </div>
-
-            <div
-              v-if="message.content"
-              class="message-text"
-              v-html="
-                renderMarkdown(
-                  message.content,
-                  tableIcons,
-                  message.citations?.items?.length ? Math.max(...message.citations.items.map((i) => i.id)) : undefined
-                )
-              "
-              @click="handleFootnoteClick($event, index)"
-            ></div>
-
-            <!-- 数据来源卡片 citations -->
-            <div v-if="message.citations" class="citations-card">
-              <div v-if="message.citations.model_only" class="citations-header citations-header-simple">
-                <span class="citations-title">本回答依据模型知识生成,仅供参考</span>
-              </div>
-              <template v-else-if="message.citations.items?.length">
-                <div class="citations-header" @click="toggleCitations(index)">
-                  <div class="citations-header-left">
-                    <span class="citations-title">数据来源</span>
-                    <span class="citations-count">{{ message.citations.items.length }}</span>
-                  </div>
-                  <div :class="['thinking-arrow', { expanded: !collapsedCitations[index] }]">
-                    <img :src="getCssUrl('--img-chat-arrow-icon')" width="10" height="10" />
-                  </div>
-                </div>
-                <div v-show="!collapsedCitations[index]" class="citations-body">
-                  <div v-for="item in message.citations.items" :key="item.id" :id="`citation-${index}-${item.id}`" class="citation-item">
-                    <div class="citation-item-row">
-                      <span :class="['citation-badge', `citation-badge-${item.type}`]">{{ item.type_cn }}</span>
-                      <span class="citation-title-text">{{ item.title }}</span>
-                      <span v-if="item.search_url" class="citation-link">知识库检索</span>
-                      <span v-if="item.url" class="citation-link">链接</span>
-                    </div>
-                    <div class="citation-item-attrs">
-                      <span v-if="item.source_system" class="citation-attr">{{ item.source_system }}</span>
-                      <span v-if="item.fetched_at" class="citation-attr">{{ item.fetched_at }}</span>
-                    </div>
-                    <div v-if="item.snippet" class="citation-snippet" :title="item.snippet">{{ item.snippet }}</div>
-                  </div>
-                </div>
-              </template>
-            </div>
-
-            <div v-if="message.wordDownloadUrl" class="word-download-area">
-              <a-button type="link" @click="emit('download-word', message.wordDownloadUrl)"> 下载报告文件 (.docx) </a-button>
-            </div>
-
-            <!-- 文件操作卡片 -->
-            <div v-if="message.downloadFiles?.length" class="download-file-cards">
-              <div v-for="(file, fIdx) in message.downloadFiles" :key="fIdx" class="download-file-card" @click="handlePreviewFile(file)">
-                <div class="file-card-info">
-                  <div class="file-card-icon" :style="{ backgroundImage: getFileIconVar(file.filename) }"></div>
-                  <div class="file-card-text">
-                    <span class="file-card-name">{{ file.filename }}</span>
-                    <span class="file-card-hint">点击预览文件</span>
-                  </div>
-                </div>
-                <div class="file-card-actions">
-                  <button class="file-action-btn download-btn" @click.stop="handleDownloadFile(file.url, file.filename)">
-                    <img :src="getCssUrl('--img-chat-download-icon-btn')" width="14" height="14" />
-                    <span>下载</span>
-                  </button>
-                  <button v-if="isEditableFile(file.filename)" class="file-action-btn edit-btn" @click.stop="handleEditFile(file.url, file.filename)">
-                    <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
-                    <span>编辑</span>
-                  </button>
-                </div>
-              </div>
-            </div>
-            <div v-show="message.isLoading" class="loading-dots">
-              <span class="dot"></span>
-              <span class="dot"></span>
-              <span class="dot"></span>
-            </div>
-
-            <!-- ask_user 交互提问 UI(分步向导):ask_user 存在但 questions 缺失时不渲染,避免模板访问异常 -->
-            <div v-if="message.isPendingApproval && message.pendingApprovalData?.ask_user?.questions?.length" class="approval-card">
-              <div class="approval-icon"></div>
-              <div class="approval-content">
-                <div class="approval-title">{{ message.pendingApprovalData?.message }}</div>
-
-                <!-- 计划内容 -->
-                <div v-if="message.pendingApprovalData?.plan" class="approval-plan">
-                  <div class="approval-plan-label">执行计划</div>
-                  <div class="approval-plan-content">{{ message.pendingApprovalData.plan }}</div>
-                </div>
-
-                <!-- 步骤指示器 -->
-                <div v-if="message.pendingApprovalData.ask_user.questions.length > 1" class="ask-user-stepper">
-                  <div
-                    v-for="(_, sIdx) in message.pendingApprovalData.ask_user.questions"
-                    :key="sIdx"
-                    :class="[
-                      'ask-user-step-dot',
-                      {
-                        active: sIdx === (askUserCurrentStep[index] ?? 0),
-                        completed: sIdx < (askUserCurrentStep[index] ?? 0) || !!(askUserAnswers[index]?.[sIdx] && askUserAnswers[index][sIdx].trim()),
-                      },
-                    ]"
-                    @click="askUserCurrentStep[index] = sIdx"
-                  ></div>
-                  <span class="ask-user-step-text"
-                    >{{ (askUserCurrentStep[index] ?? 0) + 1 }} / {{ message.pendingApprovalData.ask_user.questions.length }}</span
-                  >
-                </div>
-
-                <!-- 当前问题 -->
-                <div v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]" class="ask-user-question">
-                  <div class="ask-user-question-text">
-                    {{ message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].question }}
-                    <span
-                      v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].required !== false"
-                      class="ask-user-required"
-                      >*</span
-                    >
-                  </div>
-
-                  <!-- multiple_choice 类型 -->
-                  <div
-                    v-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'multiple_choice'"
-                    class="ask-user-choices"
-                  >
-                    <div
-                      v-for="(choice, cIdx) in message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].choices"
-                      :key="cIdx"
-                      :class="[
-                        'ask-user-choice',
-                        {
-                          selected:
-                            askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0] === getChoiceValue(choice) &&
-                            !askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0],
-                        },
-                      ]"
-                      @click="selectChoice(index, askUserCurrentStep[index] ?? 0, choice, message.pendingApprovalData.ask_user.questions.length)"
-                    >
-                      <span>{{ getChoiceValue(choice) }}</span>
-                    </div>
-                    <!-- Other 选项 -->
-                    <div
-                      :class="['ask-user-choice', { selected: askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0] }]"
-                      @click="selectOther(index, askUserCurrentStep[index] ?? 0)"
-                    >
-                      <span>其他</span>
-                    </div>
-                    <input
-                      v-if="askUserOtherSelected[index]?.[askUserCurrentStep[index] ?? 0]"
-                      class="ask-user-other-input"
-                      placeholder="请输入..."
-                      :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
-                      @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLInputElement).value"
-                    />
-                  </div>
-
-                  <!-- text 类型 -->
-                  <div
-                    v-else-if="message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0].type === 'text'"
-                    class="ask-user-text"
-                  >
-                    <textarea
-                      class="ask-user-textarea"
-                      placeholder="请输入..."
-                      :value="askUserAnswers[index]?.[askUserCurrentStep[index] ?? 0]"
-                      @input="askUserAnswers[index][askUserCurrentStep[index] ?? 0] = ($event.target as HTMLTextAreaElement).value"
-                    ></textarea>
-                  </div>
-                </div>
-
-                <!-- 操作按钮 -->
-                <div class="approval-actions">
-                  <button v-if="(askUserCurrentStep[index] ?? 0) > 0" class="approval-btn nav-btn" @click="goPrevStep(index)">上一步</button>
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) < message.pendingApprovalData.ask_user.questions.length - 1"
-                    class="approval-btn approve-btn"
-                    :disabled="
-                      !isCurrentStepAnswered(
-                        index,
-                        askUserCurrentStep[index] ?? 0,
-                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
-                      )
-                    "
-                    @click="goNextStep(index, message.pendingApprovalData.ask_user.questions.length)"
-                    >下一步</button
-                  >
-                  <button
-                    v-if="(askUserCurrentStep[index] ?? 0) === message.pendingApprovalData.ask_user.questions.length - 1"
-                    class="approval-btn approve-btn"
-                    :disabled="
-                      !isCurrentStepAnswered(
-                        index,
-                        askUserCurrentStep[index] ?? 0,
-                        message.pendingApprovalData.ask_user.questions[askUserCurrentStep[index] ?? 0]
-                      )
-                    "
-                    @click="handleSubmitAskUser(index)"
-                    >提交</button
-                  >
-                  <button class="approval-btn reject-btn" @click="handleCancelAskUser">取消</button>
-                </div>
-              </div>
-            </div>
-          </div>
-          <!-- AI消息操作栏 -->
-          <div class="message-actions">
-            <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
-              <img :src="copyIconUrl(index)" width="14" height="14" />
-            </div>
-            <div v-if="canRegenerate(index, message)" class="action-btn" @click="emit('regenerate')" title="重新生成">
-              <img :src="getCssUrl('--img-chat-regenerate-icon')" width="14" height="14" />
-            </div>
-            <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
-              <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
-            </div>
-            <div v-if="showAnswerSwitcher(index)" class="answer-switcher">
-              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canPrevAnswer(index) }" @click="prevAnswer(index)" title="上一个回答">
-                <img :src="getCssUrl('--img-chat-prev-icon')" width="14" height="14" />
-              </div>
-              <span class="answer-switcher-text">{{ answerPositionText(index) }}</span>
-              <div class="action-btn answer-nav-btn" :class="{ 'is-disabled': !canNextAnswer(index) }" @click="nextAnswer(index)" title="下一个回答">
-                <img :src="getCssUrl('--img-chat-next-icon')" width="14" height="14" />
-              </div>
-            </div>
-            <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
-          </div>
-        </div>
-
-        <!-- 用户消息 -->
-        <div v-else class="user-message">
-          <div class="message-body">
-            <template v-if="editingIndex === index">
-              <textarea
-                v-model="editDraft"
-                class="message-edit-textarea"
-                spellcheck="false"
-                @keydown.enter.exact.prevent="submitEdit(index)"
-                @keydown.escape.prevent="cancelEdit"
-              ></textarea>
-              <div class="message-edit-actions">
-                <button class="message-edit-btn confirm" @click="submitEdit(index)">重发</button>
-                <button class="message-edit-btn cancel" @click="cancelEdit">取消</button>
-              </div>
-            </template>
-            <template v-else>
-              <div class="message-text" v-html="renderMarkdown(message.content, tableIcons)"></div>
-              <div v-if="message.attachedFile" class="attached-file-card" @click="emit('file-preview', message.attachedFile)">
-                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(message.attachedFile.name) }"></div>
-                <div class="file-info-card">
-                  <div class="file-name-card">{{ message.attachedFile.name }}</div>
-                  <div class="file-size-card">{{ formatFileSize(message.attachedFile.size) }}</div>
-                </div>
-              </div>
-            </template>
-          </div>
-          <!-- 用户消息操作栏 -->
-          <div class="message-actions">
-            <div class="action-btn" @click="copyMessageContent(message.content, index)" :title="copiedMessageIndex === index ? '复制成功' : '复制'">
-              <img :src="copyIconUrl(index)" width="14" height="14" />
-            </div>
-            <div v-if="canEdit(index, message)" class="action-btn" @click="startEdit(index)" title="编辑">
-              <img :src="getCssUrl('--img-chat-edit-icon')" width="14" height="14" />
-            </div>
-            <div v-if="message.id" class="action-btn" @click="emit('delete-message', message)" title="删除消息">
-              <img :src="getCssUrl('--img-chat-delete-icon')" width="14" height="14" />
-            </div>
-            <span v-if="message.createdAt" class="action-time">{{ formatFullTime(message.createdAt) }}</span>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- 回到底部按钮 -->
-    <div v-if="!isAtBottom" class="scroll-to-bottom" @click="scrollToBottom">
-      <div class="scroll-to-bottom-icon"></div>
-    </div>
-  </div>
-</template>
-
-<script setup lang="ts">
-  import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
-  import dayjs from 'dayjs';
-  import {
-    renderMarkdown,
-    formatFileSize,
-    tableHtmlToMarkdown,
-    tableHtmlToCsv,
-    isMdFile,
-    isTextFile,
-    getFileIconVar,
-    fetchPreviewFile,
-    groupStepsByAgent,
-  } from './utils';
-  import type { AgentGroup } from './utils';
-  import { getToken } from '/@/utils/auth';
-  import { message } from 'ant-design-vue';
-  import type { Message, AttachedFile } from './types';
-  // 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) => {
-    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'),
-    preview: getCssUrl('--img-chat-preview-icon'),
-    copySuccess: getCssUrl('--img-chat-copy-success-icon'),
-  };
-
-  const copyIconUrl = computed(() => {
-    const icon = getCssUrl('--img-chat-copy-icon');
-    const successIcon = getCssUrl('--img-chat-copy-success-icon');
-    return (index: number) => (copiedMessageIndex.value === index ? successIcon : icon);
-  });
-
-  // 全局计时器 - 每秒刷新一次,用于实时显示思考耗时
-  const nowTs = ref(Date.now());
-  let timerInterval: ReturnType<typeof setInterval> | null = null;
-
-  const getDurationText = (message: Message): string => {
-    // 优先使用后端返回的 duration_ms(已包含 baseDurationMs 累加)
-    if (message.durationMs) {
-      const totalSec = Math.round(message.durationMs / 1000);
-      const min = Math.floor(totalSec / 60);
-      const sec = totalSec % 60;
-      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
-    }
-    // 审批等待中 - 暂停显示中断前的累计时长
-    if (message.isPendingApproval && message.pendingApprovalData?.elapsedMs) {
-      const totalSec = Math.round(message.pendingApprovalData.elapsedMs / 1000);
-      const min = Math.floor(totalSec / 60);
-      const sec = totalSec % 60;
-      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
-    }
-    // 前端实时计时(含中断前累计时长)
-    if (message.generateStartTime && message.isLoading) {
-      const baseMs = message.baseDurationMs || 0;
-      const elapsed = Math.floor((nowTs.value - message.generateStartTime + baseMs) / 1000);
-      const min = Math.floor(elapsed / 60);
-      const sec = elapsed % 60;
-      return `已工作 ${min}分${sec.toString().padStart(2, '0')}秒`;
-    }
-    return '';
-  };
-
-  const props = defineProps<{
-    messages: Message[];
-  }>();
-
-  export interface AgentDetailData {
-    messageIndex: number;
-    agent: string;
-    agentId?: string;
-  }
-
-  const emit = defineEmits<{
-    (e: 'file-preview', file: AttachedFile): void;
-    (e: 'download-word', url: string): void;
-    (e: 'open-file-preview', file: AttachedFile): void;
-    (e: 'ask-user-submit', answers: string[]): void;
-    (e: 'ask-user-cancel'): void;
-    (e: 'agent-detail-click', data: AgentDetailData): void;
-    (e: 'open-markdown-tab', payload: { title: string; content: string }): void;
-    (e: 'delete-message', message: Message): void;
-    (e: 'regenerate'): void;
-    (e: 'edit-resend', index: number, newContent: string): void;
-  }>();
-
-  // 用户消息内联编辑状态
-  const editingIndex = ref(-1);
-  const editDraft = ref('');
-
-  // 剥离注入的技能/会话引用 <img> HTML,用于编辑时回填原始文本
-  const stripInjectedHtml = (content: string): string =>
-    (content || '')
-      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
-      .replace(/<img[^>]*>/g, '')
-      .trim();
-
-  // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
-  interface AnswerGroup {
-    indices: number[];
-  }
-
-  // 将连续的 AI 消息划分为若干“回答组”
-  const answerGroups = computed<AnswerGroup[]>(() => {
-    const groups: AnswerGroup[] = [];
-    let current: AnswerGroup | null = null;
-    props.messages.forEach((msg, index) => {
-      if (msg.type === 'ai') {
-        if (!current) {
-          current = { indices: [] };
-          groups.push(current);
-        }
-        current.indices.push(index);
-      } else {
-        current = null;
-      }
-    });
-    return groups;
-  });
-
-  // 每条 AI 消息 → 所属组 key(组首条 index)、组内偏移、组大小
-  const aiGroupMeta = computed(() => {
-    const meta = new Map<number, { groupKey: number; offset: number; groupLen: number }>();
-    for (const group of answerGroups.value) {
-      const groupKey = group.indices[0];
-      const groupLen = group.indices.length;
-      group.indices.forEach((idx, offset) => {
-        meta.set(idx, { groupKey, offset, groupLen });
-      });
-    }
-    return meta;
-  });
-
-  // 每个回答组当前选中的回答偏移(默认组内最后一条,即最新生成)
-  const activeAnswerOffset = reactive<Record<number, number>>({});
-
-  const currentAnswerOffset = (groupKey: number, groupLen: number): number => {
-    const val = activeAnswerOffset[groupKey];
-    if (val == null) return groupLen - 1;
-    return Math.min(Math.max(val, 0), groupLen - 1);
-  };
-
-  // 末尾回答组内是否存在正在生成(isLoading)的消息
-  const lastAnswerGroupHasLoading = computed(() => {
-    const msgs = props.messages;
-    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    for (let i = start; i < msgs.length; i++) {
-      if (msgs[i].isLoading) return true;
-    }
-    return false;
-  });
-
-  // 新回答开始生成(false → true)时,自动切换到最新一条正在生成的消息
-  watch(lastAnswerGroupHasLoading, (loading, wasLoading) => {
-    if (loading && !wasLoading) {
-      const msgs = props.messages;
-      let start = msgs.length - 1;
-      while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-      delete activeAnswerOffset[start];
-    }
-  });
-
-  // 该 AI 消息是否应被折叠隐藏(属于多回答组且非当前选中项)
-  const isHiddenAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta || meta.groupLen <= 1) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) !== meta.offset;
-  };
-
-  // 该消息是否为当前选中显示的回答(供父组件标记折叠回答中的条目,如胶囊面板的智能体记录)
-  const isAnswerVisible = (index: number): boolean => !isHiddenAnswer(index);
-
-  // 该 AI 消息是否显示回答切换器(多回答组且为当前选中项)
-  const showAnswerSwitcher = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta || meta.groupLen <= 1) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) === meta.offset;
-  };
-
-  const answerPositionText = (index: number): string => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return '';
-    return `${currentAnswerOffset(meta.groupKey, meta.groupLen) + 1}/${meta.groupLen}`;
-  };
-
-  const canPrevAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) > 0;
-  };
-
-  const canNextAnswer = (index: number): boolean => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return false;
-    return currentAnswerOffset(meta.groupKey, meta.groupLen) < meta.groupLen - 1;
-  };
-
-  const prevAnswer = (index: number) => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return;
-    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
-    if (cur > 0) activeAnswerOffset[meta.groupKey] = cur - 1;
-  };
-
-  const nextAnswer = (index: number) => {
-    const meta = aiGroupMeta.value.get(index);
-    if (!meta) return;
-    const cur = currentAnswerOffset(meta.groupKey, meta.groupLen);
-    if (cur < meta.groupLen - 1) activeAnswerOffset[meta.groupKey] = cur + 1;
-  };
-
-  // 该消息是否属于“末尾连续 AI 组”:重新生成按钮只对最新一轮回答(含折叠组内任意一条)显示
-  const isInLastAnswerGroup = (index: number): boolean => {
-    const msgs = props.messages;
-    if (!msgs.length || msgs[msgs.length - 1].type !== 'ai') return false;
-    if (msgs[index]?.type !== 'ai') return false;
-    let start = msgs.length - 1;
-    while (start - 1 >= 0 && msgs[start - 1].type === 'ai') start--;
-    return index >= start;
-  };
-
-  const canRegenerate = (index: number, message: Message): boolean => isInLastAnswerGroup(index) && !message.isLoading && !message.isPendingApproval;
-
-  // 最后一条用户消息的索引(编辑重发只作用于最后一条用户消息,而非整段对话的最后一条)
-  const lastUserMessageIndex = computed(() => {
-    for (let i = props.messages.length - 1; i >= 0; i--) {
-      if (props.messages[i].type === 'user') return i;
-    }
-    return -1;
-  });
-
-  const canEdit = (index: number, message: Message): boolean =>
-    message.type === 'user' && index === lastUserMessageIndex.value && editingIndex.value !== index;
-
-  const startEdit = (index: number) => {
-    const msg = props.messages[index];
-    editingIndex.value = index;
-    editDraft.value = msg.rawInput || stripInjectedHtml(msg.content);
-  };
-
-  const cancelEdit = () => {
-    editingIndex.value = -1;
-    editDraft.value = '';
-  };
-
-  const submitEdit = (index: number) => {
-    const content = editDraft.value;
-    editingIndex.value = -1;
-    editDraft.value = '';
-    emit('edit-resend', index, content);
-  };
-
-  const isEditableFile = (filename: string) => isMdFile(filename) || isTextFile(filename);
-
-  const handleDownloadFile = (url: string, filename: string) => {
-    if (!url) return;
-    const a = document.createElement('a');
-    a.href = url;
-    a.download = filename;
-    document.body.appendChild(a);
-    a.click();
-    document.body.removeChild(a);
-  };
-
-  const handleEditFile = async (url: string, filename: string) => {
-    try {
-      const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
-      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-      const content = await resp.text();
-      const ext = filename.split('.').pop()?.toLowerCase() || '';
-      const mimeMap: Record<string, string> = {
-        md: 'text/markdown',
-        txt: 'text/plain',
-        json: 'application/json',
-        xml: 'text/xml',
-        yaml: 'text/yaml',
-        yml: 'text/yaml',
-        csv: 'text/csv',
-      };
-      const file: AttachedFile = {
-        id: `report-${url}`,
-        name: filename,
-        size: content.length,
-        type: mimeMap[ext] || 'text/plain',
-        content,
-        uploadTime: new Date().toISOString(),
-      };
-      emit('open-file-preview', file);
-    } catch (err) {
-      console.error('获取文件内容失败:', err);
-      message.error('获取文件内容失败');
-    }
-  };
-
-  // 点击文件卡片:按类型拉取内容并在右侧辅助面板打开预览标签
-  const handlePreviewFile = async (file: { url: string; filename: string }) => {
-    if (!file.url) return;
-    try {
-      const previewFile = await fetchPreviewFile(file.url, file.filename);
-      if (!previewFile) {
-        // 不支持的格式回退为直接下载
-        handleDownloadFile(file.url, file.filename);
-        return;
-      }
-      emit('file-preview', previewFile);
-    } catch (err) {
-      console.error('获取文件内容失败:', err);
-      message.error('文件预览失败,请尝试下载查看');
-    }
-  };
-
-  // ask_user 答案状态(按消息索引存储)
-  const askUserAnswers = reactive<Record<number, string[]>>({});
-  const askUserOtherSelected = reactive<Record<number, boolean[]>>({});
-  const askUserCurrentStep = reactive<Record<number, number>>({});
-
-  // choice 可能是字符串或 { value: "..." } 对象,统一提取字符串
-  const getChoiceValue = (choice: any): string => {
-    if (typeof choice === 'string') return choice;
-    if (choice && typeof choice === 'object' && 'value' in choice) return choice.value;
-    return String(choice);
-  };
-
-  const initAskUserAnswers = (index: number, questions: { question: string; type: string; choices?: any[] }[]) => {
-    // Always reinitialize when called - handles repeated interrupts from backend
-    askUserAnswers[index] = questions.map(() => '');
-    askUserOtherSelected[index] = questions.map(() => false);
-    askUserCurrentStep[index] = 0;
-  };
-
-  // Track the last pendingApprovalData reference to detect new interrupts
-  const lastApprovalDataRef = new Map<number, any>();
-
-  // 自动初始化 ask_user 答案状态。
-  // 必须注册在 initAskUserAnswers 等 const 声明之后:immediate watcher 在 setup 阶段同步执行,
-  // 若带审批消息重新挂载(关闭弹框后再打开),回调会访问尚未初始化的变量(TDZ 报错导致整个弹框挂载失败)
-  watch(
-    () => props.messages,
-    (msgs) => {
-      msgs.forEach((msg, idx) => {
-        if (msg.isPendingApproval && 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);
-        }
-      });
-    },
-    { immediate: true, deep: true }
-  );
-
-  const isCurrentStepAnswered = (msgIndex: number, qIndex: number, question: { required?: boolean; type: string }) => {
-    const answers = askUserAnswers[msgIndex];
-    if (!answers) return false;
-    if (question.required === false && !answers[qIndex]) return true;
-    return !!(answers[qIndex] && answers[qIndex].trim() !== '');
-  };
-
-  const goNextStep = (msgIndex: number, totalQuestions: number) => {
-    if (askUserCurrentStep[msgIndex] < totalQuestions - 1) {
-      askUserCurrentStep[msgIndex]++;
-    }
-  };
-
-  const goPrevStep = (msgIndex: number) => {
-    if (askUserCurrentStep[msgIndex] > 0) {
-      askUserCurrentStep[msgIndex]--;
-    }
-  };
-
-  const selectChoice = (msgIndex: number, qIndex: number, choice: any, totalQuestions: number) => {
-    if (!askUserAnswers[msgIndex]) return;
-    askUserAnswers[msgIndex][qIndex] = getChoiceValue(choice);
-    askUserOtherSelected[msgIndex][qIndex] = false;
-    // 单选自动跳到下一题(非最后一题时)
-    if (qIndex < totalQuestions - 1) {
-      goNextStep(msgIndex, totalQuestions);
-    }
-  };
-
-  const selectOther = (msgIndex: number, qIndex: number) => {
-    if (!askUserOtherSelected[msgIndex]) return;
-    askUserOtherSelected[msgIndex][qIndex] = true;
-    askUserAnswers[msgIndex][qIndex] = '';
-  };
-
-  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]);
-  };
-
-  const handleCancelAskUser = () => {
-    emit('ask-user-cancel');
-  };
-
-  const messagesRef = ref<HTMLElement>();
-  const collapsedThinking = reactive<Record<number, boolean>>({});
-  const collapsedThinkingContent = reactive<Record<number, boolean>>({});
-  const collapsedCitations = reactive<Record<number, boolean>>({});
-  const isAtBottom = ref(true);
-  const copiedMessageIndex = ref(-1);
-
-  const handleTableAction = (e: Event) => {
-    const target = (e.target as HTMLElement).closest('.table-action-btn') as HTMLElement;
-    if (!target) return;
-
-    const action = target.dataset.action;
-    const encodedTable = target.dataset.table;
-    if (!action || !encodedTable) return;
-
-    const tableHtml = decodeURIComponent(encodedTable);
-
-    switch (action) {
-      case 'copy': {
-        const markdown = tableHtmlToMarkdown(tableHtml);
-        const doCopy = async () => {
-          try {
-            await navigator.clipboard.writeText(markdown);
-          } catch {
-            const ta = document.createElement('textarea');
-            ta.value = markdown;
-            ta.style.position = 'fixed';
-            ta.style.opacity = '0';
-            document.body.appendChild(ta);
-            ta.select();
-            document.execCommand('copy');
-            document.body.removeChild(ta);
-          }
-        };
-        doCopy().then(() => {
-          const btn = target;
-          const img = btn.querySelector('img');
-          const successIcon = btn.dataset.iconSuccess;
-          const originalIcon = btn.dataset.icon;
-          if (img && successIcon) {
-            img.src = successIcon;
-            btn.title = '复制成功';
-            const reset = () => {
-              img.src = originalIcon!;
-              btn.title = '复制Markdown';
-              wrapper.removeEventListener('mouseleave', reset);
-            };
-            const wrapper = btn.closest('.markdown-table-wrapper')!;
-            wrapper.addEventListener('mouseleave', reset);
-          }
-        });
-        break;
-      }
-      case 'csv': {
-        const csv = tableHtmlToCsv(tableHtml);
-        const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
-        const url = URL.createObjectURL(blob);
-        const a = document.createElement('a');
-        a.href = url;
-        a.download = `table_${Date.now()}.csv`;
-        a.click();
-        URL.revokeObjectURL(url);
-        break;
-      }
-      case 'preview': {
-        // 在右侧面板新建标签页展示表格渲染效果
-        emit('open-markdown-tab', { title: '表格预览', content: tableHtmlToMarkdown(tableHtml) });
-        break;
-      }
-    }
-  };
-
-  const checkScrollBottom = () => {
-    const el = messagesRef.value;
-    if (!el) return;
-    const threshold = 50;
-    isAtBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
-  };
-
-  const scrollToBottom = () => {
-    const el = messagesRef.value;
-    if (el) {
-      el.scrollTop = el.scrollHeight - el.clientHeight;
-    }
-  };
-
-  const forceScrollToBottom = () => {
-    const el = messagesRef.value;
-    if (el) {
-      el.scrollTop = el.scrollHeight - el.clientHeight;
-      // 使用 requestAnimationFrame 确保 DOM 渲染完成后再滚动一次
-      requestAnimationFrame(() => {
-        el.scrollTop = el.scrollHeight - el.clientHeight;
-      });
-    }
-    isAtBottom.value = true;
-  };
-
-  onMounted(() => {
-    messagesRef.value?.addEventListener('click', handleTableAction);
-    messagesRef.value?.addEventListener('scroll', handleMessagesScroll);
-    timerInterval = setInterval(() => {
-      nowTs.value = Date.now();
-    }, 1000);
-  });
-
-  onBeforeUnmount(() => {
-    messagesRef.value?.removeEventListener('click', handleTableAction);
-    messagesRef.value?.removeEventListener('scroll', handleMessagesScroll);
-    if (spyRaf) {
-      cancelAnimationFrame(spyRaf);
-      spyRaf = 0;
-    }
-    if (navMoveRaf) {
-      cancelAnimationFrame(navMoveRaf);
-      navMoveRaf = 0;
-    }
-    if (navScrollRaf) {
-      cancelAnimationFrame(navScrollRaf);
-      navScrollRaf = 0;
-    }
-    if (timerInterval) {
-      clearInterval(timerInterval);
-      timerInterval = null;
-    }
-  });
-
-  const toggleThinkingCard = (index: number) => {
-    collapsedThinking[index] = !collapsedThinking[index];
-  };
-
-  const toggleThinkingContent = (index: number) => {
-    collapsedThinkingContent[index] = !collapsedThinkingContent[index];
-  };
-
-  const handleAgentGroupClick = (messageIndex: number, group: AgentGroup) => {
-    emit('agent-detail-click', {
-      messageIndex,
-      agent: group.agent,
-      agentId: group.agentId,
-    });
-  };
-
-  const toggleCitations = (index: number) => {
-    collapsedCitations[index] = !collapsedCitations[index];
-  };
-
-  const handleFootnoteClick = (e: Event, msgIndex: number) => {
-    const target = (e.target as HTMLElement).closest('.footnote-ref') as HTMLElement;
-    if (!target) return;
-    const n = target.dataset.n;
-    if (!n) return;
-    const el = document.getElementById(`citation-${msgIndex}-${n}`);
-    if (el) {
-      scrollMessagesTo(el, 'center');
-      el.classList.add('citation-highlight');
-      setTimeout(() => el.classList.remove('citation-highlight'), 1500);
-    }
-  };
-
-  const getLatestStepMessage = (group: AgentGroup): string => {
-    if (group.steps.length === 0) return '';
-    const last = group.steps[group.steps.length - 1];
-    if (last.type === 'executing') return last.message || '正在执行...';
-    if (last.type === 'tool_call') return last.message || last.tool || '';
-    if (last.type === 'tool_result') return last.message || '';
-    if (last.type === 'agent_start') return last.message || '';
-    if (last.type === 'agent_done') return last.message || '';
-    if (last.type === 'updated_todo_list') return '任务列表已更新';
-    return '';
-  };
-
-  const formatFullTime = (createdAt: string) => {
-    return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
-  };
-
-  const copyMessageContent = async (content: string, index: number) => {
-    if (!content) return;
-    try {
-      await navigator.clipboard.writeText(content);
-      copiedMessageIndex.value = index;
-    } catch {
-      const textarea = document.createElement('textarea');
-      textarea.value = content;
-      textarea.style.position = 'fixed';
-      textarea.style.opacity = '0';
-      document.body.appendChild(textarea);
-      textarea.select();
-      document.execCommand('copy');
-      document.body.removeChild(textarea);
-      copiedMessageIndex.value = index;
-    }
-  };
-
-  // ===== 左侧对话缩略导航 =====
-  interface QaPair {
-    startIndex: number;
-    question: string;
-    answer: string;
-  }
-
-  const toPlainText = (md: string): string =>
-    md
-      .replace(/```[\s\S]*?```/g, ' [代码] ')
-      .replace(/`[^`]*`/g, ' ')
-      .replace(/<[^>]+>/g, '')
-      .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
-      .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
-      .replace(/^#{1,6}\s*/gm, '')
-      .replace(/[*_~>|]/g, '')
-      .replace(/\s+/g, ' ')
-      .trim();
-
-  const truncateText = (text: string, max = 50): string => (text.length > max ? `${text.slice(0, max)}…` : text);
-
-  // 一轮问答 = 一条用户消息 + 其后的连续 AI 消息
-  const qaPairs = computed<QaPair[]>(() => {
-    const pairs: QaPair[] = [];
-    let current: QaPair | null = null;
-    props.messages.forEach((msg, index) => {
-      if (msg.type === 'user') {
-        if (current) pairs.push(current);
-        current = { startIndex: index, question: truncateText(toPlainText(msg.content)), answer: '' };
-      } else {
-        if (!current) current = { startIndex: index, question: '', answer: '' };
-        const part = msg.isLoading && !msg.content ? '正在生成...' : toPlainText(msg.content);
-        if (part) current.answer = truncateText(current.answer ? `${current.answer} ${part}` : part);
-      }
-    });
-    if (current) pairs.push(current);
-    return pairs;
-  });
-
-  const navRef = ref<HTMLElement>();
-  const tooltipRef = ref<HTMLElement>();
-  const hoveredPair = ref<QaPair | null>(null);
-  const tooltipTop = ref(0);
-  const tooltipArrowTop = ref(0);
-  const activeMessage = ref<Message | null>(null);
-
-  // 鼠标在导航列内的 Y 坐标(导航列内容坐标系,随滚动补偿);null 表示未悬浮
-  const navMouseY = ref<number | null>(null);
-  // 各横线中心 Y(导航列内容坐标系),消息变化/列滚动后重建
-  const lineCenters = ref<number[]>([]);
-
-  const updateLineCenters = () => {
-    const nav = navRef.value;
-    if (!nav) {
-      lineCenters.value = [];
-      return;
-    }
-    const centers: number[] = [];
-    nav.querySelectorAll<HTMLElement>('.nav-line').forEach((line) => centers.push(line.offsetTop + line.offsetHeight / 2));
-    lineCenters.value = centers;
-  };
-
-  // 宽度随与鼠标的距离连续衰减(高斯),形成平滑的"长-中-短"波形;
-  // 用 scaleX 缩放(合成器动画)替代逐帧改 width(逐帧重排),滚动/移动时不再卡顿
-  const navLineScale = (pIdx: number): number => {
-    const mouseY = navMouseY.value;
-    if (mouseY === null) return 1;
-    const dist = Math.abs((lineCenters.value[pIdx] ?? 0) - mouseY);
-    return 1 + 0.8 * Math.exp(-(dist * dist) / (2 * 14 * 14));
-  };
-
-  const nearestLineIndex = computed(() => {
-    const mouseY = navMouseY.value;
-    if (mouseY === null) return -1;
-    let best = -1;
-    let bestDist = Infinity;
-    lineCenters.value.forEach((center, i) => {
-      const dist = Math.abs(center - mouseY);
-      if (dist < bestDist) {
-        bestDist = dist;
-        best = i;
-      }
-    });
-    return best;
-  });
-
-  let lastTooltipIndex = -1;
-
-  const applyTooltip = async (pIdx: number) => {
-    const pair = qaPairs.value[pIdx];
-    if (!pair) {
-      hoveredPair.value = null;
-      return;
-    }
-    hoveredPair.value = pair;
-    await nextTick();
-    const tooltip = tooltipRef.value;
-    const nav = navRef.value;
-    const container = nav?.parentElement as HTMLElement | null;
-    if (!tooltip || !nav || !container) return;
-    const lineCenterY = nav.offsetTop + (lineCenters.value[pIdx] ?? 0) - nav.scrollTop;
-    const tooltipH = tooltip.offsetHeight;
-    const maxTop = Math.max(8, container.clientHeight - tooltipH - 8);
-    tooltipTop.value = Math.max(8, Math.min(maxTop, lineCenterY - tooltipH / 2));
-    tooltipArrowTop.value = Math.max(6, Math.min(tooltipH - 16, lineCenterY - tooltipTop.value - 5));
-  };
-
-  let navMoveRaf = 0;
-  let lastNavMoveEvent: MouseEvent | null = null;
-
-  const applyNavMove = (e: MouseEvent) => {
-    const nav = navRef.value;
-    if (!nav) return;
-    // 横线数量变化(新增问答/首次悬浮)时重建坐标
-    if (lineCenters.value.length !== qaPairs.value.length) updateLineCenters();
-    // e.clientY / getBoundingClientRect 是容器 scale 缩放后的视口坐标,需换算回布局坐标
-    // (÷ 容器竖向缩放)后再与 lineCenters / scrollTop 比较,否则不同分辨率下定位错位
-    const scale = nav.offsetHeight ? nav.getBoundingClientRect().height / nav.offsetHeight : 1;
-    navMouseY.value = (e.clientY - nav.getBoundingClientRect().top) / scale + nav.scrollTop;
-    const nearest = nearestLineIndex.value;
-    if (nearest >= 0 && nearest !== lastTooltipIndex) {
-      lastTooltipIndex = nearest;
-      applyTooltip(nearest);
-    }
-  };
-
-  const onNavMove = (e: MouseEvent) => {
-    // 鼠标移动高频触发,合并到每帧处理一次,避免频繁重排造成卡顿
-    lastNavMoveEvent = e;
-    if (navMoveRaf) return;
-    navMoveRaf = requestAnimationFrame(() => {
-      navMoveRaf = 0;
-      if (lastNavMoveEvent) applyNavMove(lastNavMoveEvent);
-    });
-  };
-
-  const onNavLeave = () => {
-    if (navMoveRaf) {
-      cancelAnimationFrame(navMoveRaf);
-      navMoveRaf = 0;
-      lastNavMoveEvent = null;
-    }
-    navMouseY.value = null;
-    hoveredPair.value = null;
-    lastTooltipIndex = -1;
-  };
-
-  let navScrollRaf = 0;
-  const onNavScroll = () => {
-    // 导航列自身滚动时同样按帧合并,保持滚动过程顺滑
-    if (navScrollRaf) return;
-    navScrollRaf = requestAnimationFrame(() => {
-      navScrollRaf = 0;
-      updateLineCenters();
-      if (lastTooltipIndex >= 0) applyTooltip(lastTooltipIndex);
-    });
-  };
-
-  // 仅滚动消息列表容器:scrollIntoView 会联动滚动 #adaptive-container 等祖先,导致整页上移、底部露出空白
-  const scrollMessagesTo = (el: HTMLElement, block: 'start' | 'center' = 'start') => {
-    const container = messagesRef.value;
-    if (!container) return;
-    // 容器处于自适应缩放环境:getBoundingClientRect 是缩放后的视口坐标,scrollTop 是未缩放的布局坐标,
-    // 直接混算在缩放比非 1(打包部署后的其他分辨率)时定位偏差,需除以纵向缩放比换算回布局坐标
-    //(与左侧对话缩略导航 tooltip 的换算方式一致)
-    const rect = container.getBoundingClientRect();
-    const scale = container.offsetHeight ? rect.height / container.offsetHeight : 1;
-    const safeScale = scale > 0 && Number.isFinite(scale) ? scale : 1;
-    const top = (el.getBoundingClientRect().top - rect.top) / safeScale + container.scrollTop;
-    const offset = block === 'center' ? (container.clientHeight - el.offsetHeight) / 2 : 0;
-    container.scrollTo({ top: Math.max(0, top - offset), behavior: 'smooth' });
-  };
-
-  const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
-    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
-    let el = wrappers?.[index] as HTMLElement | undefined;
-    // 多回答折叠组中被隐藏的消息没有几何信息,回退到其前面最近一条可见消息(通常是该轮的用户提问)
-    if (el && el.offsetParent === null) {
-      el = undefined;
-      for (let i = index - 1; i >= 0; i--) {
-        const w = wrappers?.[i] as HTMLElement | undefined;
-        if (w && w.offsetParent !== null) {
-          el = w;
-          break;
-        }
-      }
-    }
-    if (el) scrollMessagesTo(el, block);
-  };
-
-  const scrollToPair = (pair: QaPair) => {
-    activeMessage.value = props.messages[pair.startIndex];
-    scrollToMessage(pair.startIndex, 'start');
-    const pIdx = qaPairs.value.indexOf(pair);
-    if (pIdx >= 0) ensureNavLineVisible(pIdx);
-    const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
-    const el = wrappers?.[pair.startIndex] as HTMLElement | undefined;
-    el?.classList.add('nav-located-highlight');
-    setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
-  };
-
-  // 选中线保持在导航列可视区内(长对话时导航列自身可滚动):
-  // 仅在本列内部调整 scrollTop,不调用 scrollIntoView 以免联动祖先容器
-  const ensureNavLineVisible = (pIdx: number) => {
-    const nav = navRef.value;
-    const line = nav?.querySelectorAll<HTMLElement>('.nav-line')[pIdx];
-    if (!nav || !line) return;
-    const margin = 12;
-    const top = line.offsetTop;
-    const bottom = top + line.offsetHeight;
-    if (top < nav.scrollTop + margin) {
-      nav.scrollTop = Math.max(0, top - margin);
-    } else if (bottom > nav.scrollTop + nav.clientHeight - margin) {
-      nav.scrollTop = bottom - nav.clientHeight + margin;
-    }
-  };
-
-  // ===== 滚动跟随:消息滚动时,导航选中项实时跟随当前正在阅读的一组问答 =====
-  let spyRaf = 0;
-  const updateActivePairByScroll = () => {
-    const pairs = qaPairs.value;
-    const container = messagesRef.value;
-    if (!pairs.length || !container) {
-      activeMessage.value = null;
-      return;
-    }
-    const wrappers = container.querySelectorAll('.message-wrapper');
-    // 以视口上部 1/3 处为判定线:起始位置越过判定线的一组即为当前阅读组;触底时强制选中最后一组
-    const mark = container.scrollTop + container.clientHeight * 0.35;
-    let current = 0;
-    for (let p = 0; p < pairs.length; p++) {
-      const el = wrappers[pairs[p].startIndex] as HTMLElement | undefined;
-      if (!el) continue;
-      if (el.offsetTop <= mark) current = p;
-      else break;
-    }
-    if (isAtBottom.value) current = pairs.length - 1;
-    const target = props.messages[pairs[current].startIndex] ?? null;
-    if (activeMessage.value !== target) {
-      activeMessage.value = target;
-      // 选中项变化时把对应横线滚入导航列可视区,跟随才真正可见
-      ensureNavLineVisible(current);
-    }
-  };
-
-  // 滚动事件高频触发,用 rAF 合并到每帧最多计算一次
-  const scheduleSpy = () => {
-    if (spyRaf) return;
-    spyRaf = requestAnimationFrame(() => {
-      spyRaf = 0;
-      updateActivePairByScroll();
-    });
-  };
-
-  // 消息增减(新问答、加载历史、切换任务)后同步一次选中状态
-  watch(
-    () => qaPairs.value.length,
-    () => {
-      nextTick(updateActivePairByScroll);
-    },
-    { immediate: true }
-  );
-
-  const handleMessagesScroll = () => {
-    checkScrollBottom();
-    scheduleSpy();
-  };
-
-  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
-</script>
-
-<style scoped lang="less">
-  .chat-messages-container {
-    flex: 1;
-    position: relative;
-    display: flex;
-    flex-direction: row;
-    overflow: hidden;
-  }
-
-  .conversation-nav {
-    position: relative;
-    width: 28px;
-    flex-shrink: 0;
-    display: flex;
-    flex-direction: column;
-    align-items: center;
-    overflow-y: auto;
-    padding: 6px 0;
-    scrollbar-width: none;
-
-    &::-webkit-scrollbar {
-      display: none;
-    }
-
-    .nav-line {
-      width: 10px;
-      height: 3px;
-      border-radius: 2px;
-      background: rgba(139, 148, 158, 0.35);
-      margin: 5px 0;
-      flex-shrink: 0;
-      cursor: pointer;
-      transform-origin: center;
-      transition:
-        transform 0.18s cubic-bezier(0.33, 1, 0.68, 1),
-        background 0.2s,
-        box-shadow 0.2s;
-
-      &:first-child {
-        margin-top: auto;
-      }
-
-      &:last-child {
-        margin-bottom: auto;
-      }
-
-      &.nav-line-hot {
-        background: linear-gradient(90deg, rgba(10, 132, 255, 0.6) 0%, #0a84ff 100%);
-      }
-
-      &.active {
-        background: linear-gradient(90deg, #2f9bff 0%, #0a84ff 100%);
-        box-shadow: 0 0 6px rgba(10, 132, 255, 0.45);
-      }
-    }
-  }
-
-  .conversation-tooltip {
-    position: absolute;
-    left: 36px;
-    width: 260px;
-    background: linear-gradient(135deg, #0a1628 0%, #0d2137 50%, #0a1e35 100%);
-    border: 1px solid #1a4a6f;
-    border-radius: 10px;
-    box-shadow:
-      0 8px 24px rgba(0, 0, 0, 0.5),
-      0 0 14px rgba(10, 132, 255, 0.08);
-    padding: 10px 12px;
-    z-index: 20;
-    pointer-events: none;
-
-    .tooltip-arrow {
-      position: absolute;
-      left: -6px;
-      width: 10px;
-      height: 10px;
-      transform: rotate(45deg);
-      background: #0d2137;
-      border-left: 1px solid #1a4a6f;
-      border-bottom: 1px solid #1a4a6f;
-      border-top-left-radius: 2px;
-    }
-
-    .tooltip-row {
-      display: flex;
-      align-items: flex-start;
-      gap: 8px;
-      font-size: 12px;
-      line-height: 1.6;
-
-      & + .tooltip-row {
-        margin-top: 8px;
-        padding-top: 8px;
-        border-top: 1px solid rgba(63, 80, 106, 0.35);
-      }
-
-      .tooltip-label {
-        flex-shrink: 0;
-        width: 16px;
-        height: 16px;
-        line-height: 16px;
-        margin-top: 2px;
-        text-align: center;
-        border-radius: 4px;
-        font-size: 11px;
-        font-weight: 500;
-
-        &-q {
-          color: #79c0ff;
-          background: rgba(10, 132, 255, 0.15);
-        }
-
-        &-a {
-          color: #a9b9c9;
-          background: rgba(139, 148, 158, 0.15);
-        }
-      }
-
-      .tooltip-text {
-        color: #d7dee6;
-        word-break: break-word;
-      }
-    }
-  }
-
-  .tooltip-fade-enter-active,
-  .tooltip-fade-leave-active {
-    transition:
-      opacity 0.18s ease,
-      transform 0.18s ease;
-  }
-
-  .tooltip-fade-enter-from,
-  .tooltip-fade-leave-to {
-    opacity: 0;
-    transform: translateX(-4px);
-  }
-
-  .chat-messages {
-    flex: 1;
-    overflow-y: auto;
-    padding: 20px;
-    position: relative;
-
-    .welcome-empty {
-      position: absolute;
-      top: 50%;
-      left: 50%;
-      transform: translate(-50%, -50%);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-
-      .welcome-img {
-        max-width: 300px;
-        max-height: 300px;
-        object-fit: contain;
-      }
-    }
-
-    .message-wrapper {
-      // margin-bottom: 20px;
-
-      &.answer-hidden {
-        display: none;
-      }
-
-      &.nav-located-highlight {
-        background: rgba(10, 132, 255, 0.08);
-        border-radius: 6px;
-      }
-
-      .ai-message {
-        display: flex;
-        flex-wrap: wrap;
-        .message-agent {
-          display: flex;
-          margin-bottom: 8px;
-          opacity: 0;
-        }
-
-        .message-body {
-          padding: 12px 12px 4px 12px;
-          border-radius: 6px;
-          width: 100%;
-          min-width: 0;
-          overflow: hidden;
-
-          /* 思考过程卡片 */
-          .thinking-card {
-            margin-bottom: 12px;
-            overflow: hidden;
-
-            .thinking-duration-bar {
-              display: flex;
-              align-items: center;
-              justify-content: space-between;
-              padding: 8px 14px;
-              border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-              cursor: pointer;
-              user-select: none;
-              transition: background 0.2s;
-
-              &:hover {
-                background: rgba(10, 132, 255, 0.12);
-              }
-
-              .thinking-duration {
-                font-size: 16px;
-                color: #8b949e;
-              }
-
-              .thinking-arrow {
-                color: #8b949e;
-                display: flex;
-                align-items: center;
-                transition: transform 0.2s;
-
-                &.expanded {
-                  transform: rotate(180deg);
-                }
-              }
-            }
-
-            .thinking-header {
-              display: flex;
-              align-items: center;
-              justify-content: space-between;
-              padding: 10px 14px;
-              cursor: pointer;
-              user-select: none;
-              transition: background 0.2s;
-
-              &:hover {
-                background: rgba(10, 132, 255, 0.05);
-              }
-
-              .thinking-header-left {
-                display: flex;
-                align-items: center;
-                gap: 8px;
-              }
-
-              .thinking-arrow {
-                display: flex;
-                align-items: center;
-                transition: transform 0.2s;
-
-                &.expanded {
-                  transform: rotate(180deg);
-                }
-              }
-
-              .thinking-icon {
-                width: 16px;
-                height: 16px;
-                background-image: var(--img-chat-thinking-icon);
-                background-repeat: no-repeat;
-                background-size: 100% 100%;
-              }
-
-              .thinking-title {
-                font-size: 13px;
-                font-weight: 500;
-                color: #8b949e;
-              }
-
-              .thinking-count {
-                font-size: 12px;
-                color: #8b949e;
-                background: rgba(10, 132, 255, 0.1);
-                padding: 1px 6px;
-                border-radius: 10px;
-              }
-            }
-
-            .thinking-content-body {
-              margin: 0 14px 8px;
-              padding: 8px 12px;
-              max-height: 200px;
-              overflow-y: auto;
-              font-size: 13px;
-              line-height: 1.6;
-              color: #8b949e;
-              white-space: pre-wrap;
-              word-break: break-word;
-              background: rgba(10, 13, 23, 0.3);
-              border-radius: 6px;
-            }
-
-            .thinking-body {
-              padding: 8px 14px;
-
-              .agent-group {
-                margin-bottom: 8px;
-                border: 1px solid transparent;
-                border-radius: 6px;
-                overflow: hidden;
-                transition: border-color 0.2s;
-                cursor: pointer;
-
-                &:hover {
-                  border-color: rgba(63, 80, 106, 0.25);
-                  background: rgba(10, 132, 255, 0.05);
-                }
-
-                &:last-child {
-                  margin-bottom: 0;
-                }
-
-                .agent-group-header {
-                  display: flex;
-                  align-items: center;
-                  justify-content: space-between;
-                  padding: 8px 12px;
-                  user-select: none;
-
-                  .agent-group-header-left {
-                    display: flex;
-                    align-items: center;
-                    gap: 8px;
-                  }
-
-                  .agent-group-icon {
-                    width: 14px;
-                    height: 14px;
-                    background-image: var(--img-chat-agent-icon);
-                    background-repeat: no-repeat;
-                    background-size: 100% 100%;
-                    flex-shrink: 0;
-                  }
-
-                  .agent-group-name {
-                    font-size: 13px;
-                    font-weight: 500;
-                    color: #fff;
-                  }
-
-                  .agent-group-latest {
-                    font-size: 12px;
-                    color: #8b949e;
-                    margin-left: 8px;
-                    overflow: hidden;
-                    text-overflow: ellipsis;
-                    white-space: nowrap;
-                    flex: 1;
-                    min-width: 0;
-                  }
-
-                  .agent-arrow {
-                    margin-left: 8px;
-                    flex-shrink: 0;
-                    display: flex;
-                    align-items: center;
-                  }
-                }
-              }
-            }
-          }
-
-          .collapse-enter-active,
-          .collapse-leave-active {
-            transition: all 0.25s ease;
-            overflow: hidden;
-          }
-
-          .collapse-enter-from,
-          .collapse-leave-to {
-            opacity: 0;
-            max-height: 0;
-            padding-top: 0;
-            padding-bottom: 0;
-            margin-top: 0;
-            margin-bottom: 0;
-          }
-
-          .collapse-enter-to,
-          .collapse-leave-from {
-            opacity: 1;
-            max-height: 500px;
-          }
-
-          .message-text {
-            font-size: 14px;
-            font-weight: 400;
-            line-height: 1.6;
-            color: #e0e6ed;
-            word-break: break-word;
-            overflow-wrap: break-word;
-
-            :deep(.session-ref-icon) {
-              width: 14px;
-              height: 14px;
-              vertical-align: -2px;
-            }
-
-            :deep(h1),
-            :deep(h2),
-            :deep(h3),
-            :deep(h4),
-            :deep(h5),
-            :deep(h6) {
-              color: #fff;
-            }
-            :deep(p) {
-              margin: 0;
-            }
-            :deep(code) {
-              background: rgba(10, 132, 255, 0.1);
-              padding: 1px 4px;
-              border-radius: 3px;
-              font-size: 12px;
-            }
-            :deep(pre) {
-              background: rgba(10, 132, 255, 0.1);
-              padding: 8px;
-              border-radius: 4px;
-              overflow-x: auto;
-              margin: 6px 0;
-              white-space: pre-wrap;
-            }
-            :deep(pre code) {
-              background: transparent;
-              padding: 0;
-            }
-            :deep(ul),
-            :deep(ol) {
-              padding-left: 18px;
-              margin: 4px 0;
-            }
-            :deep(blockquote) {
-              border-left: 3px solid rgba(10, 132, 255, 0.3);
-              padding-left: 10px;
-              margin: 6px 0;
-              color: #8b949e;
-            }
-            :deep(table) {
-              border-collapse: collapse;
-              margin: 6px 0;
-            }
-            :deep(th),
-            :deep(td) {
-              border: 1px solid rgba(63, 80, 106, 0.5);
-              padding: 4px 8px;
-            }
-            :deep(th) {
-              background: rgba(10, 132, 255, 0.1);
-            }
-            :deep(strong) {
-              font-weight: 600;
-            }
-
-            :deep(.markdown-table-wrapper) {
-              position: relative;
-              margin: 6px 0;
-              max-width: 100%;
-              overflow-x: auto;
-
-              .table-actions {
-                position: absolute;
-                top: 4px;
-                right: 4px;
-                display: flex;
-                gap: 2px;
-                opacity: 0;
-                transition: opacity 0.2s;
-                z-index: 1;
-              }
-
-              &:hover .table-actions {
-                opacity: 1;
-              }
-
-              .table-action-btn {
-                width: 26px;
-                height: 26px;
-                display: flex;
-                align-items: center;
-                justify-content: center;
-                border-radius: 4px;
-                color: #8b949e;
-                cursor: pointer;
-                background: rgba(22, 27, 34, 0.8);
-                backdrop-filter: blur(4px);
-                transition: all 0.2s;
-
-                &:hover {
-                  color: #e0e6ed;
-                  background: rgba(10, 132, 255, 0.3);
-                }
-              }
-            }
-          }
-        }
-        .loading-dots {
-          display: flex;
-          align-items: center;
-          gap: 6px;
-          padding: 4px 0;
-          min-height: 16px;
-
-          .dot {
-            width: 8px;
-            height: 8px;
-            border-radius: 50%;
-            background: rgba(10, 132, 255, 0.4);
-            animation: dot-bounce 1.4s ease-in-out infinite both;
-
-            &:nth-child(1) {
-              animation-delay: 0s;
-            }
-            &:nth-child(2) {
-              animation-delay: 0.2s;
-            }
-            &:nth-child(3) {
-              animation-delay: 0.4s;
-            }
-          }
-        }
-
-        @keyframes dot-bounce {
-          0%,
-          80%,
-          100% {
-            transform: scale(0.6);
-            opacity: 0.4;
-          }
-          40% {
-            transform: scale(1);
-            opacity: 1;
-          }
-        }
-
-        .approval-card {
-          display: flex;
-          align-items: flex-start;
-          gap: 12px;
-          padding: 14px 16px;
-          margin-top: 12px;
-          background: #002f4a;
-          border: 1px solid #005482;
-          border-radius: 8px;
-
-          .approval-icon {
-            width: 36px;
-            height: 36px;
-            background-image: var(--img-chat-session-approve-icon);
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            flex-shrink: 0;
-          }
-
-          .approval-content {
-            flex: 1;
-
-            .approval-title {
-              font-size: 14px;
-              font-weight: 600;
-              color: #e0e6ed;
-              margin-bottom: 6px;
-            }
-
-            .approval-plan {
-              margin-bottom: 14px;
-              padding: 10px 12px;
-              background: rgba(10, 132, 255, 0.08);
-              border: 1px solid rgba(10, 132, 255, 0.2);
-              border-radius: 6px;
-
-              .approval-plan-label {
-                font-size: 12px;
-                color: #0a84ff;
-                font-weight: 600;
-                margin-bottom: 6px;
-              }
-
-              .approval-plan-content {
-                font-size: 13px;
-                color: #c9d1d9;
-                line-height: 1.6;
-                white-space: pre-wrap;
-              }
-            }
-
-            .ask-user-question {
-              margin-bottom: 14px;
-
-              .ask-user-question-text {
-                font-size: 13px;
-                color: #c9d1d9;
-                margin-bottom: 8px;
-                line-height: 1.5;
-
-                .ask-user-required {
-                  color: #f85149;
-                  margin-left: 2px;
-                }
-              }
-
-              .ask-user-choices {
-                display: flex;
-                flex-direction: column;
-                gap: 6px;
-
-                .ask-user-choice {
-                  display: flex;
-                  align-items: center;
-                  padding: 8px 12px;
-                  background: rgba(255, 255, 255, 0.04);
-                  border: 1px solid rgba(63, 80, 106, 0.4);
-                  border-radius: 6px;
-                  cursor: pointer;
-                  font-size: 13px;
-                  color: #c9d1d9;
-                  transition: all 0.2s;
-
-                  &:hover {
-                    background: rgba(10, 132, 255, 0.1);
-                    border-color: rgba(10, 132, 255, 0.4);
-                  }
-
-                  &.selected {
-                    background: rgba(10, 132, 255, 0.15);
-                    border-color: #0a84ff;
-                    color: #fff;
-                  }
-                }
-
-                .ask-user-other-input {
-                  margin-top: 4px;
-                  padding: 8px 12px;
-                  font-size: 13px;
-                  color: #e0e6ed;
-                  background: rgba(255, 255, 255, 0.06);
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-                  border-radius: 6px;
-                  outline: none;
-                  width: 100%;
-
-                  &:focus {
-                    border-color: #0a84ff;
-                    background: rgba(10, 132, 255, 0.08);
-                  }
-
-                  &::placeholder {
-                    color: #6e7681;
-                  }
-                }
-              }
-
-              .ask-user-text {
-                .ask-user-textarea {
-                  width: 100%;
-                  min-height: 60px;
-                  padding: 8px 12px;
-                  font-size: 13px;
-                  color: #e0e6ed;
-                  background: rgba(255, 255, 255, 0.06);
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-                  border-radius: 6px;
-                  outline: none;
-                  resize: vertical;
-                  font-family: inherit;
-
-                  &:focus {
-                    border-color: #0a84ff;
-                    background: rgba(10, 132, 255, 0.08);
-                  }
-
-                  &::placeholder {
-                    color: #6e7681;
-                  }
-                }
-              }
-            }
-
-            .ask-user-stepper {
-              display: flex;
-              align-items: center;
-              gap: 6px;
-              margin-bottom: 14px;
-
-              .ask-user-step-dot {
-                width: 8px;
-                height: 8px;
-                border-radius: 50%;
-                background: rgba(63, 80, 106, 0.5);
-                cursor: pointer;
-                transition: all 0.2s;
-
-                &.active {
-                  background: #0a84ff;
-                  width: 20px;
-                  border-radius: 4px;
-                }
-
-                &.completed {
-                  background: rgba(10, 132, 255, 0.5);
-                }
-              }
-
-              .ask-user-step-text {
-                margin-left: 6px;
-                font-size: 12px;
-                color: #6e7681;
-              }
-            }
-
-            .approval-actions {
-              display: flex;
-              gap: 10px;
-
-              .approval-btn {
-                padding: 6px 20px;
-                border-radius: 6px;
-                font-size: 13px;
-                font-weight: 500;
-                cursor: pointer;
-                border: none;
-                transition: all 0.2s;
-
-                &.approve-btn {
-                  background: #0a84ff;
-                  color: #fff;
-
-                  &:hover:not(:disabled) {
-                    background: #3a9bfd;
-                  }
-
-                  &:disabled {
-                    opacity: 0.4;
-                    cursor: not-allowed;
-                  }
-                }
-
-                &.nav-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #c9d1d9;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.15);
-                  }
-                }
-
-                &.reject-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #e0e6ed;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.15);
-                  }
-                }
-              }
-            }
-          }
-        }
-
-        .citations-card {
-          overflow: hidden;
-
-          .citations-header {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 8px 14px;
-            border-bottom: 1px solid rgba(63, 80, 106, 0.3);
-            cursor: pointer;
-            user-select: none;
-            transition: background 0.2s;
-
-            &:hover {
-              background: rgba(10, 132, 255, 0.08);
-            }
-
-            &.citations-header-simple {
-              cursor: default;
-
-              &:hover {
-                background: transparent;
-              }
-            }
-
-            .citations-header-left {
-              display: flex;
-              align-items: center;
-              gap: 8px;
-            }
-
-            .citations-title {
-              font-size: 13px;
-              font-weight: 500;
-              color: #8b949e;
-            }
-
-            .citations-count {
-              font-size: 12px;
-              color: #8b949e;
-              background: rgba(10, 132, 255, 0.1);
-              padding: 1px 6px;
-              border-radius: 10px;
-            }
-
-            .thinking-arrow {
-              display: flex;
-              align-items: center;
-              transition: transform 0.2s;
-
-              &.expanded {
-                transform: rotate(180deg);
-              }
-            }
-          }
-
-          .citations-body {
-            padding: 4px 14px;
-
-            .citation-item {
-              padding: 6px 0;
-              border-bottom: 1px solid rgba(63, 80, 106, 0.15);
-
-              &:last-child {
-                border-bottom: none;
-              }
-
-              &.citation-highlight {
-                background: rgba(10, 132, 255, 0.08);
-                border-radius: 4px;
-                padding: 6px 8px;
-                margin: 0 -8px;
-              }
-
-              .citation-item-row {
-                display: flex;
-                align-items: center;
-                gap: 8px;
-                line-height: 1.5;
-
-                .citation-badge {
-                  font-size: 12px;
-                  color: #79c0ff;
-                  font-family: monospace;
-                  background: rgba(10, 132, 255, 0.1);
-                  padding: 1px 5px;
-                  border-radius: 3px;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-
-                .citation-badge-knowledge_base {
-                  color: #3fb950;
-                  background: rgba(46, 160, 67, 0.1);
-                }
-
-                .citation-badge-web {
-                  color: #d29922;
-                  background: rgba(210, 153, 34, 0.1);
-                }
-
-                .citation-badge-calc {
-                  color: #a371f7;
-                  background: rgba(163, 113, 247, 0.1);
-                }
-
-                .citation-title-text {
-                  font-size: 13px;
-                  color: #b0b8c4;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                  min-width: 0;
-                }
-
-                .citation-link {
-                  font-size: 12px;
-                  color: #0a84ff;
-                  text-decoration: none;
-                  white-space: nowrap;
-                  flex-shrink: 0;
-                }
-              }
-
-              .citation-item-attrs {
-                display: flex;
-                align-items: center;
-                flex-wrap: wrap;
-                gap: 4px 10px;
-                padding-left: 0;
-                margin-top: 2px;
-
-                .citation-attr {
-                  font-size: 12px;
-                  color: #8b949e;
-                  white-space: nowrap;
-                }
-
-                .citation-low-score {
-                  color: #d29922;
-                }
-              }
-
-              .citation-snippet {
-                margin-top: 4px;
-                font-size: 12px;
-                color: #8b949e;
-                line-height: 1.5;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-              }
-            }
-          }
-        }
-
-        .download-file-cards {
-          display: flex;
-          flex-direction: column;
-          gap: 8px;
-          margin-top: 12px;
-
-          .download-file-card {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: 12px 16px;
-            background: linear-gradient(135deg, rgba(0, 47, 74, 0.8) 0%, rgba(0, 60, 90, 0.6) 100%);
-            border: 1px solid rgba(0, 84, 130, 0.6);
-            border-radius: 10px;
-            cursor: pointer;
-            transition: all 0.2s ease;
-
-            &:hover {
-              border-color: rgba(10, 132, 255, 0.4);
-              box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
-            }
-
-            .file-card-info {
-              display: flex;
-              align-items: center;
-              gap: 12px;
-              min-width: 0;
-              flex: 1;
-
-              .file-card-icon {
-                width: 36px;
-                height: 36px;
-                background-image: var(--img-chat-download-icon);
-                background-repeat: no-repeat;
-                background-size: 100% 100%;
-                flex-shrink: 0;
-                opacity: 0.9;
-              }
-
-              .file-card-text {
-                display: flex;
-                flex-direction: column;
-                gap: 2px;
-                min-width: 0;
-
-                .file-card-name {
-                  font-size: 13px;
-                  font-weight: 500;
-                  color: #e0e6ed;
-                  overflow: hidden;
-                  text-overflow: ellipsis;
-                  white-space: nowrap;
-                }
-
-                .file-card-hint {
-                  font-size: 11px;
-                  color: #6e7681;
-                }
-              }
-            }
-
-            .file-card-actions {
-              display: flex;
-              gap: 8px;
-              flex-shrink: 0;
-
-              .file-action-btn {
-                display: inline-flex;
-                align-items: center;
-                gap: 5px;
-                padding: 6px 14px;
-                border-radius: 7px;
-                font-size: 12px;
-                font-weight: 500;
-                cursor: pointer;
-                border: none;
-                transition: all 0.2s ease;
-                white-space: nowrap;
-
-                &.download-btn {
-                  background: rgba(255, 255, 255, 0.08);
-                  color: #c9d1d9;
-                  border: 1px solid rgba(63, 80, 106, 0.5);
-
-                  &:hover {
-                    background: rgba(255, 255, 255, 0.14);
-                    color: #e0e6ed;
-                    border-color: rgba(63, 80, 106, 0.8);
-                  }
-                }
-
-                &.edit-btn {
-                  background: linear-gradient(135deg, #0a84ff 0%, #0066cc 100%);
-                  color: #fff;
-                  border: 1px solid rgba(10, 132, 255, 0.5);
-                  box-shadow: 0 2px 6px rgba(10, 132, 255, 0.2);
-
-                  &:hover {
-                    background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
-                    box-shadow: 0 3px 10px rgba(10, 132, 255, 0.35);
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-
-      .user-message {
-        display: flex;
-        flex-wrap: wrap;
-        justify-content: flex-end;
-        align-items: flex-start;
-
-        .message-agent {
-          display: flex;
-          margin-bottom: 8px;
-          margin-left: 5px;
-        }
-
-        .message-body {
-          background: rgba(10, 132, 255, 0.15);
-          padding: 12px;
-          border-radius: 6px 0 6px 6px;
-          max-width: 700px;
-          min-width: 0;
-          overflow: hidden;
-
-          .message-text {
-            font-size: 14px;
-            font-weight: 400;
-            line-height: 1.6;
-            color: #e0e6ed;
-            word-break: break-word;
-            overflow-wrap: break-word;
-
-            :deep(.session-ref-icon) {
-              width: 14px;
-              height: 14px;
-              vertical-align: -2px;
-            }
-
-            :deep(h1, h2, h3, h4, h5, h6) {
-              color: #fff;
-            }
-            :deep(p) {
-              margin: 0;
-            }
-            :deep(code) {
-              background: rgba(0, 0, 0, 0.2);
-              padding: 1px 4px;
-              border-radius: 3px;
-              font-size: 12px;
-            }
-            :deep(pre) {
-              background: rgba(0, 0, 0, 0.2);
-              padding: 8px;
-              border-radius: 4px;
-              overflow-x: auto;
-              margin: 6px 0;
-              white-space: pre-wrap;
-            }
-            :deep(pre code) {
-              background: transparent;
-              padding: 0;
-            }
-          }
-
-          .attached-file-card {
-            margin-top: 10px;
-            background: rgba(255, 255, 255, 0.08);
-            border-radius: 6px;
-            display: flex;
-            align-items: center;
-            gap: 10px;
-            padding: 8px;
-            cursor: pointer;
-            transition: all 0.3s;
-
-            &:hover {
-              background: rgba(255, 255, 255, 0.15);
-            }
-
-            .file-icon-card {
-              width: 32px;
-              height: 32px;
-              background-image: var(--img-chat-attach-icon);
-              background-repeat: no-repeat;
-              background-size: 100% 100%;
-              flex-shrink: 0;
-            }
-
-            .file-info-card {
-              flex: 1;
-              min-width: 0;
-
-              .file-name-card {
-                font-size: 12px;
-                font-weight: 400;
-                line-height: 1.4;
-                color: #e0e6ed;
-                overflow: hidden;
-                text-overflow: ellipsis;
-                white-space: nowrap;
-                margin-bottom: 2px;
-              }
-
-              .file-size-card {
-                font-size: 11px;
-                font-weight: 400;
-                line-height: 1.3;
-                color: #8b949e;
-              }
-            }
-          }
-        }
-      }
-
-      .ai-message,
-      .user-message {
-        .message-actions {
-          display: flex;
-          align-items: center;
-          gap: 8px;
-          width: 100%;
-          opacity: 0;
-          transition: opacity 0.2s;
-
-          .action-btn {
-            width: 26px;
-            height: 26px;
-            display: flex;
-            align-items: center;
-            justify-content: center;
-            border-radius: 4px;
-            color: #8b949e;
-            cursor: pointer;
-            transition: all 0.2s;
-
-            &:hover {
-              color: #e0e6ed;
-              background: rgba(10, 132, 255, 0.15);
-            }
-          }
-
-          .action-time {
-            font-size: 12px;
-            color: #8b949e;
-          }
-
-          .answer-switcher {
-            display: flex;
-            align-items: center;
-            gap: 2px;
-            padding: 0 4px;
-            border-radius: 4px;
-            background: rgba(255, 255, 255, 0.04);
-
-            .answer-nav-btn {
-              width: 22px;
-              height: 22px;
-
-              &.is-disabled {
-                opacity: 0.35;
-                cursor: not-allowed;
-
-                &:hover {
-                  background: transparent;
-                  color: #8b949e;
-                }
-              }
-            }
-
-            .answer-switcher-text {
-              font-size: 12px;
-              color: #8b949e;
-              min-width: 32px;
-              text-align: center;
-              user-select: none;
-            }
-          }
-        }
-
-        &:hover .message-actions {
-          opacity: 1;
-        }
-      }
-
-      .ai-message .message-actions {
-        padding: 0 0 0 10px;
-      }
-
-      .user-message .message-actions {
-        padding: 4px 0;
-        justify-content: flex-end;
-      }
-    }
-  }
-
-  .message-edit-textarea {
-    width: 100%;
-    min-width: 500px;
-    min-height: 80px;
-    padding: 8px 12px;
-    font-size: 14px;
-    line-height: 1.6;
-    color: #e0e6ed;
-    background: rgba(0, 0, 0, 0.2);
-    border: 1px solid rgba(10, 132, 255, 0.5);
-    border-radius: 6px;
-    outline: none;
-    resize: vertical;
-    font-family: inherit;
-
-    &:focus {
-      border-color: #0a84ff;
-      background: rgba(0, 0, 0, 0.3);
-    }
-  }
-
-  .message-edit-actions {
-    display: flex;
-    gap: 8px;
-    margin-top: 8px;
-    justify-content: flex-end;
-
-    .message-edit-btn {
-      padding: 3px 14px;
-      font-size: 12px;
-      border-radius: 4px;
-      cursor: pointer;
-      border: 1px solid transparent;
-      transition: all 0.2s;
-
-      &.confirm {
-        color: #fff;
-        background: linear-gradient(135deg, #0a84ff 0%, #06c 100%);
-        border-color: rgba(10, 132, 255, 0.5);
-
-        &:hover {
-          background: linear-gradient(135deg, #3a9bfd 0%, #0a84ff 100%);
-        }
-      }
-
-      &.cancel {
-        color: #8b949e;
-        background: rgba(255, 255, 255, 0.06);
-        border-color: rgba(63, 80, 106, 0.4);
-
-        &:hover {
-          color: #c9d1d9;
-          background: rgba(255, 255, 255, 0.1);
-        }
-      }
-    }
-  }
-
-  .scroll-to-bottom {
-    position: absolute;
-    bottom: 20px;
-    left: 50%;
-    transform: translateX(-50%);
-    z-index: 10;
-
-    .scroll-to-bottom-icon {
-      width: 40px;
-      height: 40px;
-      border-radius: 50%;
-      background: rgba(255, 255, 255, 0.15);
-      backdrop-filter: blur(4px);
-      background-image: var(--img-chat-scroll-bottom);
-      background-repeat: no-repeat;
-      background-size: 20px 20px;
-      background-position: center;
-      cursor: pointer;
-      transition: all 0.2s;
-
-      &:hover {
-        background-color: rgba(255, 255, 255, 0.25);
-      }
-    }
-  }
-</style>

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

@@ -1,206 +0,0 @@
-export interface AttachedFile {
-  id: string;
-  name: string;
-  size: number;
-  type: string;
-  preview?: string;
-  content?: string;
-  arrayBuffer?: ArrayBuffer;
-  uploadTime: string;
-  messageId?: string;
-  originalFile?: File;
-}
-
-// 胶囊面板聚合文件项:用户上传的附件 + AI 返回的下载链接
-export interface CapsuleFileItem {
-  kind: 'upload' | 'download';
-  name: string;
-  size?: number;
-  // upload 类型的原始附件(用于打开右侧预览面板)
-  file?: AttachedFile;
-  // download 类型的下载地址
-  url?: string;
-}
-
-// 胶囊面板聚合链接项:从 AI 回复 markdown 文本中解析出的可跳转链接
-export interface CapsuleLinkItem {
-  title: string;
-  href: string;
-}
-
-// 胶囊面板聚合智能体条目:thinking 卡片中按(消息、智能体)聚合的一次智能体调用
-export interface CapsuleAgentItem {
-  messageIndex: number;
-  agent: string;
-  agentId?: string;
-  // running: 有 start 无 done 且消息仍在流式;done: 收到完成事件;
-  // waiting: 消息处于审批等待(智能体暂停待用户确认);interrupted: 未完成但消息已结束
-  status: 'running' | 'done' | 'waiting' | 'interrupted';
-  // 所属问答轮次(从 1 开始)
-  round: number;
-  toolCount: number;
-  stepCount: number;
-  // 是否属于多回答折叠组中被隐藏的历史回答(重新生成产生)
-  hiddenAnswer?: boolean;
-}
-
-export interface AgentAnswer {
-  id: string;
-  agentName: string;
-  toolName: string;
-  thinking: string;
-  result: string;
-  status: 'thinking' | 'completed';
-  timestamp: number;
-}
-
-export interface CitationItem {
-  id: number;
-  type: 'knowledge_base' | 'mcp' | 'web' | 'calc' | 'file' | 'history' | 'system' | 'model';
-  type_cn: string;
-  title: string;
-  tool: string;
-  tool_cn: string;
-  score?: number;
-  low_score?: boolean;
-  doc_path?: string;
-  search_url?: string;
-  snippet?: string;
-  url?: string;
-  fetched_at?: string;
-  source_system?: string;
-  param?: Record<string, any>;
-  meta?: any;
-}
-
-export interface CitationsData {
-  items: CitationItem[];
-  model_only: boolean;
-  message?: string;
-}
-
-export interface ThinkingStep {
-  type: 'agent_start' | 'agent_done' | 'tool_call' | 'tool_result' | 'executing' | 'updated_todo_list' | 'token';
-  agent?: string;
-  agentId?: string;
-  subagentType?: string;
-  thinkingContent?: string;
-  tool?: string;
-  message?: string;
-  todos?: Array<{ content: string; status: string }>;
-  content?: string;
-  timestamp: number;
-}
-
-export interface AskUserQuestion {
-  question: string;
-  type: 'text' | 'multiple_choice';
-  choices?: any[];
-  required?: boolean;
-}
-
-export interface PendingApprovalData {
-  message: string;
-  sessionId: string;
-  threadId?: string;
-  interrupts?: any[];
-  elapsedMs?: number;
-  prevThinkingContent?: string;
-  plan?: string;
-  ask_user?: {
-    questions: AskUserQuestion[];
-    tool_call_id?: string;
-  };
-  answers?: string[];
-}
-
-export interface Message {
-  id?: number;
-  type: 'user' | 'ai';
-  content: string;
-  // 原始用户输入(不含注入的技能/会话引用 <img> HTML),用于编辑重发/重新生成时回填模型输入
-  rawInput?: string;
-  thinkingContent?: string;
-  time: string;
-  createdAt?: string;
-  isTerminal?: boolean;
-  isLoading?: boolean;
-  isPendingApproval?: boolean;
-  pendingApprovalData?: PendingApprovalData;
-  attachedFile?: AttachedFile;
-  agentAnswers?: AgentAnswer[];
-  thinkingSteps?: ThinkingStep[];
-  sessionId?: string;
-  wordDownloadUrl?: string;
-  downloadFiles?: Array<{ url: string; filename: string }>;
-  citations?: CitationsData;
-  durationMs?: number;
-  baseDurationMs?: number;
-  generateStartTime?: number;
-}
-
-export interface ScheduleTask {
-  id: number;
-  name: string;
-  cron_expr: string;
-  cron_human: string;
-  prompt: string;
-  enabled: boolean;
-  next_run_at: string | null;
-  feedback_session_id?: string;
-  schedule?: { freq: string; human?: string };
-}
-
-export type RightPanelTab =
-  | { id: string; type: 'file'; title: string; file: AttachedFile; editable: boolean }
-  | { id: string; type: 'agent'; title: string; messageIndex: number; agent: string; agentId?: string }
-  | { id: string; type: 'schedule'; title: string; task: ScheduleTask };
-
-export interface RightPanelState {
-  tabs: RightPanelTab[];
-  activeTabId: string | null;
-}
-
-export interface Task {
-  id: string;
-  name: string;
-  sessionId: string;
-  messages: Message[];
-  attachedFiles: AttachedFile[];
-  wordUrl?: string;
-  updatedAt?: string;
-  sortOrder?: number;
-  isStreaming?: boolean;
-  // 流式期间从会话列表归并得到的后端会话 id(流结束前 sessionId 尚未返回)
-  pendingSessionId?: string;
-  // 右侧辅助面板标签页状态(按任务维度保存)
-  rightPanels?: RightPanelState;
-}
-
-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;
-  type: SectionType;
-}

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/CreateScheduleModal.vue → src/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue

@@ -103,7 +103,7 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { createSchedule, updateSchedule } from '../../api';
+  import { createSchedule, updateSchedule } from '/@/views/ventAI/manageAssistent/api/client';
   import { message } from 'ant-design-vue';
 
   interface ScheduleData {
@@ -169,7 +169,7 @@
           mode.value = 'friendly';
         }
       }
-    },
+    }
   );
 
   const onFreqChange = (freq: string) => {

+ 1 - 1
src/views/ventAI/manageAssistent/components/chatModal/CreateSubAgentModal.vue → src/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue

@@ -102,7 +102,7 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { createSubAgent, updateSubAgent, getSubAgentTools } from '../../api';
+  import { createSubAgent, updateSubAgent, getSubAgentTools } from '/@/views/ventAI/manageAssistent/api/client';
   import { message } from 'ant-design-vue';
 
   interface ToolInfo {

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/SkillModal.vue → src/views/ventAI/manageAssistent/components/modals/SkillModal.vue

@@ -50,8 +50,8 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { getSkills, toggleSkill, getSkillDetails } from '../../api';
-  import { renderMarkdown } from './utils';
+  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api/client';
+  import { renderMarkdown } from '/@/views/ventAI/manageAssistent/lib/index';
   import { message } from 'ant-design-vue';
 
   interface Skill {

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/AgentDetailPanel.vue → src/views/ventAI/manageAssistent/components/panels/AgentDetailPanel.vue

@@ -94,8 +94,8 @@
 
 <script setup lang="ts">
   import { ref, computed } from 'vue';
-  import { renderMarkdown } from './utils';
-  import type { Message, ThinkingStep } from './types';
+  import { renderMarkdown } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { Message, ThinkingStep } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     messageIndex: number;

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/FileListPanel.vue → src/views/ventAI/manageAssistent/components/panels/FileListPanel.vue

@@ -19,8 +19,8 @@
 
 <script setup lang="ts">
   import { CloseOutlined } from '@ant-design/icons-vue';
-  import { formatFileSize, getFileIconVar } from './utils';
-  import type { AttachedFile } from './types';
+  import { formatFileSize, getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
 
   defineProps<{
     visible: boolean;

+ 3 - 3
src/views/ventAI/manageAssistent/components/chatModal/RightPanelTabs.vue → src/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue

@@ -80,11 +80,11 @@
 <script setup lang="ts">
   import { computed, nextTick, ref } from 'vue';
   import { onClickOutside, useResizeObserver } from '@vueuse/core';
-  import FilePreviewPanel from './FilePreviewPanel.vue';
+  import FilePreviewPanel from '/@/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue';
   import AgentDetailPanel from './AgentDetailPanel.vue';
   import ScheduleDetailPanel from './ScheduleDetailPanel.vue';
-  import { getFileIconVar } from './utils';
-  import type { Message, RightPanelTab } from './types';
+  import { getFileIconVar } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { Message, RightPanelTab } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     tabs: RightPanelTab[];

+ 3 - 3
src/views/ventAI/manageAssistent/components/chatModal/ScheduleDetailPanel.vue → src/views/ventAI/manageAssistent/components/panels/ScheduleDetailPanel.vue

@@ -89,9 +89,9 @@
   import { ref, watch } from 'vue';
   import { onClickOutside } from '@vueuse/core';
   import { message, Modal } from 'ant-design-vue';
-  import { getScheduleRuns, pauseSchedule, resumeSchedule, deleteSchedule } from '../../api';
-  import CreateScheduleModal from './CreateScheduleModal.vue';
-  import type { ScheduleTask } from './types';
+  import { getScheduleRuns, pauseSchedule, resumeSchedule, deleteSchedule } from '/@/views/ventAI/manageAssistent/api/client';
+  import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
+  import type { ScheduleTask } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{ task: ScheduleTask | null; hideHeader?: boolean }>();
   const emit = defineEmits<{

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/SchedulePanel.vue → src/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue

@@ -111,9 +111,9 @@
 <script setup lang="ts">
   import { ref, reactive, computed, onMounted } from 'vue';
   import { PlusOutlined } from '@ant-design/icons-vue';
-  import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '../../api';
+  import { getScheduleList, deleteSchedule, pauseSchedule, resumeSchedule, getScheduleRuns } from '/@/views/ventAI/manageAssistent/api/client';
   import { message, Modal } from 'ant-design-vue';
-  import CreateScheduleModal from './CreateScheduleModal.vue';
+  import CreateScheduleModal from '/@/views/ventAI/manageAssistent/components/modals/CreateScheduleModal.vue';
 
   const emit = defineEmits<{ (e: 'close'): void; (e: 'open-session', sessionId: string, prompt: string): void; (e: 'data-changed'): void }>();
 

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/SkillPanel.vue → src/views/ventAI/manageAssistent/components/panels/SkillPanel.vue

@@ -57,8 +57,8 @@
 
 <script setup lang="ts">
   import { ref, reactive, computed, watch } from 'vue';
-  import { getSkills, toggleSkill, getSkillDetails } from '../../api';
-  import { renderMarkdown } from './utils';
+  import { getSkills, toggleSkill, getSkillDetails } from '/@/views/ventAI/manageAssistent/api/client';
+  import { renderMarkdown } from '/@/views/ventAI/manageAssistent/lib/index';
   import { message } from 'ant-design-vue';
 
   interface Skill {

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/SubAgentPanel.vue → src/views/ventAI/manageAssistent/components/panels/SubAgentPanel.vue

@@ -86,9 +86,9 @@
 <script setup lang="ts">
   import { ref, computed } from 'vue';
   import { PlusOutlined } from '@ant-design/icons-vue';
-  import { getSubAgents, deleteSubAgent, toggleSubAgent } from '../../api';
+  import { getSubAgents, deleteSubAgent, toggleSubAgent } from '/@/views/ventAI/manageAssistent/api/client';
   import { message, Modal } from 'ant-design-vue';
-  import CreateSubAgentModal from './CreateSubAgentModal.vue';
+  import CreateSubAgentModal from '/@/views/ventAI/manageAssistent/components/modals/CreateSubAgentModal.vue';
 
   interface ToolInfo {
     name: string;

+ 1 - 1
src/views/ventAI/manageAssistent/components/chatModal/TaskListPanel.vue → src/views/ventAI/manageAssistent/components/panels/TaskListPanel.vue

@@ -55,7 +55,7 @@
 
 <script setup lang="ts">
   import { ref, computed } from 'vue';
-  import type { Task } from './types';
+  import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     taskList: Task[];

+ 3 - 3
src/views/ventAI/manageAssistent/components/chatModal/FilePreviewPanel.vue → src/views/ventAI/manageAssistent/components/resource/FilePreviewPanel.vue

@@ -103,7 +103,7 @@
 <script setup lang="ts">
   import { ref, computed, watch, onUnmounted, defineAsyncComponent } from 'vue';
   import { message } from 'ant-design-vue';
-  import { saveReportText } from '../../api';
+  import { saveReportText } from '/@/views/ventAI/manageAssistent/api/client';
   import {
     isImageFile,
     isPdfFile,
@@ -117,8 +117,8 @@
     getFileIconVar,
     renderMarkdown,
     wrapMarkdownCodeBlocks,
-  } from './utils';
-  import type { AttachedFile } from './types';
+  } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
 
   // [perf-base-v1] 卡2c: VueOfficeDocx 改异步加载,仅打开 docx 预览时才拉取 office-preview chunk
   const VueOfficeDocx = defineAsyncComponent(() => import('@vue-office/docx/lib/v3/vue-office-docx.mjs'));

+ 2 - 2
src/views/ventAI/manageAssistent/components/chatModal/ResourceCapsule.vue → src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue

@@ -140,8 +140,8 @@
   import { CloseOutlined } from '@ant-design/icons-vue';
   import { onClickOutside, onKeyStroke } from '@vueuse/core';
   import { message } from 'ant-design-vue';
-  import { formatFileSize, getFileIconVar, fetchPreviewFile } from './utils';
-  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
+  import { formatFileSize, getFileIconVar, fetchPreviewFile } from '/@/views/ventAI/manageAssistent/lib/index';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from '/@/views/ventAI/manageAssistent/types/index';
 
   const props = defineProps<{
     files: CapsuleFileItem[];

+ 246 - 0
src/views/ventAI/manageAssistent/hooks/useChatApproval.ts

@@ -0,0 +1,246 @@
+import { computed, ref } from 'vue';
+import { message } from 'ant-design-vue';
+import { chatResumeStream, StreamError } from '/@/views/ventAI/manageAssistent/api/client';
+import type { SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/**
+ * 待审批(ask_user 中断)流程:按任务登记审批信息、提交答案续跑、取消提问。
+ *
+ * 审批状态按任务 id 存放;提交/取消都要走一次 resume 流,因此需要注入 SSE 事件处理、
+ * AbortController 管理与滚动能力,保持与发送流程一致的行为。
+ */
+export interface ChatApprovalDeps {
+  getTaskList: () => Task[];
+  getCurrentTaskId: () => string;
+  switchTask: (taskId: string) => void | Promise<void>;
+  setTaskAbortController: (taskId: string, controller: AbortController | null) => void;
+  handleStreamEvent: (data: SseEvent, aiMsgIndex: number, taskId: string) => void;
+  scrollToBottom: (force?: boolean) => Promise<void>;
+  scrollToBottomThrottled: (force?: boolean) => void;
+  refreshUsage: () => void;
+  resetTodos: () => void;
+  setCurrentSessionId: (sessionId: string) => void;
+}
+
+export const useChatApproval = (deps: ChatApprovalDeps) => {
+  const {
+    getTaskList,
+    getCurrentTaskId,
+    switchTask,
+    setTaskAbortController,
+    handleStreamEvent,
+    scrollToBottom,
+    scrollToBottomThrottled,
+    refreshUsage,
+    resetTodos,
+    setCurrentSessionId,
+  } = deps;
+  // 按任务维度的状态存储
+  const taskPendingApprovals = ref<Map<string, { sessionId: string; threadId: string }>>(new Map());
+
+  // 获取当前任务的待审批状态
+  const currentPendingApproval = computed(() => {
+    return taskPendingApprovals.value.get(getCurrentTaskId());
+  });
+
+  // 设置任务的审批状态
+  const setTaskPendingApproval = (taskId: string, sessionId: string, threadId: string) => {
+    const map = new Map(taskPendingApprovals.value);
+    map.set(taskId, { sessionId, threadId });
+    taskPendingApprovals.value = map;
+  };
+
+  // 清除任务的审批状态
+  const clearTaskPendingApproval = (taskId: string) => {
+    const map = new Map(taskPendingApprovals.value);
+    map.delete(taskId);
+    taskPendingApprovals.value = map;
+  };
+
+  // 任务 ID 迁移(流式期间临时 id → 后端 session_id)时同步审批状态的 key,否则审批会跟丢任务
+  const migrateTaskApproval = (fromTaskId: string, toTaskId: string) => {
+    const entry = taskPendingApprovals.value.get(fromTaskId);
+    if (!entry) return;
+    const map = new Map(taskPendingApprovals.value);
+    map.delete(fromTaskId);
+    map.set(toTaskId, entry);
+    taskPendingApprovals.value = map;
+  };
+
+  const findPendingApprovalTask = (): { taskId: string; approval: { sessionId: string; threadId?: string } } | null => {
+    // 优先用当前任务 ID 查找
+    const approval = currentPendingApproval.value;
+    if (approval) return { taskId: getCurrentTaskId(), approval };
+    // 遍历所有审批(可能任务 ID 已变,用 sessionId 做回退匹配)
+    for (const [taskId, a] of taskPendingApprovals.value.entries()) {
+      return { taskId, approval: a };
+    }
+    return null;
+  };
+
+  const handleAskUserSubmit = async (answers: string[]) => {
+    // 防重复提交:如果当前任务正在流式中,忽略
+    const currentTask = getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (currentTask?.isStreaming) return;
+
+    const found = findPendingApprovalTask();
+
+    // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
+    let sessionId = found?.approval.sessionId;
+    let threadId = found?.approval.threadId;
+    const foundTaskId = found?.taskId;
+
+    // 查找任务:优先用 foundTaskId,回退到 currentTaskId
+    const task = (foundTaskId ? getTaskList().find((t) => t.id === foundTaskId) : null) || getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (!task) return;
+
+    // 切换到目标任务(如果不在当前视图)
+    if (task.id !== getCurrentTaskId()) {
+      await switchTask(task.id);
+    }
+
+    // 单一数据源:消息只存在于任务自己的 messages 数组里(当前任务的 messages.value 就是它)
+    const sourceArr = 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 = sourceArr.length - 1;
+
+    task.isStreaming = true;
+    resetTodos();
+
+    const controller = new AbortController();
+    setTaskAbortController(task.id, controller);
+
+    try {
+      const result = await chatResumeStream(
+        {
+          session_id: sessionId,
+          thread_id: threadId || undefined,
+          status: 'answered',
+          answers,
+          signal: controller.signal,
+        },
+        (data: SseEvent) => {
+          handleStreamEvent(data, aiMsgIndex, task.id);
+          if (getCurrentTaskId() === task.id) scrollToBottomThrottled();
+        }
+      );
+
+      if (result.session_id) {
+        setCurrentSessionId(result.session_id);
+      }
+    } catch (error) {
+      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);
+        const isStreamError = error instanceof StreamError;
+        message.error(isStreamError ? error.message : '提交失败,请重试');
+        if (isStreamError) refreshUsage();
+        lastMsg.isLoading = false;
+        lastMsg.durationMs = (lastMsg.baseDurationMs || 0) + (lastMsg.generateStartTime ? Date.now() - lastMsg.generateStartTime : 0);
+        if (!lastMsg.content) {
+          lastMsg.content = isStreamError ? error.message : '抱歉,提交请求失败,请稍后重试。';
+        }
+      }
+    } finally {
+      setTaskAbortController(task.id, null);
+      task.isStreaming = false;
+      // 仅当消息不再处于审批状态时才清除(避免 resume 流返回新 interrupt 被误清)
+      if (!lastMsg.isPendingApproval) {
+        clearTaskPendingApproval(task.id);
+      }
+      if (getCurrentTaskId() === task.id) {
+        await scrollToBottom();
+      }
+    }
+  };
+
+  const handleAskUserCancel = async () => {
+    // 防重复操作:如果当前任务正在流式中,忽略
+    const currentTask = getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (currentTask?.isStreaming) return;
+
+    const found = findPendingApprovalTask();
+
+    // 优先从 taskPendingApprovals 获取,若被清除则从消息的 pendingApprovalData 回退
+    let sessionId = found?.approval.sessionId;
+    let threadId = found?.approval.threadId;
+    const foundTaskId = found?.taskId;
+
+    // 查找任务:优先用 foundTaskId,回退到 currentTaskId
+    const task = (foundTaskId ? getTaskList().find((t) => t.id === foundTaskId) : null) || getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (!task) return;
+
+    // 切换到目标任务(如果不在当前视图)
+    if (task.id !== getCurrentTaskId()) {
+      await switchTask(task.id);
+    }
+
+    // 单一数据源:消息只存在于任务自己的 messages 数组里
+    const sourceArr = 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;
+    lastMsg.pendingApprovalData = undefined;
+    lastMsg.isLoading = false;
+    lastMsg.content = (lastMsg.content ? lastMsg.content + '\n\n' : '') + '已取消提问。';
+
+    clearTaskPendingApproval(task.id);
+
+    try {
+      await chatResumeStream(
+        {
+          session_id: sessionId,
+          thread_id: threadId || undefined,
+          status: 'cancelled',
+        },
+        () => {}
+      );
+    } catch (error) {
+      console.error('取消操作失败:', error);
+    }
+
+    task.isStreaming = false;
+
+    if (getCurrentTaskId() === task.id) {
+      await scrollToBottom();
+    }
+  };
+  return {
+    setTaskPendingApproval,
+    clearTaskPendingApproval,
+    migrateTaskApproval,
+    currentPendingApproval,
+    findPendingApprovalTask,
+    handleAskUserSubmit,
+    handleAskUserCancel,
+  };
+};

+ 84 - 0
src/views/ventAI/manageAssistent/hooks/useChatDrag.ts

@@ -0,0 +1,84 @@
+import { nextTick, onBeforeUnmount, watch } from 'vue';
+import { applyWrapStyles, findModalWrap, queryModalWrap } from '../lib/modalWrap';
+
+/**
+ * 弹框拖拽与居中:拖标题栏移动窗口,缩放环境下把鼠标视口坐标换算回布局坐标。
+ *
+ * 拖拽状态刻意用普通变量(非响应式),避免 mousemove 触发渲染;
+ * modal-wrap 的定位样式统一走 lib/modalWrap,与面板侧的全屏逻辑共用同一套选择器。
+ */
+export interface ChatDragDeps {
+  getIsFullscreen: () => boolean;
+  getVisible: () => boolean | undefined;
+}
+
+export const useChatDrag = (deps: ChatDragDeps) => {
+  // 拖拽相关(使用普通变量避免 mousemove 触发 Vue 响应式导致卡顿)
+  let isDragging = false;
+  let dragStartX = 0;
+  let dragStartY = 0;
+  let modalStartLeft = 0;
+  let modalStartTop = 0;
+  // 容器 scale 缩放比(视觉尺寸/布局尺寸),用于把鼠标视口坐标换算回布局坐标
+  let dragScaleX = 1;
+  let dragScaleY = 1;
+  let draggedEl: HTMLElement | null = null;
+
+  const centerModal = () => {
+    nextTick(() => {
+      applyWrapStyles(deps.getIsFullscreen());
+      draggedEl = null;
+    });
+  };
+
+  const onDragStart = (e: MouseEvent) => {
+    if (deps.getIsFullscreen()) return;
+    draggedEl = findModalWrap(e.target as HTMLElement) || queryModalWrap();
+    if (!draggedEl) return;
+
+    // 记录容器缩放比,并把鼠标/弹框的视口坐标换算回布局坐标,避免不同分辨率下拖拽位移失真
+    const rect = draggedEl.getBoundingClientRect();
+    dragScaleX = draggedEl.offsetWidth ? rect.width / draggedEl.offsetWidth : 1;
+    dragScaleY = draggedEl.offsetHeight ? rect.height / draggedEl.offsetHeight : 1;
+
+    isDragging = true;
+    dragStartX = e.clientX;
+    dragStartY = e.clientY;
+    modalStartLeft = rect.left / dragScaleX;
+    modalStartTop = rect.top / dragScaleY;
+
+    draggedEl.style.position = 'fixed';
+    draggedEl.style.left = `${modalStartLeft}px`;
+    draggedEl.style.top = `${modalStartTop}px`;
+    draggedEl.style.margin = '0';
+    draggedEl.style.transform = 'none';
+
+    document.addEventListener('mousemove', onDragMove);
+    document.addEventListener('mouseup', onDragEnd);
+  };
+
+  const onDragMove = (e: MouseEvent) => {
+    if (!isDragging || !draggedEl) return;
+    draggedEl.style.left = `${modalStartLeft + (e.clientX - dragStartX) / dragScaleX}px`;
+    draggedEl.style.top = `${modalStartTop + (e.clientY - dragStartY) / dragScaleY}px`;
+  };
+
+  const onDragEnd = () => {
+    isDragging = false;
+    document.removeEventListener('mousemove', onDragMove);
+    document.removeEventListener('mouseup', onDragEnd);
+  };
+
+  watch(deps.getVisible, (newVisible) => {
+    if (newVisible) {
+      centerModal();
+    }
+  });
+
+  onBeforeUnmount(() => {
+    document.removeEventListener('mousemove', onDragMove);
+    document.removeEventListener('mouseup', onDragEnd);
+  });
+
+  return { onDragStart, centerModal };
+};

+ 69 - 0
src/views/ventAI/manageAssistent/hooks/useChatExport.ts

@@ -0,0 +1,69 @@
+import { computed } from 'vue';
+import dayjs from 'dayjs';
+import { message } from 'ant-design-vue';
+import { buildConversationMarkdown, markdownToDocxBlob } from '/@/views/ventAI/manageAssistent/lib/exportDoc';
+import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/** 会话导出(Markdown / Word)与报告下载;消息与当前任务以 getter 传入,保证拿到最新值 */
+export const useChatExport = (getMessages: () => Message[], getCurrentTask: () => Task | undefined) => {
+  const currentWordUrl = computed(() => {
+    return getCurrentTask()?.wordUrl || '';
+  });
+
+  const downloadWordFile = (url: string) => {
+    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 downloadWordReport = () => downloadWordFile(currentWordUrl.value);
+
+  const sanitizeFileName = (name: string): string => (name || '会话').replace(/[\\/:*?"<>|]/g, '_');
+
+  const downloadBlob = (blob: Blob, filename: string) => {
+    const url = URL.createObjectURL(blob);
+    const a = document.createElement('a');
+    a.href = url;
+    a.download = filename;
+    document.body.appendChild(a);
+    a.click();
+    document.body.removeChild(a);
+    URL.revokeObjectURL(url);
+  };
+
+  // 导出会话为 Markdown
+  const handleExportMarkdown = () => {
+    if (!getMessages().length) {
+      message.warning('当前会话暂无内容');
+      return;
+    }
+    const md = buildConversationMarkdown(getMessages());
+    const filename = `${sanitizeFileName(getCurrentTask()?.name || '会话')}_${dayjs().format('YYYYMMDD_HHmmss')}.md`;
+    downloadBlob(new Blob([md], { type: 'text/markdown;charset=utf-8;' }), filename);
+  };
+
+  // 导出会话为 Word (.docx)
+  const handleExportWord = async () => {
+    if (!getMessages().length) {
+      message.warning('当前会话暂无内容');
+      return;
+    }
+    try {
+      const md = buildConversationMarkdown(getMessages());
+      const title = getCurrentTask()?.name || '会话';
+      const blob = await markdownToDocxBlob(md, title);
+      const filename = `${sanitizeFileName(title)}_${dayjs().format('YYYYMMDD_HHmmss')}.docx`;
+      downloadBlob(blob, filename);
+    } catch (e) {
+      console.error('导出 Word 失败:', e);
+      message.error('导出 Word 失败');
+    }
+  };
+
+  return { currentWordUrl, downloadWordFile, downloadWordReport, handleExportMarkdown, handleExportWord };
+};

+ 81 - 0
src/views/ventAI/manageAssistent/hooks/useChatFiles.ts

@@ -0,0 +1,81 @@
+import { ref } from 'vue';
+import dayjs from 'dayjs';
+import { message } from 'ant-design-vue';
+import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/index';
+import { isAudioFile, isCsvFile, isExcelFile, isTextFile, isVideoFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib/index';
+import type ChatInputArea from '/@/views/ventAI/manageAssistent/components/chat/ChatInputArea.vue';
+
+/** 输入区附件:选择文件 → 按类型读取(文本/二进制/预览)→ 待发送状态 */
+export const useChatFiles = (getChatInput: () => InstanceType<typeof ChatInputArea> | undefined) => {
+  const pendingFile = ref<AttachedFile | null>(null);
+  const activeFileReader = ref<FileReader | null>(null);
+
+  const triggerFileUpload = () => {
+    getChatInput()?.fileInputRef?.click();
+  };
+
+  const handleFileUpload = async (event: Event) => {
+    const target = event.target as HTMLInputElement;
+    const file = target.files?.[0];
+    if (!file) return;
+
+    if (isVideoFile(file.name) || isAudioFile(file.name)) {
+      message.warning('不支持上传音频和视频文件');
+      target.value = '';
+      return;
+    }
+
+    try {
+      const newFile: AttachedFile = {
+        id: `file-${Date.now()}`,
+        name: file.name,
+        size: file.size,
+        type: file.type,
+        uploadTime: dayjs().format('YYYY-MM-DD HH:mm'),
+        originalFile: file,
+      };
+
+      // Abort previous FileReader if still in progress
+      if (activeFileReader.value) {
+        activeFileReader.value.abort();
+        activeFileReader.value = null;
+      }
+
+      const reader = new FileReader();
+      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.readAsArrayBuffer(file);
+      } else {
+        reader.readAsDataURL(file);
+      }
+
+      pendingFile.value = newFile;
+      message.success(`文件 "${file.name}" 已添加,点击发送按钮提交`);
+    } catch (error) {
+      console.error('文件处理失败:', error);
+      message.error('文件处理失败,请重试');
+    }
+
+    target.value = '';
+  };
+
+  const removePendingFile = () => {
+    pendingFile.value = null;
+  };
+
+  return { pendingFile, activeFileReader, triggerFileUpload, handleFileUpload, removePendingFile };
+};

+ 266 - 0
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

@@ -0,0 +1,266 @@
+import { computed, nextTick, ref, watch } from 'vue';
+import { applyWrapStyles } from '../lib/modalWrap';
+import type { AgentDetailData, AttachedFile, Message, RightPanelState, RightPanelTab, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/**
+ * 右侧面板与标签页:面板开关/折叠、标签页增删激活、文件与智能体详情标签、全屏与弹框宽度,
+ * 以及顶部的上一/下一个会话导航。
+ *
+ * 标签页状态挂在任务对象上(按任务维度保存),因此需要注入当前任务与消息的取值函数;
+ * 全屏后归位依赖拖拽侧状态,由调用方注入 resetModalPosition。
+ */
+export interface ChatPanelsDeps {
+  getCurrentTask: () => Task | undefined;
+  getMessages: () => Message[];
+  getChatMessages: () => { scrollToMessage: (index: number, block?: string) => void } | undefined;
+  getContainer: () => HTMLElement;
+  resetModalPosition: () => void;
+  defaultFullscreen: boolean;
+}
+
+export const useChatPanels = (deps: ChatPanelsDeps) => {
+  const { getCurrentTask, getMessages, getChatMessages, getContainer, resetModalPosition, defaultFullscreen } = deps;
+  const showFileList = ref(false);
+  const activePanel = ref<'chat' | 'skill' | 'sub-agents' | 'schedules'>('chat');
+  const panelCollapsed = ref(false);
+  const togglePanel = () => {
+    panelCollapsed.value = !panelCollapsed.value;
+  };
+
+  const toggleSidePanel = (panel: 'skill' | 'schedules' | 'sub-agents') => {
+    if (activePanel.value === panel) {
+      activePanel.value = 'chat';
+    } else {
+      activePanel.value = panel;
+    }
+  };
+
+  // Keep backward-compatible aliases
+  const toggleSkillPanel = () => toggleSidePanel('skill');
+  const toggleSchedulePanel = () => toggleSidePanel('schedules');
+  const toggleSubAgentPanel = () => toggleSidePanel('sub-agents');
+
+  const isFullscreen = ref(!!defaultFullscreen);
+
+  const currentRightPanels = computed<RightPanelState>(() => {
+    const task = getCurrentTask();
+    return task?.rightPanels || { tabs: [], activeTabId: null };
+  });
+
+  // 是否有右侧辅助面板(标签页容器)与 chat-panel 并列显示
+  const hasRightPanel = computed(() => {
+    if (activePanel.value !== 'chat') return false;
+    return currentRightPanels.value.tabs.length > 0;
+  });
+
+  // 面板离开动画标记:确保 modal 在面板退出动画结束后才收缩宽度
+  const isPanelLeaving = ref(false);
+  const modalShouldExpand = computed(() => hasRightPanel.value || isPanelLeaving.value);
+
+  // modal 宽度:全屏时用容器实际宽度(容器自带 scale 统一缩放适配视口),挂 body 时用 100vw;
+  // 有右侧面板时 1600 / 默认 1400(设计稿像素)。高度由 CSS 的 height:100% 撑满容器
+  const modalWidth = computed(() => {
+    if (isFullscreen.value) {
+      const container = getContainer();
+      return container === document.body ? '100vw' : `${container.clientWidth}px`;
+    }
+    if (modalShouldExpand.value) return 1600;
+    return 1400;
+  });
+
+  // 右侧面板离开动画结束后,允许 modal 宽度收缩
+  const onPanelLeaveEnd = () => {
+    isPanelLeaving.value = false;
+  };
+
+  // 监听右侧面板从有到无,标记正在离开状态,防止 modal 宽度提前收缩
+  watch(hasRightPanel, (newVal, oldVal) => {
+    if (oldVal && !newVal) {
+      isPanelLeaving.value = true;
+    }
+  });
+
+  // 全屏切换,附带缩放动画效果
+  const toggleFullscreen = () => {
+    const enteringFs = !isFullscreen.value;
+    isFullscreen.value = enteringFs;
+    nextTick(() => {
+      // 给 modal-content 添加缩放动画类,动画结束后自动移除
+      const content = document.querySelector<HTMLElement>('.ai-assistant-modal .zxm-modal-content');
+      if (content) {
+        const animClass = enteringFs ? 'modal-zoom-in-anim' : 'modal-zoom-out-anim';
+        content.classList.remove('modal-zoom-in-anim', 'modal-zoom-out-anim');
+        void content.offsetWidth; // 强制重排,确保动画重新触发
+        content.classList.add(animClass);
+        content.addEventListener('animationend', () => content.classList.remove(animClass), { once: true });
+      }
+
+      applyWrapStyles(isFullscreen.value);
+      resetModalPosition();
+    });
+  };
+
+  const openFileTab = (file: AttachedFile, editable: boolean) => {
+    const task = getCurrentTask();
+    if (!task) return;
+    const state = getPanelState(task);
+    const existing = state.tabs.find((t) => t.type === 'file' && t.file.id === file.id);
+    if (existing) {
+      // 编辑入口可升级已有标签为可编辑;预览入口不降级
+      if (editable && existing.type === 'file') existing.editable = true;
+      state.activeTabId = existing.id;
+      return;
+    }
+    openTab({ id: nextTabId(), type: 'file', title: file.name, file, editable });
+  };
+
+  const handleAgentDetailClick = (data: AgentDetailData) => {
+    const task = getCurrentTask();
+    if (!task) return;
+    const state = getPanelState(task);
+    const existing = state.tabs.find(
+      (t) => t.type === 'agent' && t.messageIndex === data.messageIndex && (t.agentId || '') === (data.agentId || '') && t.agent === data.agent
+    );
+    if (existing) {
+      if (state.activeTabId === existing.id) {
+        // 保持原交互:再次点击已激活的 agent 则关闭
+        removeTab(state, existing.id);
+      } else {
+        state.activeTabId = existing.id;
+      }
+      return;
+    }
+    openTab({
+      id: nextTabId(),
+      type: 'agent',
+      title: data.agent,
+      messageIndex: data.messageIndex,
+      agent: data.agent,
+      agentId: data.agentId,
+    });
+  };
+
+  // 胶囊面板"定位到消息":滚动消息列表到该智能体所在的消息
+  const handleLocateMessage = (messageIndex: number) => {
+    getChatMessages()?.scrollToMessage(messageIndex, 'center');
+  };
+
+  // 在右侧面板新建标签页展示 Markdown 代码块渲染效果(相同内容复用同一标签)
+  const openMarkdownPreviewTab = (payload: { title: string; content: string }) => {
+    let hash = 0;
+    for (let i = 0; i < payload.content.length; i++) {
+      hash = (hash * 31 + payload.content.charCodeAt(i)) | 0;
+    }
+    openFileTab(
+      {
+        id: `md-preview-${hash}`,
+        name: `${payload.title}.md`,
+        size: payload.content.length,
+        type: 'text/markdown',
+        content: payload.content,
+        uploadTime: '',
+      },
+      false
+    );
+  };
+
+  const openFilePreview = (file: AttachedFile) => {
+    openFileTab(file, false);
+
+    const msgIndex = getMessages().findIndex((m) => m.attachedFile?.id === file.id);
+    if (msgIndex !== -1) {
+      nextTick(() => {
+        getChatMessages()?.scrollToMessage(msgIndex, 'center');
+      });
+    }
+  };
+
+  const getCurrentTaskFiles = (): AttachedFile[] => {
+    return getCurrentTask()?.attachedFiles || [];
+  };
+
+  // 右侧辅助面板标签页状态(按任务维度保存,切换任务自动恢复)
+  let tabSeq = 0;
+  const nextTabId = () => `panel-${Date.now()}-${tabSeq++}`;
+
+  const getPanelState = (task: Task): RightPanelState => {
+    if (!task.rightPanels) {
+      task.rightPanels = { tabs: [], activeTabId: null };
+    }
+    return task.rightPanels;
+  };
+
+  const openTab = (tab: RightPanelTab) => {
+    const task = getCurrentTask();
+    if (!task) return;
+    const state = getPanelState(task);
+    state.tabs.push(tab);
+    state.activeTabId = tab.id;
+  };
+
+  const activateTab = (tabId: string) => {
+    const task = getCurrentTask();
+    if (!task?.rightPanels) return;
+    if (task.rightPanels.tabs.some((t) => t.id === tabId)) {
+      task.rightPanels.activeTabId = tabId;
+    }
+  };
+
+  const removeTab = (state: RightPanelState, tabId: string) => {
+    const idx = state.tabs.findIndex((t) => t.id === tabId);
+    if (idx === -1) return;
+    state.tabs.splice(idx, 1);
+    if (state.activeTabId === tabId) {
+      state.activeTabId = state.tabs.length ? (state.tabs[idx] || state.tabs[idx - 1]).id : null;
+    }
+  };
+
+  const closeTab = (tabId: string) => {
+    const task = getCurrentTask();
+    if (!task?.rightPanels) return;
+    removeTab(task.rightPanels, tabId);
+  };
+
+  const handleOpenFilePreview = (file: AttachedFile) => {
+    openFileTab(file, true);
+  };
+
+  const handleSaveSuccess = (content: string) => {
+    const task = getCurrentTask();
+    const tab = task?.rightPanels?.tabs.find((t) => t.id === task.rightPanels?.activeTabId);
+    if (tab?.type === 'file') {
+      tab.file = { ...tab.file, content };
+    }
+  };
+
+  return {
+    showFileList,
+    activePanel,
+    panelCollapsed,
+    togglePanel,
+    toggleSidePanel,
+    toggleSkillPanel,
+    toggleSchedulePanel,
+    toggleSubAgentPanel,
+    isFullscreen,
+    currentRightPanels,
+    hasRightPanel,
+    modalShouldExpand,
+    modalWidth,
+    onPanelLeaveEnd,
+    toggleFullscreen,
+    getPanelState,
+    openTab,
+    activateTab,
+    removeTab,
+    closeTab,
+    openFileTab,
+    handleAgentDetailClick,
+    handleLocateMessage,
+    openMarkdownPreviewTab,
+    openFilePreview,
+    handleOpenFilePreview,
+    handleSaveSuccess,
+    getCurrentTaskFiles,
+  };
+};

+ 103 - 0
src/views/ventAI/manageAssistent/hooks/useChatQuota.ts

@@ -0,0 +1,103 @@
+import { ref } from 'vue';
+import { message } from 'ant-design-vue';
+import { getContext, getModelMsg, getSessionMode, getTodayUsage, putSessionMode, switchThinking } from '/@/views/ventAI/manageAssistent/api/client';
+import type { TodayUsage } from '/@/views/ventAI/manageAssistent/api/client';
+
+/**
+ * 额度与上下文用量、会话权限模式、模型思考级别。
+ * 这些状态挂在弹框层(跨会话共享),切会话时由外部按 sessionId 刷新。
+ */
+export const useChatQuota = (getCurrentSessionId: () => string) => {
+  const usageToday = ref<TodayUsage | null>(null);
+
+  const fetchTodayUsage = async () => {
+    try {
+      const res = await getTodayUsage();
+      if (res && typeof res === 'object') {
+        usageToday.value = res;
+      }
+    } catch (e) {
+      console.error('获取今日用量失败:', e);
+    }
+  };
+
+  const contextUsed = ref(0);
+  const contextMax = ref(1000000);
+  const contextPercent = ref(0);
+  const thinkLevel = ref('off');
+  const editMode = ref('full');
+  const contextBreakdown = ref({ messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 });
+
+  const fetchModelMsg = async () => {
+    try {
+      const res = await getModelMsg();
+      if (res?.thinking_level) {
+        thinkLevel.value = res.thinking_level;
+      }
+    } catch (e) {
+      console.error('获取模型信息失败:', e);
+    }
+  };
+
+  const fetchSessionMode = async (sessionId: string) => {
+    try {
+      const res = await getSessionMode(sessionId);
+      if (res?.mode) {
+        editMode.value = res.mode;
+      }
+    } catch (e) {
+      console.error('获取会话模式失败:', e);
+    }
+  };
+
+  const fetchContext = async (sessionId: string) => {
+    try {
+      const res = await getContext(sessionId);
+      contextUsed.value = res?.current_usage ?? 0;
+      contextMax.value = res?.total_limit ?? 0;
+      contextPercent.value = res?.usage_pct ?? 0;
+      contextBreakdown.value = res?.breakdown ?? { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
+    } catch (e) {
+      console.error('获取上下文容量失败:', e);
+      contextUsed.value = 0;
+      contextMax.value = 0;
+      contextPercent.value = 0;
+      contextBreakdown.value = { messages: 0, mcp: 0, skills: 0, system_prompt: 0, other: 0 };
+    }
+  };
+
+  const handleThinkLevelChange = async (levelKey: string) => {
+    try {
+      await switchThinking(levelKey);
+    } catch {
+      message.error('切换思考级别失败');
+    }
+  };
+
+  const handleEditModeChange = async (modeKey: string) => {
+    editMode.value = modeKey;
+    const sessionId = getCurrentSessionId();
+    if (!sessionId) return;
+    try {
+      await putSessionMode(sessionId, modeKey);
+    } catch {
+      message.error('切换模式失败');
+    }
+  };
+
+  return {
+    usageToday,
+    fetchTodayUsage,
+    contextUsed,
+    contextMax,
+    contextPercent,
+    contextBreakdown,
+    thinkLevel,
+    editMode,
+    fetchModelMsg,
+    fetchSessionMode,
+    fetchContext,
+    handleThinkLevelChange,
+    handleEditModeChange,
+  };
+};

+ 34 - 0
src/views/ventAI/manageAssistent/hooks/useChatScroll.ts

@@ -0,0 +1,34 @@
+import { nextTick } from 'vue';
+import type ChatMessages from '/@/views/ventAI/manageAssistent/components/chat/ChatMessages.vue';
+
+/**
+ * 消息区滚动控制:流式增量用 rAF 合并到每帧一次,显式调用走 async 版本(等 nextTick 再滚)。
+ * 滚动目标实例由调用方以 getter 传入,composable 不直接依赖组件 ref。
+ */
+export const useChatScroll = (getChatMessages: () => InstanceType<typeof ChatMessages> | undefined) => {
+  let scrollRafId: number | null = null;
+  const scrollToBottomThrottled = (force = false) => {
+    if (scrollRafId) cancelAnimationFrame(scrollRafId);
+    scrollRafId = requestAnimationFrame(async () => {
+      scrollRafId = null;
+      await nextTick();
+      if (!force && !getChatMessages()?.isAtBottom) return;
+      const el = getChatMessages()?.messagesRef;
+      if (el) {
+        el.scrollTop = el.scrollHeight - el.clientHeight;
+      }
+    });
+  };
+
+  // Async version for explicit await calls
+  const scrollToBottom = async (force = false) => {
+    await nextTick();
+    if (!force && !getChatMessages()?.isAtBottom) return;
+    const el = getChatMessages()?.messagesRef;
+    if (el) {
+      el.scrollTop = el.scrollHeight - el.clientHeight;
+    }
+  };
+
+  return { scrollToBottom, scrollToBottomThrottled };
+};

+ 402 - 0
src/views/ventAI/manageAssistent/hooks/useChatSend.ts

@@ -0,0 +1,402 @@
+import { computed, nextTick } from 'vue';
+import dayjs from 'dayjs';
+import { message } from 'ant-design-vue';
+import { unifiedStream, reviewPdfStream, getDetail, deleteMessage } from '/@/views/ventAI/manageAssistent/api/client';
+import type { AttachedFile, Message, SseEvent, Task } from '/@/views/ventAI/manageAssistent/types/index';
+import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+
+/**
+ * 发送与流式管线:推送一条空 AI 消息 → 建 AbortController → 走 SSE(纯文本 / PDF 审查)→ 处理
+ * session_id 迁移与异常清理;另含停止生成、卸载断开、历史消息 id 回填、重新生成、编辑重发。
+ *
+ * 输入框交互状态(草稿、待发附件、引用)留在组件里,这里只负责「消息落库 + 起流」;任务/消息读写、
+ * SSE 事件处理、滚动跟随、待办重置与审批 key 迁移等能力通过 deps 注入(一律延迟求值,避免 setup 期 TDZ)。
+ */
+export interface ChatSendDeps {
+  getTaskList: () => Task[];
+  getCurrentTaskId: () => string;
+  setCurrentTaskId: (taskId: string) => void;
+  getMessages: () => Message[];
+  setMessages: (messages: Message[]) => void;
+  setTaskList: (tasks: Task[]) => void;
+  getCurrentSessionId: () => string;
+  getEditMode: () => string;
+  getPendingApproval: () => unknown;
+  getChatMessages: () => { forceScrollToBottom?: () => void } | undefined;
+  handleStreamEvent: (data: SseEvent, aiMsgIndex: number, taskId: string) => void;
+  scrollToBottomThrottled: (force?: boolean) => void;
+  resetTodos: () => void;
+  migrateTaskApproval: (fromTaskId: string, toTaskId: string) => void;
+  historyLoadedTasks: Set<string>;
+  transformHistoryToMessages: (data: Array<Record<string, unknown>>) => Message[];
+}
+
+export const useChatSend = (deps: ChatSendDeps) => {
+  const {
+    getTaskList,
+    getCurrentTaskId,
+    setCurrentTaskId,
+    getMessages,
+    setMessages,
+    setTaskList,
+    getCurrentSessionId,
+    getEditMode,
+    getPendingApproval,
+    getChatMessages,
+    handleStreamEvent,
+    scrollToBottomThrottled,
+    resetTodos,
+    migrateTaskApproval,
+    historyLoadedTasks,
+    transformHistoryToMessages,
+  } = deps;
+  // 当前任务是否正在流式输出(原 streaming 别名与 loading 完全等价,统一用 loading)
+  const loading = computed(() => {
+    const currentTask = getTaskList().find((t) => t.id === getCurrentTaskId());
+    return currentTask?.isStreaming ?? false;
+  });
+
+  // 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) => {
+    if (controller) {
+      taskAbortControllers.set(taskId, controller);
+    } else {
+      taskAbortControllers.delete(taskId);
+    }
+  };
+
+  // 停止指定任务的流式传输
+  const stopTaskStreaming = (taskId: string) => {
+    const controller = taskAbortControllers.get(taskId);
+    controller?.abort();
+  };
+
+  const stopStreaming = () => {
+    stopTaskStreaming(getCurrentTaskId());
+  };
+
+  // 组件卸载/清理时断开所有任务的流式连接
+  const abortAllStreams = () => {
+    taskAbortControllers.forEach((controller) => controller.abort());
+    taskAbortControllers.clear();
+  };
+
+  const getSkillRefIconUrl = () => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-options-skill-icon').trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
+  const getSessionRefIconUrl = () => {
+    const val = getComputedStyle(document.documentElement).getPropertyValue('--img-chat-popup-session-icon').trim();
+    return val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  };
+
+  const streamAiAnswer = async (userInput: string, file?: AttachedFile) => {
+    const originTaskId = getCurrentTaskId();
+    const now = dayjs();
+    const aiMsgIndex = getMessages().length;
+    const aiMsg: Message = {
+      type: 'ai',
+      content: '',
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      isLoading: true,
+      thinkingSteps: [],
+      generateStartTime: Date.now(),
+    };
+    getMessages().push(aiMsg);
+
+    resetTodos();
+
+    const originTask = getTaskList().find((t) => t.id === originTaskId);
+    if (originTask) originTask.isStreaming = true;
+
+    const controller = new AbortController();
+    setTaskAbortController(originTaskId, controller);
+
+    const onChunk = (data: SseEvent) => {
+      handleStreamEvent(data, aiMsgIndex, originTaskId);
+      if (getCurrentTaskId() === originTaskId) scrollToBottomThrottled();
+    };
+
+    try {
+      const originalFile = file?.originalFile;
+      const result = originalFile
+        ? await reviewPdfStream(
+            {
+              file: originalFile,
+              session_id: getCurrentSessionId() || undefined,
+              message: userInput || undefined,
+              mode: getEditMode(),
+              signal: controller.signal,
+            },
+            onChunk
+          )
+        : await unifiedStream(
+            {
+              message: userInput,
+              session_id: getCurrentSessionId() || undefined,
+              mode: getEditMode(),
+              signal: controller.signal,
+            },
+            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);
+          }
+        }
+      }
+    } catch (error) {
+      // 用户主动停止生成
+      if ((error as Error)?.name === 'AbortError') {
+        const originTask = getTaskList().find((t) => t.id === originTaskId);
+        // 单一数据源:任务自己的数组就是视图数组,直接取即可
+        const arr = 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 = '已停止生成。';
+          }
+        }
+        return;
+      }
+      throw error;
+    } finally {
+      // 任务在流结束时可能已被改名为后端 session_id,需要使用最终的 ID 清理状态
+      const finalTaskId = originTask?.id || originTaskId;
+      setTaskAbortController(finalTaskId, null);
+      if (originTask) originTask.isStreaming = false;
+    }
+  };
+
+  const sendTextOnly = async (userInput: string, displayContent?: string) => {
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: displayContent || userInput,
+      rawInput: userInput,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+    };
+    getMessages().push(userMsg);
+    await nextTick();
+    getChatMessages()?.forceScrollToBottom?.();
+
+    await streamAiAnswer(userInput);
+  };
+
+  const sendWithAttachment = async (file: AttachedFile, userInput: string, displayContent?: string) => {
+    const originTaskId = getCurrentTaskId();
+    const currentTask = getTaskList().find((t) => t.id === originTaskId);
+    if (currentTask) {
+      currentTask.attachedFiles.push(file);
+    } else {
+      throw new Error('未找到当前任务');
+    }
+
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: displayContent || userInput || `上传了文件:${file.name}`,
+      rawInput: userInput,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      attachedFile: file,
+    };
+    getMessages().push(userMsg);
+    await nextTick();
+    getChatMessages()?.forceScrollToBottom?.();
+
+    // 附件缺原始文件对象(如历史消息回填)时降级为纯文本重发
+    if (!file.originalFile) {
+      await streamAiAnswer(userInput);
+      return;
+    }
+
+    await streamAiAnswer(userInput, file);
+  };
+
+  // 剥离用户消息里注入的技能/会话引用 <img> HTML(rawInput 缺失时的兜底)
+  const stripInjectedRefs = (content: string): string =>
+    (content || '')
+      .replace(/\[<img[^>]*>[^\]]*\]/g, '')
+      .replace(/<img[^>]*>/g, '')
+      .trim();
+
+  // 补齐消息的数据库主键 id(刚流式生成的消息前端没有 id,但后端已持久化)。
+  // 用 getDetail 返回的列表按尾部对齐回填,避免编辑重发/重新生成删除时后端残留重复记录。
+  const hydrateMessageIds = async () => {
+    const sessionId = getCurrentSessionId();
+    if (!sessionId) return;
+    try {
+      const res = await getDetail(sessionId);
+      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      if (!Array.isArray(raw)) return;
+      // 用 transformHistoryToMessages 归一化,避免 model_thinking 等记录导致条数与前端不对齐
+      const history = transformHistoryToMessages(raw);
+      const ids = history.map((m) => m.id).filter((id): id is number => id != null);
+      if (!ids.length) return;
+      const msgs = getMessages();
+      const offset = ids.length - msgs.length;
+      for (let i = 0; i < msgs.length; i++) {
+        const dIdx = offset + i;
+        if (dIdx >= 0 && dIdx < ids.length && msgs[i].id == null) {
+          msgs[i].id = ids[dIdx];
+        }
+      }
+    } catch (e) {
+      console.error('补齐消息 id 失败:', e);
+    }
+  };
+
+  // 重新生成后,删除后端因重发而产生的重复用户消息(保留第一次,形成「一次提问、多次回答」)
+  const removeDuplicateRegenerateUserMessage = async (expectedContent: string) => {
+    const sessionId = getCurrentSessionId();
+    if (!sessionId) return;
+    try {
+      const res = await getDetail(sessionId);
+      const raw = res?.messages || (Array.isArray(res) ? res : res?.data);
+      if (!Array.isArray(raw)) return;
+
+      // 保留第一条 user(原始提问),删除其余与重发内容一致的重复 user,避免多次重新生成残留
+      const userMsgs = raw.filter((m: any) => m?.role === 'user' || m?.type === 'user_message');
+      const duplicates = userMsgs.filter(
+        (m: any, idx: number) => idx > 0 && normalizeLineBreaks(m?.content || '').trim() === expectedContent.trim() && m?.id != null
+      );
+      for (const dup of duplicates) {
+        await deleteMessage(dup.id);
+      }
+    } catch (e) {
+      console.error('删除重发产生的重复用户消息失败:', e);
+    }
+  };
+
+  // 重新生成最后一条回答
+  const handleRegenerate = async () => {
+    const task = getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (!task || task.isStreaming) return;
+    if (getPendingApproval()) {
+      message.warning('请先审批执行计划');
+      return;
+    }
+
+    const msgs = getMessages();
+    const lastAiIdx = msgs.length - 1;
+    if (lastAiIdx < 0 || msgs[lastAiIdx].type !== 'ai') return;
+
+    // 找前一条用户消息作为重发内容
+    let userIdx = -1;
+    for (let i = lastAiIdx - 1; i >= 0; i--) {
+      if (msgs[i].type === 'user') {
+        userIdx = i;
+        break;
+      }
+    }
+    if (userIdx < 0) return;
+
+    const userMsg = msgs[userIdx];
+    const userInput = userMsg.rawInput || stripInjectedRefs(userMsg.content);
+    const attachedFile = userMsg.attachedFile;
+
+    // 不删除旧 AI 消息、不动用户提问,直接重发同一条用户输入重新生成
+    await streamAiAnswer(userInput, attachedFile);
+    // 删除重发产生的重复用户消息,形成「一次提问、多次回答」
+    await removeDuplicateRegenerateUserMessage(userInput);
+    // 回填新生成消息的 id,使折叠组内各条消息都能正常显示删除按钮
+    await hydrateMessageIds();
+  };
+
+  // 编辑用户消息并重发(截断该消息及其后所有消息)
+  const handleEditResend = async (index: number, newContent: string) => {
+    const task = getTaskList().find((t) => t.id === getCurrentTaskId());
+    if (!task || task.isStreaming) return;
+    if (getPendingApproval()) {
+      message.warning('请先审批执行计划');
+      return;
+    }
+
+    const msgs = getMessages();
+    if (index < 0 || index >= msgs.length || msgs[index].type !== 'user') return;
+    const trimmed = (newContent || '').trim();
+    if (!trimmed) {
+      message.warning('消息内容不能为空');
+      return;
+    }
+
+    const attachedFile = msgs[index].attachedFile;
+
+    await hydrateMessageIds();
+    // 从后往前删除后端记录(有 id 的才调接口)
+    const toDelete = getMessages()
+      .slice(index)
+      .filter((m) => m.id != null);
+    for (const m of toDelete) {
+      try {
+        await deleteMessage(m.id!);
+      } catch (e) {
+        console.error('删除消息失败:', e);
+      }
+    }
+    // 本地截断到编辑位置
+    setMessages(getMessages().slice(0, index));
+    const now = dayjs();
+    const userMsg: Message = {
+      type: 'user',
+      content: trimmed,
+      rawInput: trimmed,
+      time: now.format('HH:mm'),
+      createdAt: now.toISOString(),
+      attachedFile,
+    };
+    getMessages().push(userMsg);
+    await nextTick();
+    getChatMessages()?.forceScrollToBottom?.();
+    await streamAiAnswer(trimmed, attachedFile);
+  };
+  return {
+    loading,
+    stopStreaming,
+    setTaskAbortController,
+    abortAllStreams,
+    getSkillRefIconUrl,
+    getSessionRefIconUrl,
+    streamAiAnswer,
+    sendTextOnly,
+    sendWithAttachment,
+    stripInjectedRefs,
+    hydrateMessageIds,
+    removeDuplicateRegenerateUserMessage,
+    handleRegenerate,
+    handleEditResend,
+  };
+};

+ 334 - 0
src/views/ventAI/manageAssistent/hooks/useChatStream.ts

@@ -0,0 +1,334 @@
+import { nextTick } from 'vue';
+import type { AskUserQuestion, Task, ThinkingStep, SseEvent } from '/@/views/ventAI/manageAssistent/types/index';
+import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+
+// 工具名 → 中文智能体名(agent 分组标题用)。仅本模块使用,随事件映射一起内聚在此
+const AGENT_NAME_MAP: Record<string, string> = {
+  form_review: '形式审查',
+  verify_coal_face_ventilation: '采煤工作面需风量计算核验',
+  verify_tunneling_face_ventilation: '掘进工作面需风量计算核验',
+  verify_chamber_ventilation: '硐室需风量计算核验',
+  verify_other_points_ventilation: '其他地点需风量计算核验',
+  check_data_by_gas_report: '瓦斯数据一致性审查',
+  check_data_by_face_design: '设计规程一致性审查',
+  check_data_by_vent_report: '测风报表一致性审查',
+  check_current_month_plan: '日期有效性审查',
+  get_key_translate: '计算参数对照',
+  calculate_ventilation: '需风量计算',
+  get_ventilation_data: '需风量查询',
+  recalculate_ventilation: '需风量重计算',
+  get_plan_required_wind: '需风量提取',
+  get_model_calculated_wind: '三维模型解算风量提取',
+  get_sensor_wind: '巷道监测风量提取',
+  merge_wind_by_location: '地点风量智能合并',
+  check_wind_compliance: '风量检查',
+};
+
+const extractAgentName = (toolName: string): string => AGENT_NAME_MAP[toolName] || AGENT_NAME_MAP[toolName.toLowerCase()] || '智能分析助手';
+
+// 步骤只做追加,保留完整历史
+const appendStep = (steps: ThinkingStep[], step: ThinkingStep) => {
+  steps.push(step);
+};
+
+// 与 Array.prototype.findLast 语义等价的逆序查找。
+// 项目 TS 4.9 的 lib 未声明 findLast(原组件里同样触发该类型错误),这里用等价实现避免新增类型报错。
+const findLastStep = (steps: ThinkingStep[], match: (step: ThinkingStep) => boolean): ThinkingStep | undefined => {
+  for (let i = steps.length - 1; i >= 0; i--) {
+    if (match(steps[i])) return steps[i];
+  }
+  return undefined;
+};
+
+/**
+ * 事件管线需要的视图侧副作用。事件 → 消息状态的映射全部收敛在本模块,
+ * 进度条、上下文用量、待审批登记、ask_user 答案初始化等属于视图状态,由调用方注入。
+ */
+export interface ChatStreamHooks {
+  /** 按前端任务 id 取任务(拿到的是 taskList 里的响应式对象本身) */
+  findTask: (taskId: string) => Task | undefined;
+  /** 该任务当前是否正在被查看(用于判断是否同步进度条,避免切任务后串台) */
+  isCurrentTask: (taskId: string) => boolean;
+  /** 更新任务进度条 */
+  setTodos: (todos: Array<{ content: string; status: string }>) => void;
+  /** done 事件:刷新上下文容量 */
+  refreshContext: (sessionId: string) => void;
+  /** done 事件:刷新今日用量 */
+  refreshUsage: () => void;
+  /** interrupt 事件:登记待审批 */
+  markPendingApproval: (taskId: string, sessionId: string, threadId: string) => void;
+  /** interrupt 事件:初始化 ask_user 答案状态 */
+  initAskUserAnswers: (aiMsgIndex: number, questions: AskUserQuestion[]) => void;
+}
+
+/**
+ * 统一对话流(SSE)事件处理器。
+ *
+ * 数据源约定:aiMsg 固定从「任务自己的 messages 数组」读取——视图层的 messages 就是当前任务的
+ * messages(同一个数组),因此流式增量原地落在任务对象上,不需要任何「视图数组 ↔ 任务数组」同步。
+ * 切到别的任务时,增量仍然写回发起流式的那个任务,回到该任务时内容完整。
+ */
+export const useChatStream = (hooks: ChatStreamHooks) => {
+  const handleEvent = (data: SseEvent, aiMsgIndex: number, taskId: string) => {
+    const streamingTask = hooks.findTask(taskId);
+    if (!streamingTask) return;
+
+    const isViewingTask = hooks.isCurrentTask(taskId);
+    const aiMsg = streamingTask.messages[aiMsgIndex];
+    if (!aiMsg) return;
+
+    const normalizedContent = normalizeLineBreaks(data.content || '');
+
+    // 初始化 thinkingSteps 数组
+    if (!aiMsg.thinkingSteps) {
+      aiMsg.thinkingSteps = [];
+    }
+
+    switch (data.type) {
+      // token 流式内容 - 直接拼接到消息内容
+      case 'token':
+        if (normalizedContent) {
+          if (data.source === 'subagent' && data.agent_id) {
+            // subagent 的输出内容归属到对应的 agent step
+            const targetStep = findLastStep(aiMsg.thinkingSteps!, (s) => s.type === 'agent_start' && s.agentId === data.agent_id);
+            if (targetStep) {
+              targetStep.content = (targetStep.content || '') + normalizedContent;
+            }
+          } else {
+            // main 模型输出,保持原有逻辑
+            aiMsg.content += normalizedContent;
+          }
+        }
+        break;
+
+      // thinking / thinking_token - 模型思考过程内容
+      case 'thinking':
+      case 'thinking_token':
+        if (normalizedContent) {
+          if (data.source === 'subagent' && data.agent_id) {
+            // subagent 的思考内容归属到对应的 agent step
+            const targetStep = findLastStep(aiMsg.thinkingSteps!, (s) => s.type === 'agent_start' && s.agentId === data.agent_id);
+            if (targetStep) {
+              targetStep.thinkingContent = (targetStep.thinkingContent || '') + normalizedContent;
+            }
+          } else {
+            // main 模型思考,保持原有逻辑
+            aiMsg.thinkingContent = (aiMsg.thinkingContent || '') + normalizedContent;
+          }
+        }
+        break;
+
+      // 系统进度消息 - 仅当 agent 不是 system 时才作为 thinking step
+      case 'progress': {
+        if (data.agent && data.agent !== 'system') {
+          appendStep(aiMsg.thinkingSteps!, {
+            type: 'executing',
+            agent: data.cn_agent || data.agent,
+            agentId: data.agent_id,
+            message: data.message || '',
+            timestamp: Date.now(),
+          });
+        }
+        break;
+      }
+
+      // agent 开始处理
+      case 'agent_start': {
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'agent_start',
+          agent: data.cn_agent || data.agent || '智能助手',
+          agentId: data.agent_id,
+          subagentType: data.subagent_type,
+          message: data.message || '开始处理...',
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      // agent 任务列表更新(同一 agent 的 todos 更新已有条目,不重复添加)
+      case 'agent_todos':
+      case 'updated_todo_list': {
+        const agentName = data.cn_agent || data.agent || '智能助手';
+        const agentId = data.agent_id;
+        const existingIdx = aiMsg.thinkingSteps!.findIndex(
+          (s) => s.type === 'updated_todo_list' && (agentId ? s.agentId === agentId : s.agent === agentName)
+        );
+        if (existingIdx !== -1) {
+          aiMsg.thinkingSteps![existingIdx].todos = data.todos || [];
+          aiMsg.thinkingSteps![existingIdx].timestamp = Date.now();
+        } else {
+          aiMsg.thinkingSteps!.push({
+            type: 'updated_todo_list',
+            agent: agentName,
+            agentId,
+            todos: data.todos || [],
+            message: data.message || '任务进度已更新',
+            timestamp: Date.now(),
+          });
+        }
+        // todos 与任务绑定:仅当用户正在查看该任务时才更新进度条,避免流式期间切换/新建任务后串台
+        if (isViewingTask) {
+          hooks.setTodos(data.todos || []);
+        }
+        break;
+      }
+
+      // agent 执行中(工具调用)
+      case 'agent_executing':
+      case 'executing': {
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'executing',
+          agent: data.cn_agent || data.agent || '智能助手',
+          agentId: data.agent_id,
+          tool: (data.cn_tools || data.tools || []).join('、'),
+          message: data.message || '',
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      // 工具结果
+      case 'agent_tool_result':
+      case 'tool_result': {
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'tool_result',
+          agent: data.cn_agent || data.agent || '智能助手',
+          agentId: data.agent_id,
+          tool: data.cn_tool || data.tool || '',
+          message: data.message || '执行完成',
+          timestamp: Date.now(),
+        });
+        if (data.download_url) {
+          if (!aiMsg.downloadFiles) aiMsg.downloadFiles = [];
+          aiMsg.downloadFiles.push({ url: data.download_url, filename: data.filename || '文件' });
+        }
+        break;
+      }
+
+      // agent 完成
+      case 'agent_done': {
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'agent_done',
+          agent: data.cn_agent || data.agent || '智能助手',
+          agentId: data.agent_id,
+          message: `${(data.message || '审查完成').replace(/(\d+ms)/g, '')}${data.duration_ms ? `(${(data.duration_ms / 1000).toFixed(0)}s)` : ''}${data.progress ? ` [${data.progress}]` : ''}`,
+          timestamp: Date.now(),
+        });
+        // 累加后端返回的各段耗时,并重置前端计时起点,避免重复计算已验证的时间段
+        if (data.duration_ms) {
+          aiMsg.baseDurationMs = (aiMsg.baseDurationMs || 0) + data.duration_ms;
+          aiMsg.generateStartTime = Date.now();
+        }
+        if (data.preview) {
+          aiMsg.content += data.preview;
+        }
+        break;
+      }
+
+      // 旧格式兼容
+      case 'model_thinking':
+        if (normalizedContent) {
+          aiMsg.content += normalizedContent;
+          aiMsg.content = aiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
+        }
+        break;
+
+      case 'tool_select': {
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'tool_call',
+          agent: data.cn_agent || extractAgentName(normalizedContent),
+          agentId: data.agent_id,
+          tool: normalizedContent,
+          message: `选择工具:${normalizedContent}`,
+          timestamp: Date.now(),
+        });
+        break;
+      }
+
+      case 'error':
+        appendStep(aiMsg.thinkingSteps!, {
+          type: 'tool_result',
+          agent: data.cn_agent || '系统',
+          agentId: data.agent_id,
+          message: `❌ 执行失败:${normalizedContent}`,
+          timestamp: Date.now(),
+        });
+        aiMsg.isLoading = false;
+        streamingTask.isStreaming = false;
+        // 后端未返回耗时,冻结前端伪计时
+        aiMsg.durationMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        break;
+
+      case 'citations': {
+        aiMsg.citations = {
+          items: data.items || [],
+          model_only: data.model_only || false,
+          message: data.message,
+        };
+        break;
+      }
+
+      case 'word_download':
+        if (normalizedContent) {
+          aiMsg.wordDownloadUrl = normalizedContent;
+          streamingTask.wordUrl = normalizedContent;
+        }
+        break;
+
+      case 'done':
+        if (data.session_id) {
+          aiMsg.sessionId = data.session_id;
+          hooks.refreshContext(data.session_id);
+        }
+        // 对话结束后刷新今日用量
+        hooks.refreshUsage();
+        aiMsg.durationMs = data.duration_ms
+          ? (aiMsg.baseDurationMs || 0) + data.duration_ms
+          : (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        aiMsg.isLoading = false;
+        streamingTask.isStreaming = false;
+        break;
+
+      case 'system':
+        if (normalizedContent) {
+          aiMsg.content += normalizedContent.replace(/\n/g, '  \n');
+        }
+        break;
+
+      case 'interrupt': {
+        // 后端各段耗时 + 前端当前段计时,吸收进 baseDurationMs 保证审批继续后计时连续
+        const elapsedMs = (aiMsg.baseDurationMs || 0) + (aiMsg.generateStartTime ? Date.now() - aiMsg.generateStartTime : 0);
+        aiMsg.baseDurationMs = elapsedMs;
+        aiMsg.isLoading = false;
+        // 流已结束,等待用户回答;提交答案后 handleAskUserSubmit 会重新置为流式中
+        streamingTask.isStreaming = false;
+        aiMsg.isPendingApproval = true;
+        const interruptSessionId = data.session_id || streamingTask.sessionId || '';
+        aiMsg.pendingApprovalData = {
+          message: data.message || '智能体需要您回答一些问题以继续...',
+          sessionId: interruptSessionId,
+          threadId: data.thread_id,
+          interrupts: data.interrupts,
+          elapsedMs,
+          prevThinkingContent: aiMsg.thinkingContent,
+          plan: data.plan,
+          ask_user: data.ask_user,
+          answers: data.ask_user?.questions?.map(() => '') || [],
+        };
+        hooks.markPendingApproval(taskId, interruptSessionId, data.thread_id || '');
+        // 直接初始化 ask_user 答案状态,不依赖 watch(避免任务切换/时序问题)
+        if (data.ask_user?.questions) {
+          const questions = data.ask_user.questions;
+          nextTick(() => {
+            hooks.initAskUserAnswers(aiMsgIndex, questions);
+          });
+        }
+        break;
+      }
+    }
+    // 说明:这里不需要任何「把增量同步回任务」的收尾逻辑。
+    // aiMsg 取自 streamingTask.messages,原地修改即可被视图直接观察到。
+  };
+
+  return { handleEvent };
+};

+ 207 - 0
src/views/ventAI/manageAssistent/hooks/useChatTasks.ts

@@ -0,0 +1,207 @@
+import { computed, ref } from 'vue';
+import dayjs from 'dayjs';
+import { message } from 'ant-design-vue';
+import { editSessionTitle, getHistoryList, pinSession } from '/@/views/ventAI/manageAssistent/api/client';
+import type { Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+import { normalizeLineBreaks } from '/@/views/ventAI/manageAssistent/lib/index';
+
+/**
+ * 会话(任务)状态与列表:taskList / currentTaskId / 当前会话消息,以及列表拉取、置顶、标题、
+ * 会话 id 迁移与历史消息转换。
+ *
+ * messages 是唯一数据源:直接读写 currentTask.messages,不再维护两份拷贝;
+ * 切换任务、创建任务这类会牵动面板/额度/滚动的编排逻辑仍留在组件里,此处只提供状态与纯函数。
+ */
+export const useChatTasks = () => {
+  const taskList = ref<Task[]>([]);
+
+  // 已从接口拉取过的会话 id,用于识别流式期间后端新建的会话
+  const knownApiSessionIds = new Set<string>();
+
+  // 从接口获取会话列表
+  const fetchSessionList = async () => {
+    try {
+      const res = await getHistoryList({});
+      const sessions = res?.sessions || res?.data || [];
+      if (Array.isArray(sessions)) {
+        const apiTasks: Task[] = sessions.map((item: { session_id: string; title: string; updated_at: string; sort_order?: number }) => ({
+          id: item.session_id,
+          name: item.title || '未命名会话',
+          sessionId: item.session_id,
+          messages: [],
+          attachedFiles: [],
+          updatedAt: item.updated_at,
+          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;
+
+        const mergedTasks: Task[] = [];
+        for (const apiTask of apiTasks) {
+          if (blankStreamingTask && (blankStreamingTask.pendingSessionId === apiTask.id || !knownApiSessionIds.has(apiTask.id))) {
+            blankStreamingTask.pendingSessionId = apiTask.id;
+            blankStreamingTask.name = apiTask.name;
+            blankStreamingTask.updatedAt = apiTask.updatedAt;
+            blankStreamingTask.sortOrder = apiTask.sortOrder;
+            blankStreamingTask = null;
+            continue;
+          }
+
+          const existing = existingTaskMap.get(apiTask.id);
+          if (existing) {
+            // 原地更新以保持对象引用,避免流式回调持有的任务引用失效(isStreaming 等状态丢失)
+            existing.name = apiTask.name;
+            existing.updatedAt = apiTask.updatedAt;
+            existing.sortOrder = apiTask.sortOrder;
+            mergedTasks.push(existing);
+          } else {
+            mergedTasks.push(apiTask);
+          }
+        }
+
+        apiTasks.forEach((apiTask) => knownApiSessionIds.add(apiTask.id));
+
+        const localOnlyTasks = taskList.value.filter((t) => !t.sessionId && !apiTaskIds.has(t.id));
+
+        taskList.value = [...localOnlyTasks, ...mergedTasks];
+        return mergedTasks;
+      }
+    } catch (error) {
+      console.error('获取会话列表失败:', error);
+    }
+    return [];
+  };
+
+  const currentTaskId = ref('');
+  const currentTask = computed(() => taskList.value.find((t) => t.id === currentTaskId.value));
+
+  // 当前会话的消息。单一数据源:直接读写 currentTask.messages,不再维护
+  // 「视图数组 + 任务数组」两份拷贝——历史实现需要在每次流式事件后手工同步两者,
+  // 是任务切换/流式并存时视图错乱的根源。
+  // fallbackMessages 仅用于「任务已全部删除」这类没有当前任务的瞬时状态,行为与旧 ref([]) 一致。
+  const fallbackMessages: Message[] = [];
+  const messages = computed<Message[]>({
+    get: () => currentTask.value?.messages ?? fallbackMessages,
+    set: (value: Message[]) => {
+      const task = currentTask.value;
+      if (task) task.messages = value;
+    },
+  });
+
+  // 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);
+    if (currentTask && currentTask.id !== sessionId) {
+      taskList.value = taskList.value.filter((t) => t.id !== sessionId);
+      currentTask.id = sessionId;
+      currentTask.sessionId = sessionId;
+      currentTaskId.value = sessionId;
+      historyLoadedTasks.add(sessionId);
+    }
+  };
+
+  const handlePinTask = async (task: Task) => {
+    if (!task.sessionId) {
+      message.warning('请先发送消息后再置顶');
+      return;
+    }
+    try {
+      await pinSession(task.sessionId);
+      await fetchSessionList();
+    } catch {
+      message.error('置顶操作失败');
+    }
+  };
+
+  // 标题保存(由 ChatHeader 子组件触发,父组件负责 API 调用和状态更新)
+  const handleSaveTitle = async (newTitle: string) => {
+    if (!currentTask.value?.sessionId) return;
+    try {
+      await editSessionTitle(currentTask.value.sessionId, newTitle);
+      currentTask.value.name = newTitle;
+      message.success('标题修改成功');
+    } catch {
+      message.error('标题修改失败');
+    }
+  };
+
+  const getDefaultMessages = (): Message[] => {
+    return [];
+  };
+
+  const transformHistoryToMessages = (
+    data: Array<{
+      id?: number;
+      role?: string;
+      type?: string;
+      content: string;
+      created_at?: string;
+      report_url?: string;
+      report_filename?: string;
+      citations?: any;
+    }>
+  ): Message[] => {
+    const messages: Message[] = [];
+
+    for (const item of data) {
+      const normalizedContent = normalizeLineBreaks(item.content);
+      const time = item.created_at ? dayjs(item.created_at).format('HH:mm') : '';
+      const createdAt = item.created_at || '';
+
+      // 新格式:role 字段 (user/assistant)
+      if (item.role === 'user') {
+        messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
+      } else if (item.role === 'assistant') {
+        const msg: Message = { id: item.id, type: 'ai', content: normalizedContent, time, createdAt };
+        if (item.report_url && item.report_filename) {
+          msg.downloadFiles = [{ url: item.report_url, filename: item.report_filename }];
+        }
+        if (item.citations) {
+          // 历史接口返回 citations 为扁平数组,SSE 事件返回 { items, model_only },统一归一化
+          const raw = item.citations;
+          if (Array.isArray(raw)) {
+            msg.citations = { items: raw, model_only: raw.length === 0 };
+          } else {
+            msg.citations = raw;
+          }
+        }
+        messages.push(msg);
+      }
+      // 兼容旧格式:type 字段
+      else if (item.type === 'user_message') {
+        messages.push({ id: item.id, type: 'user', content: normalizedContent, time, createdAt });
+      } else if (item.type === 'model_thinking') {
+        const lastAiMsg = messages[messages.length - 1];
+        if (lastAiMsg && lastAiMsg.type === 'ai') {
+          lastAiMsg.content += normalizedContent;
+          lastAiMsg.content = lastAiMsg.content.replace(/\|\|\|SPLIT_CONTENT\|\|\|/g, '');
+        } else {
+          messages.push({ type: 'ai', content: normalizedContent, time, createdAt });
+        }
+      }
+    }
+
+    return messages;
+  };
+  return {
+    taskList,
+    currentTaskId,
+    currentTask,
+    messages,
+    historyLoadedTasks,
+    getDefaultMessages,
+    transformHistoryToMessages,
+    fetchSessionList,
+    handlePinTask,
+    handleSaveTitle,
+    setCurrentSessionId,
+  };
+};

+ 376 - 0
src/views/ventAI/manageAssistent/hooks/useMentionPopups.ts

@@ -0,0 +1,376 @@
+import { computed, nextTick, onMounted, ref, watch } from 'vue';
+import { getSkills } from '/@/views/ventAI/manageAssistent/api/client';
+import type { Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/**
+ * 输入框的会话/技能引用弹层:`#` 触发会话提及、`$` 触发技能提及,含搜索过滤、方向键导航、
+ * 回车/点击选中、标签增删,以及输入法组合态下不驱动弹层的处理。
+ *
+ * 输入框本身(文本、发送、附件)仍在 ChatInputArea 里,这里通过 deps 读写它的文本与焦点;
+ * 技能列表缓存放在模块级,跨组件实例复用,避免重复请求。
+ */
+export interface MentionPopupDeps {
+  getModelValue: () => string;
+  setModelValue: (value: string) => void;
+  getTaskList: () => Task[];
+  getCurrentTaskId: () => string;
+  isComposing: () => boolean;
+  closeAttachPopup: () => void;
+  focusTextarea: () => void;
+  setSelectedSessions: (sessions: Array<{ id: string; name: string }>) => void;
+  setSelectedSkill: (skill: { name: string; display_name: string } | null) => void;
+}
+
+interface SkillItem {
+  name: string;
+  display_name: string;
+  path: string;
+  description: string;
+  enabled: boolean;
+  agents: string[];
+  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;
+
+export const useMentionPopups = (deps: MentionPopupDeps) => {
+  const {
+    getModelValue,
+    setModelValue,
+    getTaskList,
+    getCurrentTaskId,
+    isComposing,
+    closeAttachPopup,
+    focusTextarea,
+    setSelectedSessions,
+    setSelectedSkill,
+  } = deps;
+
+  const showSessionPopup = ref(false);
+  const activeSessionIndex = ref(0);
+  const selectedSessions = ref<Array<{ id: string; name: string }>>([]);
+  const sessionPopupManual = ref(false);
+  let isRemovingTag = false;
+  let sessionJustSelected = false;
+
+  // 技能列表弹框
+
+  const showSkillPopup = ref(false);
+  const activeSkillIndex = ref(0);
+  const selectedSkill = ref<{ name: string; display_name: string } | null>(null);
+  const skillPopupManual = ref(false);
+  const skillList = ref<SkillItem[]>([]);
+  let skillJustSelected = false;
+
+  const fetchSkillList = async () => {
+    // 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(() => {
+    fetchSkillList();
+  });
+
+  const isSkillSelected = (name: string) => {
+    return selectedSkill.value?.name === name;
+  };
+
+  const isSessionSelected = (sessionId: string) => {
+    return selectedSessions.value.some((s) => s.id === sessionId);
+  };
+
+  const sessionSearchText = computed(() => {
+    const val = getModelValue() || '';
+    const hashIndex = val.lastIndexOf('#');
+    if (hashIndex === -1) return '';
+    const afterHash = val.substring(hashIndex + 1);
+    if (afterHash.includes(' ')) return '';
+    return afterHash.toLowerCase();
+  });
+
+  const filteredSessionList = computed(() => {
+    const list = getTaskList().filter((t) => t.sessionId);
+    if (!sessionSearchText.value) return list;
+    return list.filter((t) => t.name.toLowerCase().includes(sessionSearchText.value));
+  });
+
+  watch(filteredSessionList, () => {
+    activeSessionIndex.value = 0;
+  });
+
+  watch(showSessionPopup, (val) => {
+    if (val) activeSessionIndex.value = 0;
+  });
+
+  const skillSearchText = computed(() => {
+    const val = getModelValue() || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) return '';
+    const afterDollar = val.substring(dollarIndex + 1);
+    if (afterDollar.includes(' ')) return '';
+    return afterDollar.toLowerCase();
+  });
+
+  const filteredSkillList = computed(() => {
+    if (!skillSearchText.value) return skillList.value;
+    return skillList.value.filter(
+      (s) => s.display_name.toLowerCase().includes(skillSearchText.value) || s.name.toLowerCase().includes(skillSearchText.value)
+    );
+  });
+
+  watch(filteredSkillList, () => {
+    activeSkillIndex.value = 0;
+  });
+
+  watch(showSkillPopup, (val) => {
+    if (val) activeSkillIndex.value = 0;
+  });
+
+  const handleSessionKeydown = (e: KeyboardEvent) => {
+    // 组合态期间键盘输入归输入法处理(候选上下翻页也用方向键),不驱动会话/技能弹框
+    if (isComposing() || e.isComposing) return;
+    if (showSkillPopup.value && filteredSkillList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value + 1) % filteredSkillList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSkillIndex.value = (activeSkillIndex.value - 1 + filteredSkillList.value.length) % filteredSkillList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const skill = filteredSkillList.value[activeSkillIndex.value];
+        if (skill) {
+          skillJustSelected = true;
+          handleSelectSkill(skill);
+        }
+      }
+      return;
+    }
+    if (showSessionPopup.value && filteredSessionList.value.length > 0) {
+      if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value + 1) % filteredSessionList.value.length;
+      } else if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        activeSessionIndex.value = (activeSessionIndex.value - 1 + filteredSessionList.value.length) % filteredSessionList.value.length;
+      } else if (e.key === 'Enter') {
+        e.preventDefault();
+        const task = filteredSessionList.value[activeSessionIndex.value];
+        if (task) {
+          sessionJustSelected = true;
+          handleSelectSession(task);
+        }
+      }
+    }
+  };
+
+  const handleInsertSession = () => {
+    closeAttachPopup();
+    sessionPopupManual.value = true;
+    showSessionPopup.value = true;
+    const cur = getModelValue() || '';
+    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
+    setModelValue(`${cur}${needsSpace}#`);
+    nextTick(() => focusTextarea());
+  };
+
+  const handleSelectSession = (task: Task) => {
+    if (task.id === getCurrentTaskId()) return;
+    const idx = selectedSessions.value.findIndex((s) => s.id === task.id);
+    if (idx >= 0) {
+      selectedSessions.value.splice(idx, 1);
+    } else {
+      if (selectedSessions.value.length >= 5) return;
+      selectedSessions.value.push({ id: task.id, name: task.name });
+    }
+    removeSearchTag();
+    setSelectedSessions([...selectedSessions.value]);
+  };
+
+  const handleInsertSkill = () => {
+    closeAttachPopup();
+    showSessionPopup.value = false;
+    skillPopupManual.value = true;
+    showSkillPopup.value = true;
+    const cur = getModelValue() || '';
+    const needsSpace = cur && !cur.endsWith(' ') ? ' ' : '';
+    setModelValue(`${cur}${needsSpace}$`);
+    nextTick(() => focusTextarea());
+  };
+
+  const handleSelectSkill = (skill: SkillItem) => {
+    selectedSkill.value = { name: skill.name, display_name: skill.display_name };
+    removeSkillSearchTag();
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
+    setSelectedSkill({ name: skill.name, display_name: skill.display_name });
+  };
+
+  const removeSkillSearchTag = () => {
+    isRemovingTag = true;
+    const val = getModelValue() || '';
+    const dollarIndex = val.lastIndexOf('$');
+    if (dollarIndex === -1) {
+      isRemovingTag = false;
+      return;
+    }
+    setModelValue(val.substring(0, dollarIndex).trimEnd());
+    nextTick(() => {
+      isRemovingTag = false;
+    });
+  };
+
+  const removeSkill = () => {
+    selectedSkill.value = null;
+    setSelectedSkill(null);
+  };
+
+  const removeSearchTag = () => {
+    isRemovingTag = true;
+    const val = getModelValue() || '';
+    const hashIndex = val.lastIndexOf('#');
+    if (hashIndex === -1) {
+      isRemovingTag = false;
+      return;
+    }
+    setModelValue(val.substring(0, hashIndex).trimEnd());
+    nextTick(() => {
+      isRemovingTag = false;
+    });
+  };
+
+  watch(
+    () => getModelValue(),
+    (newVal, oldVal) => {
+      if (isRemovingTag) return;
+      if (oldVal && !newVal) {
+        selectedSessions.value = [];
+        selectedSkill.value = null;
+      }
+    }
+  );
+
+  const removeSession = (sessionId: string) => {
+    selectedSessions.value = selectedSessions.value.filter((s) => s.id !== sessionId);
+    setSelectedSessions([...selectedSessions.value]);
+  };
+
+  const syncPopups = (val: string) => {
+    // 技能弹框 $ 检测
+    if (!skillPopupManual.value) {
+      const dollarIndex = val.lastIndexOf('$');
+      if (dollarIndex === -1) {
+        showSkillPopup.value = false;
+      } else {
+        const afterDollar = val.substring(dollarIndex + 1);
+        if (afterDollar.includes(' ')) {
+          showSkillPopup.value = false;
+        } else {
+          showSkillPopup.value = true;
+          showSessionPopup.value = false;
+        }
+      }
+    }
+
+    // 会话弹框 # 检测
+    if (!sessionPopupManual.value) {
+      const hashIndex = val.lastIndexOf('#');
+      if (hashIndex === -1) {
+        showSessionPopup.value = false;
+      } else {
+        const afterHash = val.substring(hashIndex + 1);
+        if (afterHash.includes(' ')) {
+          showSessionPopup.value = false;
+        } else {
+          showSessionPopup.value = true;
+          showSkillPopup.value = false;
+        }
+      }
+    }
+  };
+  const handleBackspace = (e: KeyboardEvent) => {
+    const val = getModelValue() || '';
+    if (val) return;
+    if (selectedSkill.value) {
+      e.preventDefault();
+      selectedSkill.value = null;
+      setSelectedSkill(null);
+    } else if (selectedSessions.value.length > 0) {
+      e.preventDefault();
+      selectedSessions.value.pop();
+      setSelectedSessions([...selectedSessions.value]);
+    }
+  };
+
+  // 点击弹层外部关闭:由弹层组件自己监听根元素后回调进来(组件持有自己的根元素,父组件无需解析 $el)
+  const closeSessionPopup = () => {
+    showSessionPopup.value = false;
+    sessionPopupManual.value = false;
+  };
+
+  const closeSkillPopup = () => {
+    showSkillPopup.value = false;
+    skillPopupManual.value = false;
+  };
+
+  // 弹层选中项被回车确认后,紧接着的那次回车不应触发发送(组合态下 Safari 的 isComposing 不可靠,同样要挡)
+  const consumeJustSelected = () => {
+    if (sessionJustSelected) {
+      sessionJustSelected = false;
+      return true;
+    }
+    if (skillJustSelected) {
+      skillJustSelected = false;
+      return true;
+    }
+    return false;
+  };
+
+  return {
+    showSessionPopup,
+    activeSessionIndex,
+    selectedSessions,
+    showSkillPopup,
+    activeSkillIndex,
+    selectedSkill,
+    filteredSessionList,
+    filteredSkillList,
+    isSessionSelected,
+    isSkillSelected,
+    handleSessionKeydown,
+    handleSelectSession,
+    handleSelectSkill,
+    handleInsertSession,
+    handleInsertSkill,
+    removeSession,
+    removeSkill,
+    handleBackspace,
+    syncPopups,
+    consumeJustSelected,
+    closeSessionPopup,
+    closeSkillPopup,
+  };
+};

+ 12 - 0
src/views/ventAI/manageAssistent/lib/assistantHost.ts

@@ -0,0 +1,12 @@
+import { ref } from 'vue';
+
+/**
+ * 是否已有页面自己提供了助手界面(当前为 AI 首页内嵌的全屏助手)。
+ *
+ * 背景:助手组件被同时挂在布局层(悬浮球 + 弹框)和 AI 首页两处,两份实例各持一份
+ * 会话状态;在首页点悬浮球打开的是布局层那一份,等于把同一个助手开了两套,属于错误交互。
+ *
+ * 这里用一个显式标记解耦:提供界面的页面在挂载/卸载时切换,布局层据此决定是否挂载
+ * 悬浮球与弹框。不用路由路径判断——该页路由来自后端菜单配置,路径不是稳定契约。
+ */
+export const pageProvidesAssistant = ref(false);

+ 3 - 2
src/views/ventAI/manageAssistent/components/chatModal/capsuleData.ts → src/views/ventAI/manageAssistent/lib/capsule.ts

@@ -1,5 +1,6 @@
-import type { Task, Message, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
-import { extractMarkdownLinks, groupStepsByAgent } from './utils';
+import type { Task, Message, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from '/@/views/ventAI/manageAssistent/types/index';
+import { extractMarkdownLinks } from './markdown';
+import { groupStepsByAgent } from './stepRenderer';
 
 // 胶囊聚合数据构建(纯函数):由主弹框的 computed 接线调用,
 // 与响应式状态管理解耦,便于单独测试

+ 1 - 1
src/views/ventAI/manageAssistent/components/chatModal/exportDoc.ts → src/views/ventAI/manageAssistent/lib/exportDoc.ts

@@ -1,6 +1,6 @@
 import { marked } from 'marked';
 import dayjs from 'dayjs';
-import type { Message } from './types';
+import type { Message } from '/@/views/ventAI/manageAssistent/types/index';
 
 /**
  * 剥离会话导出中不应出现的注入内容:

+ 42 - 0
src/views/ventAI/manageAssistent/lib/fileIcon.ts

@@ -0,0 +1,42 @@
+// lib/fileIcon.ts:由 lib/utils.ts 按职责拆分(实现未改)
+export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename);
+
+export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);
+
+export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename);
+
+export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename);
+
+export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename);
+
+export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename);
+
+export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename);
+
+export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename);
+
+export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
+
+export const isTextFile = (filename: string): boolean =>
+  /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
+    filename
+  );
+
+export const getFileExtension = (filename: string): string => {
+  const match = filename.match(/\.([^.]+)$/);
+  return match ? `.${match[1]}`.toUpperCase() : '';
+};
+// CSS 变量里的图片地址(图标统一走 var(--img-chat-*)):带缓存,避免每帧 getComputedStyle;
+// 主题切换时 html 的 class 变化,清空缓存重新取。模块级共享,各组件读到的都是同一份文档级取值。
+
+export const getFileIconVar = (filename: string): string => {
+  if (isMdFile(filename)) return 'var(--img-chat-file-md-icon)';
+  if (isPdfFile(filename)) return 'var(--img-chat-file-pdf-icon)';
+  if (isWordFile(filename)) return 'var(--img-chat-file-word-icon)';
+  if (isExcelFile(filename) || isCsvFile(filename)) return 'var(--img-chat-file-excel-icon)';
+  if (isImageFile(filename)) return 'var(--img-chat-file-img-icon)';
+  if (isTextFile(filename)) return 'var(--img-chat-file-txt-icon)';
+  return 'var(--img-chat-attach-icon)';
+};
+// 按文件类型拉取远端文件内容并构造右侧面板预览对象(消息内文件卡片与胶囊面板共用)。
+// 不支持的格式返回 null,由调用方回退为直接下载

+ 41 - 0
src/views/ventAI/manageAssistent/lib/filePreview.ts

@@ -0,0 +1,41 @@
+// lib/filePreview.ts:由 lib/utils.ts 按职责拆分(实现未改)
+import { getToken } from '/@/utils/auth/index';
+import type { AttachedFile } from '/@/views/ventAI/manageAssistent/types/resource';
+import { isImageFile, isPdfFile, isWordFile, isExcelFile } from './fileIcon';
+
+export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
+  const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
+  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  const mimeMap: Record<string, string> = {
+    md: 'text/markdown',
+    txt: 'text/plain',
+    json: 'application/json',
+    xml: 'text/xml',
+    yaml: 'text/yaml',
+    yml: 'text/yaml',
+    csv: 'text/csv',
+  };
+  const id = `report-${url}`;
+  const uploadTime = new Date().toISOString();
+
+  if (mimeMap[ext]) {
+    const content = await resp.text();
+    return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
+  }
+  if (isWordFile(filename) || isExcelFile(filename)) {
+    const arrayBuffer = await resp.arrayBuffer();
+    return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
+  }
+  if (isImageFile(filename) || isPdfFile(filename)) {
+    const blob = await resp.blob();
+    const dataUrl = await new Promise<string>((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(reader.result as string);
+      reader.onerror = () => reject(reader.error);
+      reader.readAsDataURL(blob);
+    });
+    return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
+  }
+  return null;
+};

+ 17 - 0
src/views/ventAI/manageAssistent/lib/format.ts

@@ -0,0 +1,17 @@
+// lib/format.ts:由 lib/utils.ts 按职责拆分(实现未改)
+export const formatTokens = (tokens: number): string => {
+  if (tokens >= 10000) return (tokens / 10000).toFixed(1) + '万';
+  return tokens.toLocaleString();
+};
+
+export const formatFileSize = (bytes: number): string => {
+  if (bytes < 1024) return bytes + ' B';
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
+  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
+};
+// 后端返回的正文里换行是字面量 \n 转义,渲染/比对前统一还原为真实换行
+
+export const normalizeLineBreaks = (str: string): string => {
+  if (!str) return '';
+  return str.replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
+};

+ 8 - 0
src/views/ventAI/manageAssistent/lib/index.ts

@@ -0,0 +1,8 @@
+// lib barrel:对外保持单一入口,importers 无需按符号逐个改路径
+export * from './markdown';
+export * from './stepRenderer';
+export * from './format';
+export * from './fileIcon';
+export * from './theme';
+export * from './filePreview';
+export * from './messageKey';

+ 36 - 107
src/views/ventAI/manageAssistent/components/chatModal/utils.ts → src/views/ventAI/manageAssistent/lib/markdown.ts

@@ -1,12 +1,11 @@
+// lib/markdown.ts:由 lib/utils.ts 按职责拆分(实现未改)
 import { marked, Renderer, walkTokens } from 'marked';
 import type { Tokens } from 'marked';
 import katex from 'katex';
-// [perf-base-v1] 卡6: DOMPurify 消毒实现迁到公共 util,本文件转调避免两处漂移
 import { sanitizeHtml } from '/@/utils/markdownSafe';
-import { getToken } from '/@/utils/auth';
-import type { AttachedFile, ThinkingStep } from './types';
 
 const mdRenderer = new Renderer();
+
 const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
 mdRenderer.link = (token: Tokens.Link): string => {
   const html = defaultLinkRenderer(token);
@@ -71,9 +70,41 @@ export const renderMarkdown = (
   });
   return icons ? wrapTables(html, icons) : html;
 };
+// renderMarkdown 是纯函数(入参相同则输出相同),但 marked 解析 + KaTeX + DOMPurify 全流程开销大。
+// 流式回答时每个 token 都会让消息列表重渲染,此时未变化的历史消息也会被重复解析,
+// 这里按「引用上限 + 图标 + 原文」记忆化:命中缓存的直接返回上次结果。
+// 内容每帧都在变的流式消息由调用方传 cacheable=false 排除,避免缓存被中间态刷满。
+
+const MD_CACHE_MAX = 200;
+
+const mdCache = new Map<string, string>();
 
+export const renderMarkdownCached = (
+  text: string,
+  icons?: { copy: string; download: string; preview: string; copySuccess: string },
+  maxCitationId?: number,
+  cacheable = true
+): string => {
+  if (!cacheable || !text) return renderMarkdown(text, icons, maxCitationId);
+
+  // \u0001 作分隔符:不会出现在正文与图标地址里,避免不同入参拼出同一个 key
+  const iconKey = icons ? `${icons.copy}\u0001${icons.download}\u0001${icons.preview}\u0001${icons.copySuccess}` : '';
+  const key = `${maxCitationId ?? -1}\u0001${iconKey}\u0001${text}`;
+  const cached = mdCache.get(key);
+  if (cached !== undefined) return cached;
+
+  const html = renderMarkdown(text, icons, maxCitationId);
+  // 简单 FIFO 淘汰,长会话下缓存条目数有上限
+  if (mdCache.size >= MD_CACHE_MAX) {
+    const oldestKey = mdCache.keys().next().value as string | undefined;
+    if (oldestKey !== undefined) mdCache.delete(oldestKey);
+  }
+  mdCache.set(key, html);
+  return html;
+};
 // 从 markdown 文本中提取链接(与 renderMarkdown 共用同一套 marked 解析,
 // 仅收集 link token,代码块/图片等不会被误收)
+
 export const extractMarkdownLinks = (text: string): Array<{ title: string; href: string }> => {
   if (!text) return [];
   const links: Array<{ title: string; href: string }> = [];
@@ -94,26 +125,6 @@ export const extractMarkdownLinks = (text: string): Array<{ title: string; href:
   return links;
 };
 
-export interface AgentGroup {
-  agent: string;
-  agentId?: string;
-  steps: ThinkingStep[];
-}
-
-// 按智能体聚合思考步骤(与思考卡片、右侧详情面板的分组语义一致:优先按 agentId 分组)
-export const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
-  const groupMap = new Map<string, AgentGroup>();
-  for (const step of steps) {
-    const agentName = step.agent || '系统';
-    const groupKey = step.agentId || agentName;
-    if (!groupMap.has(groupKey)) {
-      groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] });
-    }
-    groupMap.get(groupKey)!.steps.push(step);
-  }
-  return Array.from(groupMap.values());
-};
-
 export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
   return html.replace(/<table>([\s\S]*?)<\/table>/g, (match) => {
     const encodedTable = encodeURIComponent(match);
@@ -139,8 +150,8 @@ const decodeHtmlEntities = (escaped: string): string => {
   textarea.innerHTML = escaped;
   return textarea.value;
 };
-
 // 给渲染后 HTML 中的 ```markdown / ```md 代码块加上操作工具栏(复制/下载/预览)
+
 export const wrapMarkdownCodeBlocks = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
   return html.replace(/<pre><code class="[^"]*language-(?:markdown|md)[^"]*">([\s\S]*?)<\/code><\/pre>/g, (match, escaped: string) => {
     const encoded = encodeURIComponent(decodeHtmlEntities(escaped));
@@ -211,86 +222,4 @@ export const tableHtmlToCsv = (tableHtml: string): string => {
 
   return '' + rows.join('\n');
 };
-
-export const formatFileSize = (bytes: number): string => {
-  if (bytes < 1024) return bytes + ' B';
-  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
-  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
-};
-
-export const isImageFile = (filename: string): boolean => /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico|tiff|tif)$/i.test(filename);
-
-export const isPdfFile = (filename: string): boolean => /\.pdf$/i.test(filename);
-
-export const isVideoFile = (filename: string): boolean => /\.(mp4|webm|ogg|mov|avi|mkv|flv|wmv|m4v)$/i.test(filename);
-
-export const isAudioFile = (filename: string): boolean => /\.(mp3|wav|ogg|aac|flac|wma|m4a|opus)$/i.test(filename);
-
-export const isMdFile = (filename: string): boolean => /\.md$/i.test(filename);
-
-export const isWordFile = (filename: string): boolean => /\.(doc|docx)$/i.test(filename);
-
-export const isOldDocFile = (filename: string): boolean => /\.doc$/i.test(filename) && !/\.docx$/i.test(filename);
-
-export const isExcelFile = (filename: string): boolean => /\.(xls|xlsx)$/i.test(filename);
-
-export const isCsvFile = (filename: string): boolean => /\.csv$/i.test(filename);
-
-export const isTextFile = (filename: string): boolean =>
-  /\.(txt|md|json|csv|xml|yaml|yml|log|ini|toml|cfg|conf|env|gitignore|dockerignore|editorconfig|prettierrc|eslintrc|babelrc|properties|sh|bat|ps1|cmd|bash|zsh|fish|sql|graphql|proto|js|ts|jsx|tsx|vue|css|scss|sass|less|html|htm|svelte|py|java|kt|kts|c|cpp|h|hpp|cc|cxx|cs|go|rs|rb|php|swift|m|mm|r|R|lua|pl|pm|hs|ex|exs|erl|clj|scala|dart|zig|nim|v|sol|tf|hcl|gradle|cmake|makefile|mk)$/i.test(
-    filename
-  );
-
-export const getFileExtension = (filename: string): string => {
-  const match = filename.match(/\.([^.]+)$/);
-  return match ? `.${match[1]}`.toUpperCase() : '';
-};
-
-export const getFileIconVar = (filename: string): string => {
-  if (isMdFile(filename)) return 'var(--img-chat-file-md-icon)';
-  if (isPdfFile(filename)) return 'var(--img-chat-file-pdf-icon)';
-  if (isWordFile(filename)) return 'var(--img-chat-file-word-icon)';
-  if (isExcelFile(filename) || isCsvFile(filename)) return 'var(--img-chat-file-excel-icon)';
-  if (isImageFile(filename)) return 'var(--img-chat-file-img-icon)';
-  if (isTextFile(filename)) return 'var(--img-chat-file-txt-icon)';
-  return 'var(--img-chat-attach-icon)';
-};
-
-// 按文件类型拉取远端文件内容并构造右侧面板预览对象(消息内文件卡片与胶囊面板共用)。
-// 不支持的格式返回 null,由调用方回退为直接下载
-export const fetchPreviewFile = async (url: string, filename: string): Promise<AttachedFile | null> => {
-  const resp = await fetch(url, { headers: { 'X-Access-Token': getToken() } });
-  if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-  const ext = filename.split('.').pop()?.toLowerCase() || '';
-  const mimeMap: Record<string, string> = {
-    md: 'text/markdown',
-    txt: 'text/plain',
-    json: 'application/json',
-    xml: 'text/xml',
-    yaml: 'text/yaml',
-    yml: 'text/yaml',
-    csv: 'text/csv',
-  };
-  const id = `report-${url}`;
-  const uploadTime = new Date().toISOString();
-
-  if (mimeMap[ext]) {
-    const content = await resp.text();
-    return { id, name: filename, size: content.length, type: mimeMap[ext], content, uploadTime };
-  }
-  if (isWordFile(filename) || isExcelFile(filename)) {
-    const arrayBuffer = await resp.arrayBuffer();
-    return { id, name: filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
-  }
-  if (isImageFile(filename) || isPdfFile(filename)) {
-    const blob = await resp.blob();
-    const dataUrl = await new Promise<string>((resolve, reject) => {
-      const reader = new FileReader();
-      reader.onload = () => resolve(reader.result as string);
-      reader.onerror = () => reject(reader.error);
-      reader.readAsDataURL(blob);
-    });
-    return { id, name: filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
-  }
-  return null;
-};
+// 大数字按「万」展示(上下文容量与今日用量共用)

+ 30 - 0
src/views/ventAI/manageAssistent/lib/messageKey.ts

@@ -0,0 +1,30 @@
+// lib/messageKey.ts:由 lib/utils.ts 按职责拆分(实现未改)
+let messageSeq = 0;
+// 极端情况兜底(对象被冻结/密封等无法挂属性时),用 WeakMap 记录
+
+const messageKeyFallback = new WeakMap<object, string>();
+/**
+ * 消息的稳定标识:同一条消息在整个生命周期内返回同一个 key,供列表渲染与「按消息」存储的局部状态
+ * (思考折叠、引用折叠、复制态、编辑态、回答切换位置)使用。
+ *
+ * 不能用下标:消息被截断重发或重新生成后下标会整体前移,这些状态就会跟错消息。
+ * 也不能用后端 id:那是流式结束后才回填的,key 变化会导致节点重建、状态丢失。
+ * uid 挂在消息对象上且不可枚举,不会进入请求体或序列化结果。
+ */
+
+export const getMessageKey = (message: unknown): string => {
+  const target = message as Record<string, unknown> | null | undefined;
+  if (!target || typeof target !== 'object') return 'unknown';
+  if (typeof target.__uid !== 'string') {
+    const uid = `msg-${++messageSeq}`;
+    try {
+      Object.defineProperty(target, '__uid', { value: uid, enumerable: false, configurable: true, writable: true });
+    } catch {
+      const existing = messageKeyFallback.get(target);
+      if (existing) return existing;
+      messageKeyFallback.set(target, uid);
+      return uid;
+    }
+  }
+  return target.__uid as string;
+};

+ 29 - 0
src/views/ventAI/manageAssistent/lib/modalWrap.ts

@@ -0,0 +1,29 @@
+/**
+ * antd Modal 包装层(modal-wrap)的 DOM 工具。
+ *
+ * 助手弹框需要自行控制包装层的定位:全屏时置顶铺满、拖拽时改写 left/top,
+ * 但 antd 的 wrap 类名在不同构建下不一致(zxm- / ant- / 自定义),故统一用 [class*="modal-wrap"] 兜底匹配。
+ */
+const WRAP_SELECTOR = '.zxm-modal-wrap, .ant-modal-wrap, [class*="modal-wrap"]';
+
+/** 从事件目标向上找 modal 包装层 */
+export const findModalWrap = (target: HTMLElement): HTMLElement | null => target.closest<HTMLElement>(WRAP_SELECTOR);
+
+/** 直接查当前页面的 modal 包装层(拖拽兜底、统一改样式时用) */
+export const queryModalWrap = (): HTMLElement | null => document.querySelector<HTMLElement>(WRAP_SELECTOR);
+
+/** 统一处理 modal-wrap 内联样式:全屏时置顶铺满,否则恢复默认 */
+export const applyWrapStyles = (fullscreen: boolean) => {
+  document.querySelectorAll<HTMLElement>(WRAP_SELECTOR).forEach((wrap) => {
+    wrap.style.position = '';
+    wrap.style.left = '';
+    wrap.style.top = fullscreen ? '0' : '';
+    wrap.style.right = '';
+    wrap.style.bottom = '';
+    wrap.style.width = '';
+    wrap.style.height = '';
+    wrap.style.margin = fullscreen ? '0' : '';
+    wrap.style.padding = fullscreen ? '0' : '';
+    wrap.style.transform = '';
+  });
+};

+ 22 - 0
src/views/ventAI/manageAssistent/lib/stepRenderer.ts

@@ -0,0 +1,22 @@
+// lib/stepRenderer.ts:由 lib/utils.ts 按职责拆分(实现未改)
+import type { ThinkingStep } from '/@/views/ventAI/manageAssistent/types/chat';
+
+export interface AgentGroup {
+  agent: string;
+  agentId?: string;
+  steps: ThinkingStep[];
+}
+// 按智能体聚合思考步骤(与思考卡片、右侧详情面板的分组语义一致:优先按 agentId 分组)
+
+export const groupStepsByAgent = (steps: ThinkingStep[]): AgentGroup[] => {
+  const groupMap = new Map<string, AgentGroup>();
+  for (const step of steps) {
+    const agentName = step.agent || '系统';
+    const groupKey = step.agentId || agentName;
+    if (!groupMap.has(groupKey)) {
+      groupMap.set(groupKey, { agent: agentName, agentId: step.agentId, steps: [] });
+    }
+    groupMap.get(groupKey)!.steps.push(step);
+  }
+  return Array.from(groupMap.values());
+};

+ 17 - 0
src/views/ventAI/manageAssistent/lib/theme.ts

@@ -0,0 +1,17 @@
+// lib/theme.ts:由 lib/utils.ts 按职责拆分(实现未改)
+const cssUrlCache = new Map<string, string>();
+
+let cssUrlObserverInstalled = false;
+
+export const getCssUrl = (varName: string): string => {
+  if (!cssUrlObserverInstalled && typeof MutationObserver !== 'undefined') {
+    cssUrlObserverInstalled = true;
+    new MutationObserver(() => cssUrlCache.clear()).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+  }
+  const cached = cssUrlCache.get(varName);
+  if (cached !== undefined) return cached;
+  const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
+  const url = val.replace(/^url\(["']?/, '').replace(/["']?\)$/, '');
+  cssUrlCache.set(varName, url);
+  return url;
+};

+ 267 - 0
src/views/ventAI/manageAssistent/styles/modal.less

@@ -0,0 +1,267 @@
+// AI 助手弹框的全局样式(由 components/AiAssistantModal.vue 的 <style lang="less"> 原样抽出,规则未改);
+// 组件通过 <style lang="less">@import '../styles/modal.less';</style> 引入,保持「组件挂载时才生效」的原语义。
+
+@import './tokens.less';
+
+.ai-assistant-modal {
+  .zxm-modal-content {
+    background-image: var(--img-chat-modal-bg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    background-color: unset !important;
+    box-shadow: none;
+    padding: 0;
+    border: none !important;
+    transition: width 0.3s ease;
+  }
+
+  /* 全屏切换缩放动画 */
+  @keyframes modal-zoom-in {
+    from {
+      transform: scale(0.85);
+      opacity: 0.6;
+    }
+    to {
+      transform: scale(1);
+      opacity: 1;
+    }
+  }
+
+  @keyframes modal-zoom-out {
+    from {
+      transform: scale(1.15);
+      opacity: 0.6;
+    }
+    to {
+      transform: scale(1);
+      opacity: 1;
+    }
+  }
+
+  .zxm-modal-content.modal-zoom-in-anim {
+    animation: modal-zoom-in 0.3s ease forwards;
+  }
+
+  .zxm-modal-content.modal-zoom-out-anim {
+    animation: modal-zoom-out 0.3s ease forwards;
+  }
+  .zxm-modal-close {
+    background-image: var(--img-chat-close-bg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    top: 12px;
+    right: 20px;
+    width: 38px;
+    height: 38px;
+    .zxm-modal-close-x {
+      width: 38px;
+      height: 38px;
+    }
+    svg {
+      width: 26px;
+      height: 26px;
+      margin-bottom: 4px;
+    }
+  }
+
+  .maximize-btn {
+    position: absolute;
+    top: 12px;
+    right: 72px;
+    width: 38px;
+    height: 38px;
+    background-image: var(--img-chat-close-bg);
+    background-repeat: no-repeat;
+    background-size: 100% 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    z-index: 10;
+
+    .maximize-icon {
+      width: 22px;
+      height: 22px;
+      background-image: var(--img-chat-maximize-icon);
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+
+      &.minimized {
+        background-image: var(--img-chat-minimize-icon);
+      }
+    }
+  }
+
+  &.fullscreen {
+    top: 0 !important;
+    height: 100% !important;
+    padding: 0 !important;
+    max-width: none !important;
+    margin: 0 !important;
+    background-color: @color-night;
+
+    .zxm-modal-wrap,
+    .ant-modal-wrap {
+      padding: 0 !important;
+      margin: 0 !important;
+      top: 0 !important;
+    }
+    .zxm-modal-close,
+    .maximize-btn {
+      top: 40px;
+    }
+
+    .zxm-modal-content {
+      width: 100% !important;
+      height: 100% !important;
+      max-width: 100% !important;
+      margin: 0;
+      border-radius: 0;
+      overflow: hidden;
+      background-image: var(--img-chat-fs-modal-bg);
+    }
+    .zxm-modal-header {
+      height: 90px;
+      background-image: var(--img-chat-fs-header-bg) !important;
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+      margin-bottom: 16px;
+    }
+    .no-drag {
+      cursor: default;
+      font-size: 32px !important;
+    }
+    .zxm-modal-title {
+      height: 90px;
+    }
+    .zxm-modal-body {
+      height: calc(100% - 120px);
+      overflow: hidden;
+      padding: 0 24px 12px;
+    }
+    .ai-container {
+      height: 100%;
+      overflow: hidden;
+    }
+    .chat-panel {
+      background-image: var(--img-chat-fs-panel-bg) !important;
+    }
+    .chat-header {
+      padding: 0 12px;
+      height: 50px;
+    }
+    .collapse-btn {
+      top: 60px;
+      background-image: var(--img-chat-fs-collapse-btn-bg) !important;
+      background-repeat: no-repeat;
+      background-size: 100% 100%;
+    }
+    .task-list-panel {
+      padding: 20px;
+      background-image: var(--img-chat-fs-task-panel-bg) !important;
+      background-repeat: no-repeat !important;
+      background-size: 100% 100% !important;
+    }
+    .options-area {
+      gap: 12px;
+    }
+    .options-area-item {
+      background-image: var(--img-chat-fs-options-item-bg) !important;
+      background-repeat: no-repeat !important;
+      background-size: 100% 100% !important;
+
+      &.active,
+      &:active {
+        background-image: var(--img-chat-fs-options-item-active-bg) !important;
+      }
+      .options-img {
+        background-image: none !important;
+      }
+    }
+    .chat-header-title {
+      height: 40px;
+      line-height: 40px;
+      position: relative;
+      background-image: var(--img-chat-fs-header-title-bg) !important;
+      background-repeat: no-repeat !important;
+      background-size: auto 100% !important;
+      padding: 0 28px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 40px;
+        height: 40px;
+        background-image: var(--img-chat-fs-header-title-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+      }
+    }
+    .chat-header-title-wrapper {
+      .title-edit-input {
+        height: 36px;
+        font-size: 16px;
+        background: @color-primary-a15;
+        border-color: @color-primary-a60;
+      }
+      .title-edit-btn {
+        width: 24px;
+        height: 24px;
+        background-size: 16px 16px;
+      }
+    }
+    .chat-input-area {
+      margin: 24px;
+    }
+    .right-panel {
+      background-image: var(--img-chat-fs-panel-bg) !important;
+    }
+    :deep(.panel-tab-bar) {
+      padding: 14px 16px 0;
+    }
+    .schedule-panel,
+    .skill-panel,
+    .sub-agent-panel {
+      background-image: var(--img-chat-fs-panel-bg) !important;
+    }
+  }
+
+  .zxm-modal-header {
+    background-color: unset !important;
+    background: unset;
+    border: none !important;
+    padding: 8px;
+    backdrop-filter: unset;
+    display: flex;
+    justify-content: center;
+  }
+  .zxm-modal-title {
+    width: 100%;
+    height: 60px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    .draggable-title {
+      color: @color-white;
+      font-family: var(--vet-font-family);
+      letter-spacing: 3px;
+      font-size: 24px;
+      font-weight: 100;
+      line-height: 1.4;
+    }
+  }
+  .zxm-modal-body {
+    padding: 12px 24px !important;
+  }
+}
+
+.delete-task-confirm-modal {
+  .zxm-modal-confirm-title,
+  .zxm-modal-confirm-content {
+    color: @color-white !important;
+  }
+}

+ 31 - 0
src/views/ventAI/manageAssistent/styles/tokens.less

@@ -0,0 +1,31 @@
+// AI 助手的颜色基线:数值取自模块内既有样式的实际用值(注释里是当前出现次数),
+// 目前仅 modal.less 使用;其余组件可逐屏替换(替换是等值替换,但涉及界面外观,建议逐屏核对后再改)。
+
+@color-text: #e0e6ed; // 正文主色(模块内 87 处)
+@color-text-muted: #8b949e; // 次要说明文字(90 处)
+@color-text-dim: #6a7a8a; // 更弱的提示文字(24 处)
+@color-text-soft: #c9d1d9; // 正文次一级(22 处)
+@color-text-strong: #e8edf3; // 强调文字(23 处)
+@color-primary: #0a84ff; // 主色(28 处)
+@color-primary-light: #5cb8ff; // 主色浅(17 处)
+@color-primary-mid: #3a8fd4; // 主色中间调(14 处)
+@color-primary-soft: #79c0ff; // 主色柔和(10 处)
+@color-panel-bg: #0a2a3f; // 面板底色(10 处)
+@color-panel-bg-deep: #0d2137; // 更深的面板底色(8 处)
+@color-panel-border: #1a4a6f; // 面板描边(16 处)
+@color-input-bg: #002f4a; // 输入区底色
+@color-input-border: #005482; // 输入区描边
+@color-danger: #ff4d4f; // 危险/错误(7 处)
+@color-danger-light: #ff7875; // 危险浅(7 处)
+@color-warning: #d29922; // 警示(6 处)
+@color-white: #fff; // 纯白
+@color-border-soft: rgba(63, 80, 106, 0.3); // 常规分隔线
+@color-border-mid: rgba(63, 80, 106, 0.4); // 稍强分隔线
+@color-border-strong: rgba(63, 80, 106, 0.5); // 强分隔线
+@color-primary-a08: rgba(10, 132, 255, 0.08); // 主色 8%
+@color-primary-a10: rgba(10, 132, 255, 0.1); // 主色 10%
+@color-primary-a15: rgba(10, 132, 255, 0.15); // 主色 15%
+@color-primary-a20: rgba(10, 132, 255, 0.2); // 主色 20%
+@color-primary-a50: rgba(10, 132, 255, 0.5); // 主色 50%
+@color-primary-a60: rgba(10, 132, 255, 0.6); // 主色 60%
+@color-night: #09172c; // 深空底色

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

@@ -0,0 +1,110 @@
+// types/chat.ts:按领域拆分的类型定义(由 chatModal/types.ts 拆分,内容未改)
+import type { AttachedFile } from './resource';
+import type { CitationsData } from './domain';
+import type { RightPanelState } from './panel';
+
+export interface AgentAnswer {
+  id: string;
+  agentName: string;
+  toolName: string;
+  thinking: string;
+  result: string;
+  status: 'thinking' | 'completed';
+  timestamp: number;
+}
+
+export interface ThinkingStep {
+  type: 'agent_start' | 'agent_done' | 'tool_call' | 'tool_result' | 'executing' | 'updated_todo_list' | 'token';
+  agent?: string;
+  agentId?: string;
+  subagentType?: string;
+  thinkingContent?: string;
+  tool?: string;
+  message?: string;
+  todos?: Array<{ content: string; status: string }>;
+  content?: string;
+  timestamp: number;
+}
+
+export interface AskUserQuestion {
+  question: string;
+  type: 'text' | 'multiple_choice';
+  choices?: any[];
+  required?: boolean;
+}
+
+export interface PendingApprovalData {
+  message: string;
+  sessionId: string;
+  threadId?: string;
+  interrupts?: any[];
+  elapsedMs?: number;
+  prevThinkingContent?: string;
+  plan?: string;
+  ask_user?: {
+    questions: AskUserQuestion[];
+    tool_call_id?: string;
+  };
+  answers?: string[];
+}
+
+export interface Message {
+  id?: number;
+  type: 'user' | 'ai';
+  content: string;
+  // 原始用户输入(不含注入的技能/会话引用 <img> HTML),用于编辑重发/重新生成时回填模型输入
+  rawInput?: string;
+  thinkingContent?: string;
+  time: string;
+  createdAt?: string;
+  isTerminal?: boolean;
+  isLoading?: boolean;
+  isPendingApproval?: boolean;
+  pendingApprovalData?: PendingApprovalData;
+  attachedFile?: AttachedFile;
+  agentAnswers?: AgentAnswer[];
+  thinkingSteps?: ThinkingStep[];
+  sessionId?: string;
+  wordDownloadUrl?: string;
+  downloadFiles?: Array<{ url: string; filename: string }>;
+  citations?: CitationsData;
+  durationMs?: number;
+  baseDurationMs?: number;
+  generateStartTime?: number;
+}
+
+export interface Task {
+  id: string;
+  name: string;
+  sessionId: string;
+  messages: Message[];
+  attachedFiles: AttachedFile[];
+  wordUrl?: string;
+  updatedAt?: string;
+  sortOrder?: number;
+  isStreaming?: boolean;
+  // 流式期间从会话列表归并得到的后端会话 id(流结束前 sessionId 尚未返回)
+  pendingSessionId?: string;
+  // 右侧辅助面板标签页状态(按任务维度保存)
+  rightPanels?: RightPanelState;
+}
+
+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;
+}

+ 37 - 0
src/views/ventAI/manageAssistent/types/domain.ts

@@ -0,0 +1,37 @@
+// types/domain.ts:按领域拆分的类型定义(由 chatModal/types.ts 拆分,内容未改)
+export interface CitationItem {
+  id: number;
+  type: 'knowledge_base' | 'mcp' | 'web' | 'calc' | 'file' | 'history' | 'system' | 'model';
+  type_cn: string;
+  title: string;
+  tool: string;
+  tool_cn: string;
+  score?: number;
+  low_score?: boolean;
+  doc_path?: string;
+  search_url?: string;
+  snippet?: string;
+  url?: string;
+  fetched_at?: string;
+  source_system?: string;
+  param?: Record<string, any>;
+  meta?: any;
+}
+
+export interface CitationsData {
+  items: CitationItem[];
+  model_only: boolean;
+  message?: string;
+}
+
+export interface ScheduleTask {
+  id: number;
+  name: string;
+  cron_expr: string;
+  cron_human: string;
+  prompt: string;
+  enabled: boolean;
+  next_run_at: string | null;
+  feedback_session_id?: string;
+  schedule?: { freq: string; human?: string };
+}

+ 5 - 0
src/views/ventAI/manageAssistent/types/index.ts

@@ -0,0 +1,5 @@
+// 类型 barrel:对外保持单一入口,importers 无需按符号逐个改路径
+export * from './chat';
+export * from './panel';
+export * from './resource';
+export * from './domain';

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

@@ -0,0 +1,20 @@
+// types/panel.ts:按领域拆分的类型定义(由 chatModal/types.ts 拆分,内容未改)
+import type { AttachedFile } from './resource';
+import type { ScheduleTask } from './domain';
+
+// 右侧面板“智能体详情”标签页的定位信息(消息下标 + 智能体标识)
+export interface AgentDetailData {
+  messageIndex: number;
+  agent: string;
+  agentId?: string;
+}
+
+export type RightPanelTab =
+  | { id: string; type: 'file'; title: string; file: AttachedFile; editable: boolean }
+  | { id: string; type: 'agent'; title: string; messageIndex: number; agent: string; agentId?: string }
+  | { id: string; type: 'schedule'; title: string; task: ScheduleTask };
+
+export interface RightPanelState {
+  tabs: RightPanelTab[];
+  activeTabId: string | null;
+}

+ 46 - 0
src/views/ventAI/manageAssistent/types/resource.ts

@@ -0,0 +1,46 @@
+// types/resource.ts:按领域拆分的类型定义(由 chatModal/types.ts 拆分,内容未改)
+export interface AttachedFile {
+  id: string;
+  name: string;
+  size: number;
+  type: string;
+  preview?: string;
+  content?: string;
+  arrayBuffer?: ArrayBuffer;
+  uploadTime: string;
+  messageId?: string;
+  originalFile?: File;
+}
+
+// 胶囊面板聚合文件项:用户上传的附件 + AI 返回的下载链接
+export interface CapsuleFileItem {
+  kind: 'upload' | 'download';
+  name: string;
+  size?: number;
+  // upload 类型的原始附件(用于打开右侧预览面板)
+  file?: AttachedFile;
+  // download 类型的下载地址
+  url?: string;
+}
+
+// 胶囊面板聚合链接项:从 AI 回复 markdown 文本中解析出的可跳转链接
+export interface CapsuleLinkItem {
+  title: string;
+  href: string;
+}
+
+// 胶囊面板聚合智能体条目:thinking 卡片中按(消息、智能体)聚合的一次智能体调用
+export interface CapsuleAgentItem {
+  messageIndex: number;
+  agent: string;
+  agentId?: string;
+  // running: 有 start 无 done 且消息仍在流式;done: 收到完成事件;
+  // waiting: 消息处于审批等待(智能体暂停待用户确认);interrupted: 未完成但消息已结束
+  status: 'running' | 'done' | 'waiting' | 'interrupted';
+  // 所属问答轮次(从 1 开始)
+  round: number;
+  toolCount: number;
+  stepCount: number;
+  // 是否属于多回答折叠组中被隐藏的历史回答(重新生成产生)
+  hiddenAnswer?: boolean;
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác