소스 검색

[Feat 0000] AI助手新增会话资源胶囊弹框

wangkeyi 4 일 전
부모
커밋
9c8f107bac

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
src/assets/images/ventAI/chatModalBlue/2-13.svg


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

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1789701287227" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10438" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 512m-128 0a128 128 0 1 0 256 0 128 128 0 1 0-256 0Z" fill="#ffffff" p-id="10439"></path><path d="M554.666667 173.610667V85.333333h-85.333334v88.277334A341.674667 341.674667 0 0 0 173.610667 469.333333H85.333333v85.333334h88.277334A341.632 341.632 0 0 0 469.333333 850.389333V938.666667h85.333334v-88.277334A341.632 341.632 0 0 0 850.389333 554.666667H938.666667v-85.333334h-88.277334A341.674667 341.674667 0 0 0 554.666667 173.610667zM512 768c-141.184 0-256-114.816-256-256s114.816-256 256-256 256 114.816 256 256-114.816 256-256 256z" fill="#ffffff" p-id="10440"></path></svg>

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

@@ -126,6 +126,8 @@ html[data-theme='deepblue'] {
   --img-chat-mode-full-icon: url('/src/assets/images/ventAI/chatModalBlue/3-12.svg');
   --img-chat-regenerate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
   --img-chat-export-icon: url('/src/assets/images/ventAI/chatModalBlue/2-12.svg');
+  --img-chat-link-icon: url('/src/assets/images/ventAI/chatModalBlue/2-13.svg');
+  --img-chat-locate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-14.svg');
 
   // 全屏模式图片
   --img-chat-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

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

@@ -134,6 +134,8 @@ html {
   --img-chat-mode-full-icon: url('/src/assets/images/ventAI/chatModalBlue/3-12.svg');
   --img-chat-regenerate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-11.svg');
   --img-chat-export-icon: url('/src/assets/images/ventAI/chatModalBlue/2-12.svg');
+  --img-chat-link-icon: url('/src/assets/images/ventAI/chatModalBlue/2-13.svg');
+  --img-chat-locate-icon: url('/src/assets/images/ventAI/chatModalBlue/2-14.svg');
 
   // 全屏模式图片
   --img-chat-fs-header-bg: url('/src/assets/images/ventAI/chatModalBlue/fullscreen/1-1.png');

+ 18 - 1
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -48,6 +48,9 @@
             :canGoPrev="canGoPrev"
             :canGoNext="canGoNext"
             :currentWordUrl="currentWordUrl"
+            :capsule-files="capsuleFiles"
+            :capsule-links="capsuleLinks"
+            :capsule-agents="capsuleAgents"
             @go-to-prev="goToPrev"
             @go-to-next="goToNext"
             @create-task="createNewTask"
@@ -55,6 +58,9 @@
             @download-word="downloadWordReport"
             @export-md="handleExportMarkdown"
             @export-word="handleExportWord"
+            @file-preview="openFilePreview"
+            @agent-detail-click="handleAgentDetailClick"
+            @locate-message="handleLocateMessage"
           />
 
           <!-- 附件列表面板 -->
@@ -189,6 +195,7 @@
   import type { TodayUsage } from '../api';
   import type { AttachedFile, Message, Task, RightPanelTab, RightPanelState, SseEvent } from './chatModal/types';
   import { isTextFile, isVideoFile, isAudioFile, isWordFile, isExcelFile, isCsvFile } from './chatModal/utils';
+  import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from './chatModal/capsuleData';
   import { buildConversationMarkdown, markdownToDocxBlob } from './chatModal/exportDoc';
 
   interface Props {
@@ -1094,12 +1101,17 @@
     });
   };
 
+  // 胶囊面板"定位到消息":滚动消息列表到该智能体所在的消息
+  const handleLocateMessage = (messageIndex: number) => {
+    chatMessagesRef.value?.scrollToMessage(messageIndex, 'center');
+  };
+
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
     Modal.confirm({
       title: '提示',
-      content: '删除该消息将不再进入模型上下文,是否继续?',
+      content: '是否删除该消息?',
       okText: '确认',
       cancelText: '取消',
       wrapClassName: 'delete-task-confirm-modal',
@@ -1196,6 +1208,11 @@
     removeTab(task.rightPanels, tabId);
   };
 
+  // 胶囊聚合数据:转换逻辑内聚在 capsuleData 纯函数模块中,此处只做响应式接线
+  const capsuleFiles = computed(() => buildCapsuleFiles(currentTask.value, messages.value));
+  const capsuleLinks = computed(() => buildCapsuleLinks(capsuleFiles.value, messages.value));
+  const capsuleAgents = computed(() => buildCapsuleAgents(messages.value, (i) => chatMessagesRef.value?.isAnswerVisible(i)));
+
   const currentWordUrl = computed(() => {
     return currentTask.value?.wordUrl || '';
   });

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

@@ -32,6 +32,18 @@
       </template>
     </div>
 
+    <!-- 文件、链接与智能体聚合胶囊 -->
+    <ResourceCapsule
+      v-if="currentTask?.sessionId"
+      :task-id="currentTask?.id"
+      :files="capsuleFiles"
+      :links="capsuleLinks"
+      :agents="capsuleAgents"
+      @file-preview="emit('file-preview', $event)"
+      @agent-detail-click="emit('agent-detail-click', $event)"
+      @locate-message="emit('locate-message', $event)"
+    />
+
     <!-- 会话导出 -->
     <div v-if="currentTask?.sessionId" ref="exportWrapRef" class="export-wrapper">
       <div class="export-bg" title="导出会话" @click="toggleExportPopup">
@@ -59,7 +71,8 @@
 <script setup lang="ts">
   import { ref, nextTick } from 'vue';
   import { onClickOutside } from '@vueuse/core';
-  import type { Task } from './types';
+  import ResourceCapsule from './ResourceCapsule.vue';
+  import type { AttachedFile, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem, Task } from './types';
 
   interface Props {
     currentTask: Task | undefined;
@@ -67,6 +80,9 @@
     canGoPrev: boolean;
     canGoNext: boolean;
     currentWordUrl: string;
+    capsuleFiles: CapsuleFileItem[];
+    capsuleLinks: CapsuleLinkItem[];
+    capsuleAgents: CapsuleAgentItem[];
   }
 
   const props = defineProps<Props>();
@@ -80,6 +96,12 @@
     'download-word': [];
     'export-md': [];
     'export-word': [];
+    // 胶囊面板中点击上传附件,交给父组件打开右侧预览
+    'file-preview': [file: AttachedFile];
+    // 胶囊面板中点击智能体条目,交给父组件打开右侧智能体详情
+    'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
+    // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表
+    'locate-message': [messageIndex: number];
   }>();
 
   // 导出气泡状态
@@ -132,7 +154,7 @@
 <style scoped lang="less">
   .chat-header {
     display: flex;
-    padding: 12px 20px;
+    padding: 6px 16px;
     flex-shrink: 0;
     align-items: center;
     background: rgba(2, 53, 100, 0.3);

+ 23 - 60
src/views/ventAI/manageAssistent/components/chatModal/ChatMessages.vue

@@ -386,15 +386,14 @@
     tableHtmlToCsv,
     isMdFile,
     isTextFile,
-    isWordFile,
-    isExcelFile,
-    isImageFile,
-    isPdfFile,
     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, ThinkingStep } from './types';
+  import type { Message, AttachedFile } from './types';
   // import katex from 'katex';
   // import 'katex/dist/katex.min.css';
 
@@ -564,6 +563,9 @@
     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);
@@ -690,38 +692,8 @@
   const handlePreviewFile = async (file: { url: string; filename: string }) => {
     if (!file.url) return;
     try {
-      const resp = await fetch(file.url, { headers: { 'X-Access-Token': getToken() } });
-      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
-      const ext = file.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-${file.url}`;
-      const uploadTime = new Date().toISOString();
-
-      let previewFile: AttachedFile;
-      if (mimeMap[ext]) {
-        const content = await resp.text();
-        previewFile = { id, name: file.filename, size: content.length, type: mimeMap[ext], content, uploadTime };
-      } else if (isWordFile(file.filename) || isExcelFile(file.filename)) {
-        const arrayBuffer = await resp.arrayBuffer();
-        previewFile = { id, name: file.filename, size: arrayBuffer.byteLength, type: 'application/octet-stream', arrayBuffer, uploadTime };
-      } else if (isImageFile(file.filename) || isPdfFile(file.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);
-        });
-        previewFile = { id, name: file.filename, size: blob.size, type: blob.type, preview: dataUrl, uploadTime };
-      } else {
+      const previewFile = await fetchPreviewFile(file.url, file.filename);
+      if (!previewFile) {
         // 不支持的格式回退为直接下载
         handleDownloadFile(file.url, file.filename);
         return;
@@ -991,25 +963,6 @@
     return '';
   };
 
-  interface AgentGroup {
-    agent: string;
-    agentId?: string;
-    steps: ThinkingStep[];
-  }
-
-  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());
-  };
-
   const formatFullTime = (createdAt: string) => {
     return dayjs(createdAt).format('YYYY-MM-DD HH:mm:ss');
   };
@@ -1176,7 +1129,18 @@
 
   const scrollToMessage = (index: number, block: 'start' | 'center' = 'start') => {
     const wrappers = messagesRef.value?.querySelectorAll('.message-wrapper');
-    const el = wrappers?.[index] as HTMLElement | undefined;
+    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);
   };
 
@@ -1189,7 +1153,7 @@
     setTimeout(() => el?.classList.remove('nav-located-highlight'), 1500);
   };
 
-  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers });
+  defineExpose({ messagesRef, isAtBottom, forceScrollToBottom, scrollToMessage, initAskUserAnswers, isAnswerVisible });
 </script>
 
 <style scoped lang="less">
@@ -1942,7 +1906,6 @@
         }
 
         .citations-card {
-          margin-bottom: 12px;
           overflow: hidden;
 
           .citations-header {
@@ -2391,7 +2354,7 @@
       }
 
       .user-message .message-actions {
-        padding: 4px 0 0 0;
+        padding: 4px 0;
         justify-content: flex-end;
       }
     }

+ 896 - 0
src/views/ventAI/manageAssistent/components/chatModal/ResourceCapsule.vue

@@ -0,0 +1,896 @@
+<template>
+  <div ref="wrapRef" class="resource-capsule">
+    <!-- 胶囊按钮 -->
+    <button type="button" :class="['capsule-btn', { open: isOpen }]" title="会话资源" :aria-expanded="isOpen" @click="isOpen = !isOpen">
+      <div class="capsule-icon"></div>
+      <span class="capsule-text"> </span>
+      <span v-if="totalCount > 0" class="capsule-count">{{ totalCount }}</span>
+      <div class="capsule-arrow"></div>
+    </button>
+
+    <!-- 聚合下拉面板 -->
+    <Transition name="capsule-fade">
+      <div v-if="isOpen" class="capsule-popup" :style="{ width: `${popupWidth}px` }" @click.stop>
+        <div class="popup-header">
+          <div class="popup-title">
+            <div class="popup-title-icon"></div>
+            <span>会话资源</span>
+          </div>
+          <CloseOutlined class="close-icon" @click="isOpen = false" />
+        </div>
+        <div class="popup-body">
+          <template v-if="files.length || links.length || agents.length">
+            <div v-if="files.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedFiles }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedFiles"
+                @click="collapsedFiles = !collapsedFiles"
+                @keydown.enter.prevent="collapsedFiles = !collapsedFiles"
+                @keydown.space.prevent="collapsedFiles = !collapsedFiles"
+              >
+                <span class="section-title-text">文件</span>
+                <span class="section-count">{{ files.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedFiles" class="section-body">
+                  <div
+                    v-for="item in files"
+                    :key="fileKey(item)"
+                    class="popup-item file-item"
+                    :title="`预览 ${item.name}`"
+                    @click="handleFileClick(item)"
+                  >
+                    <div v-if="previewingKey === fileKey(item)" class="row-loading"></div>
+                    <div v-else class="item-icon" :style="{ backgroundImage: getFileIconVar(item.name) }"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="item.name">{{ item.name }}</div>
+                      <div class="item-meta">
+                        <span class="item-tag" :class="item.kind">{{ item.kind === 'upload' ? '上传' : 'AI生成' }}</span>
+                        <span v-if="item.size != null">{{ formatFileSize(item.size) }}</span>
+                      </div>
+                    </div>
+                    <button class="download-btn" :title="`下载 ${item.name}`" @click.stop="downloadFile(item)">
+                      <div class="download-btn-icon"></div>
+                    </button>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+            <div v-if="links.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedLinks }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedLinks"
+                @click="collapsedLinks = !collapsedLinks"
+                @keydown.enter.prevent="collapsedLinks = !collapsedLinks"
+                @keydown.space.prevent="collapsedLinks = !collapsedLinks"
+              >
+                <span class="section-title-text">链接</span>
+                <span class="section-count">{{ links.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedLinks" class="section-body">
+                  <div v-for="link in links" :key="link.href" class="popup-item link-item" @click="openLink(link)">
+                    <div class="link-glyph"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="link.title">{{ link.title }}</div>
+                      <div class="item-meta link-href" :title="link.href">{{ link.href }}</div>
+                    </div>
+                    <div class="link-arrow"></div>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+            <div v-if="agents.length" class="popup-section">
+              <div
+                :class="['section-title', { collapsed: collapsedAgents }]"
+                role="button"
+                tabindex="0"
+                :aria-expanded="!collapsedAgents"
+                @click="collapsedAgents = !collapsedAgents"
+                @keydown.enter.prevent="collapsedAgents = !collapsedAgents"
+                @keydown.space.prevent="collapsedAgents = !collapsedAgents"
+              >
+                <span class="section-title-text">智能体</span>
+                <span class="section-count">{{ agents.length }}</span>
+                <div class="section-arrow"></div>
+              </div>
+              <Transition name="collapse">
+                <div v-show="!collapsedAgents" class="section-body">
+                  <div v-for="item in agents" :key="agentKey(item)" class="popup-item agent-item" @click="handleAgentClick(item)">
+                    <div class="agent-icon"></div>
+                    <div class="item-info">
+                      <div class="item-name" :title="item.agent">{{ item.agent }}</div>
+                      <div class="item-meta">
+                        <span class="agent-status" :class="item.status">
+                          <span class="status-dot"></span>
+                          {{ agentStatusText(item.status) }}
+                        </span>
+                        <span>第{{ item.round }}轮</span>
+                        <span v-if="item.toolCount > 0">{{ item.toolCount }} 次工具调用</span>
+                        <span v-if="item.hiddenAnswer" class="item-tag folded">折叠回答</span>
+                      </div>
+                    </div>
+                    <button class="locate-btn" :title="`定位到第 ${item.round} 轮消息`" @click.stop="handleLocateClick(item)">
+                      <div class="locate-btn-icon"></div>
+                    </button>
+                    <div class="link-arrow"></div>
+                  </div>
+                </div>
+              </Transition>
+            </div>
+          </template>
+          <div v-else class="popup-empty">
+            <div class="popup-empty-title">暂无内容</div>
+            <div class="popup-empty-desc">AI 生成的文件、可跳转链接与智能体调用记录会汇总到这里</div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, watch, nextTick } from 'vue';
+  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';
+
+  const props = defineProps<{
+    files: CapsuleFileItem[];
+    links: CapsuleLinkItem[];
+    agents: CapsuleAgentItem[];
+    // 当前任务 id:切换任务时收起弹层,避免用户误操作上一个任务的数据
+    taskId?: string;
+  }>();
+
+  const emit = defineEmits<{
+    (e: 'file-preview', file: AttachedFile): void;
+    (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
+    (e: 'locate-message', messageIndex: number): void;
+  }>();
+
+  const wrapRef = ref<HTMLElement>();
+  const isOpen = ref(false);
+  // 分区默认折叠,点击标题后才展开
+  const collapsedFiles = ref(true);
+  const collapsedLinks = ref(true);
+  const collapsedAgents = ref(true);
+  // 正在拉取预览内容的文件行(显示加载态、防重复点击)
+  const previewingKey = ref<string | null>(null);
+  const totalCount = computed(() => props.files.length + props.links.length + props.agents.length);
+
+  onClickOutside(wrapRef, () => {
+    isOpen.value = false;
+  });
+
+  // Esc 收起弹层(键盘可达性)
+  onKeyStroke('Escape', () => {
+    isOpen.value = false;
+  });
+
+  watch(
+    () => props.taskId,
+    () => {
+      isOpen.value = false;
+      // 切换任务后分区回到默认折叠状态
+      collapsedFiles.value = true;
+      collapsedLinks.value = true;
+      collapsedAgents.value = true;
+    }
+  );
+
+  // 弹层宽度自适应:窄窗口/拖拽缩放后避免超出聊天面板左边界被裁切。
+  // 容器带 scale 缩放,getBoundingClientRect 是缩放后的视口坐标,需换算回布局坐标
+  const popupWidth = ref(300);
+  const fitPopupWidth = () => {
+    const btn = wrapRef.value;
+    const panel = btn?.closest('.chat-panel') as HTMLElement | null;
+    if (!btn || !panel) return;
+    const btnRect = btn.getBoundingClientRect();
+    const panelRect = panel.getBoundingClientRect();
+    const scale = panel.offsetHeight ? panelRect.height / panel.offsetHeight : 1;
+    const available = (btnRect.right - panelRect.left - 8) / scale;
+    popupWidth.value = Math.max(200, Math.min(300, available));
+  };
+
+  watch(isOpen, (open) => {
+    if (open) nextTick(fitPopupWidth);
+  });
+
+  // 稳定列表 key:文件按来源唯一标识(上传附件 id / 下载地址),智能体按消息×身份标识
+  const fileKey = (item: CapsuleFileItem): string =>
+    item.kind === 'upload' ? `upload-${item.file?.id || item.name}` : `download-${item.url || item.name}`;
+  const agentKey = (item: CapsuleAgentItem): string => `agent-${item.messageIndex}-${item.agentId || item.agent}`;
+
+  // 下载锚点挂在胶囊根节点内而非 body:
+  // 程序化 a.click() 的合成点击(detail === 0)会被 onClickOutside 当作"外部点击"而关闭弹层,
+  // 挂载到组件内部后合成点击的路径包含组件根节点,不会被误判
+  const triggerDownload = (href: string, filename: string) => {
+    const a = document.createElement('a');
+    a.href = href;
+    a.download = filename;
+    const mountEl = wrapRef.value || document.body;
+    mountEl.appendChild(a);
+    a.click();
+    mountEl.removeChild(a);
+  };
+
+  const downloadFile = async (item: CapsuleFileItem) => {
+    // AI 下载链接直接走地址下载
+    if (item.kind === 'download') {
+      if (!item.url) return;
+      triggerDownload(item.url, item.name);
+      return;
+    }
+    // 上传附件:优先用原始文件对象下载(字节保真),否则由本地缓存内容构造 Blob
+    const file = item.file;
+    if (!file) return;
+    if (file.originalFile) {
+      const url = URL.createObjectURL(file.originalFile);
+      triggerDownload(url, file.originalFile.name || file.name);
+      URL.revokeObjectURL(url);
+      return;
+    }
+    let blob: Blob | null = null;
+    if (file.content != null) {
+      blob = new Blob([file.content], { type: file.type || 'text/plain' });
+    } else if (file.arrayBuffer) {
+      blob = new Blob([file.arrayBuffer], { type: file.type || 'application/octet-stream' });
+    } else if (file.preview) {
+      try {
+        blob = await (await fetch(file.preview)).blob();
+      } catch {
+        blob = null;
+      }
+    }
+    if (!blob) {
+      emit('file-preview', file);
+      return;
+    }
+    const url = URL.createObjectURL(blob);
+    triggerDownload(url, file.name);
+    URL.revokeObjectURL(url);
+  };
+
+  // 点击文件行:统一在右侧面板打开预览(上传附件直接用本地数据,下载链接先拉取内容)
+  const handleFileClick = async (item: CapsuleFileItem) => {
+    const key = fileKey(item);
+    // 该行正在拉取预览内容时忽略重复点击
+    if (previewingKey.value === key) return;
+    if (item.kind === 'upload') {
+      if (item.file) emit('file-preview', item.file);
+      return;
+    }
+    if (!item.url) return;
+    previewingKey.value = key;
+    try {
+      const previewFile = await fetchPreviewFile(item.url, item.name);
+      if (!previewFile) {
+        downloadFile(item);
+        return;
+      }
+      emit('file-preview', previewFile);
+    } catch (err) {
+      console.error('获取文件内容失败:', err);
+      message.error('文件预览失败,请尝试下载查看');
+    } finally {
+      previewingKey.value = null;
+    }
+  };
+
+  // 与消息正文中 markdown 链接的渲染行为一致:新标签页打开
+  const openLink = (link: CapsuleLinkItem) => {
+    if (!link.href) return;
+    window.open(link.href, '_blank', 'noopener,noreferrer');
+  };
+
+  const agentStatusText = (status: CapsuleAgentItem['status']): string => {
+    if (status === 'done') return '已完成';
+    if (status === 'running') return '运行中';
+    if (status === 'waiting') return '等待确认';
+    return '已中断';
+  };
+
+  // 点击智能体条目:交给父组件打开右侧智能体详情面板(身份与思考卡片分组一致)
+  const handleAgentClick = (item: CapsuleAgentItem) => {
+    emit('agent-detail-click', { messageIndex: item.messageIndex, agent: item.agent, agentId: item.agentId });
+  };
+
+  // 定位到该智能体所在的消息(收起弹层后由父组件滚动消息列表)
+  const handleLocateClick = (item: CapsuleAgentItem) => {
+    isOpen.value = false;
+    emit('locate-message', item.messageIndex);
+  };
+</script>
+
+<style scoped lang="less">
+  .resource-capsule {
+    position: relative;
+    flex-shrink: 0;
+    margin-left: 8px;
+    z-index: 10;
+
+    .capsule-btn {
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      height: 36px;
+      padding: 0 12px;
+      border-radius: 18px;
+      background: linear-gradient(135deg, rgba(10, 132, 255, 0.22) 0%, rgba(10, 132, 255, 0.07) 100%);
+      border: 1px solid rgba(10, 132, 255, 0.45);
+      box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
+      cursor: pointer;
+      user-select: none;
+      font-family: inherit;
+      transition:
+        background 0.2s,
+        border-color 0.2s,
+        box-shadow 0.2s,
+        transform 0.1s;
+
+      &:hover {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.32) 0%, rgba(10, 132, 255, 0.14) 100%);
+        border-color: rgba(10, 132, 255, 0.65);
+        box-shadow:
+          0 0 10px rgba(10, 132, 255, 0.25),
+          inset 0 1px 0 rgba(255, 255, 255, 0.08);
+      }
+
+      &:active {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.4) 0%, rgba(10, 132, 255, 0.2) 100%);
+        transform: scale(0.97);
+      }
+
+      &:focus-visible {
+        outline: 1px solid rgba(10, 132, 255, 0.8);
+        outline-offset: 1px;
+      }
+
+      &.open {
+        background: linear-gradient(135deg, rgba(10, 132, 255, 0.32) 0%, rgba(10, 132, 255, 0.14) 100%);
+        border-color: rgba(10, 132, 255, 0.65);
+        box-shadow:
+          0 0 10px rgba(10, 132, 255, 0.25),
+          inset 0 1px 0 rgba(255, 255, 255, 0.08);
+      }
+
+      .capsule-icon {
+        width: 14px;
+        height: 14px;
+        background-image: var(--img-chat-attach-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        filter: drop-shadow(0 0 3px rgba(10, 132, 255, 0.5));
+      }
+
+      .capsule-text {
+        font-size: 12px;
+        color: #e0e6ed;
+        white-space: nowrap;
+      }
+
+      .capsule-count {
+        min-width: 18px;
+        height: 18px;
+        line-height: 18px;
+        padding: 0 5px;
+        border-radius: 9px;
+        background: linear-gradient(135deg, #2f9bff 0%, #0a84ff 100%);
+        box-shadow: 0 1px 4px rgba(10, 132, 255, 0.45);
+        color: #fff;
+        font-size: 11px;
+        text-align: center;
+        flex-shrink: 0;
+      }
+
+      .capsule-arrow {
+        width: 10px;
+        height: 10px;
+        background-image: var(--img-chat-arrow-icon);
+        background-repeat: no-repeat;
+        background-size: 100% 100%;
+        flex-shrink: 0;
+        transition: transform 0.2s;
+      }
+
+      &.open .capsule-arrow {
+        transform: rotate(180deg);
+      }
+    }
+
+    .capsule-popup {
+      position: absolute;
+      top: calc(100% + 8px);
+      right: 0;
+      width: 300px;
+      max-height: 400px;
+      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);
+      z-index: 1000;
+      display: flex;
+      flex-direction: column;
+
+      // 指向胶囊按钮的小箭头(与会话提示条箭头同一画法)
+      &::before {
+        content: '';
+        position: absolute;
+        top: -6px;
+        right: 26px;
+        width: 10px;
+        height: 10px;
+        transform: rotate(45deg);
+        background: #0d2137;
+        border-left: 1px solid #1a4a6f;
+        border-top: 1px solid #1a4a6f;
+      }
+
+      .popup-header {
+        padding: 12px 14px;
+        border-bottom: 1px solid rgba(26, 74, 111, 0.55);
+        background: linear-gradient(180deg, rgba(10, 132, 255, 0.1) 0%, rgba(10, 132, 255, 0.02) 100%);
+        border-radius: 10px 10px 0 0;
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        flex-shrink: 0;
+
+        .popup-title {
+          display: flex;
+          align-items: center;
+          gap: 8px;
+          font-size: 13px;
+          font-weight: 500;
+          letter-spacing: 0.3px;
+          color: #e6edf3;
+
+          .popup-title-icon {
+            width: 15px;
+            height: 15px;
+            background-image: var(--img-chat-attach-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            filter: drop-shadow(0 0 3px rgba(10, 132, 255, 0.5));
+            flex-shrink: 0;
+          }
+        }
+
+        .close-icon {
+          cursor: pointer;
+          font-size: 12px;
+          color: #8b949e;
+          width: 22px;
+          height: 22px;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          border-radius: 50%;
+          transition:
+            color 0.2s,
+            background 0.2s;
+
+          &:hover {
+            color: #e0e6ed;
+            background: rgba(10, 132, 255, 0.2);
+          }
+        }
+      }
+
+      .popup-body {
+        flex: 1;
+        overflow-y: auto;
+        padding: 6px;
+        scrollbar-width: thin;
+        scrollbar-color: rgba(139, 148, 158, 0.4) transparent;
+
+        &::-webkit-scrollbar {
+          width: 6px;
+        }
+
+        &::-webkit-scrollbar-track {
+          background: transparent;
+        }
+
+        &::-webkit-scrollbar-thumb {
+          background: rgba(139, 148, 158, 0.35);
+          border-radius: 3px;
+
+          &:hover {
+            background: rgba(139, 148, 158, 0.6);
+          }
+        }
+
+        .popup-section {
+          & + .popup-section {
+            margin-top: 4px;
+            padding-top: 6px;
+            border-top: 1px solid rgba(63, 80, 106, 0.35);
+          }
+
+          .section-title {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            padding: 6px 10px;
+            border-radius: 6px;
+            font-size: 12px;
+            font-weight: 500;
+            letter-spacing: 0.4px;
+            color: #a9b9c9;
+            cursor: pointer;
+            user-select: none;
+            transition:
+              background 0.2s,
+              color 0.2s;
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.12);
+              color: #e0e6ed;
+            }
+
+            .section-title-text {
+              font-size: inherit;
+              font-weight: inherit;
+              letter-spacing: inherit;
+              color: inherit;
+            }
+
+            .section-count {
+              min-width: 16px;
+              height: 16px;
+              line-height: 16px;
+              padding: 0 5px;
+              border-radius: 8px;
+              background: rgba(10, 132, 255, 0.14);
+              color: #79c0ff;
+              font-size: 11px;
+              text-align: center;
+            }
+
+            .section-arrow {
+              width: 10px;
+              height: 10px;
+              margin-left: auto;
+              background-image: var(--img-chat-arrow-icon);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              opacity: 0.7;
+              transition:
+                transform 0.2s,
+                opacity 0.2s;
+            }
+
+            &:hover .section-arrow {
+              opacity: 1;
+            }
+
+            &.collapsed .section-arrow {
+              transform: rotate(180deg);
+            }
+          }
+        }
+
+        .popup-item {
+          display: flex;
+          align-items: center;
+          gap: 10px;
+          padding: 9px 10px;
+          cursor: pointer;
+          border-radius: 6px;
+          transition: background 0.2s;
+
+          &:hover {
+            background: rgba(10, 132, 255, 0.14);
+          }
+
+          &:active {
+            background: rgba(10, 132, 255, 0.22);
+          }
+
+          .item-icon {
+            width: 26px;
+            height: 26px;
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .row-loading {
+            width: 16px;
+            height: 16px;
+            margin: 5px;
+            border: 2px solid rgba(10, 132, 255, 0.3);
+            border-top-color: #0a84ff;
+            border-radius: 50%;
+            flex-shrink: 0;
+            animation: row-spin 0.8s linear infinite;
+          }
+
+          .item-info {
+            flex: 1;
+            min-width: 0;
+
+            .item-name {
+              font-size: 12px;
+              color: #e0e6ed;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+
+            .item-meta {
+              display: flex;
+              align-items: center;
+              gap: 6px;
+              margin-top: 2px;
+              font-size: 11px;
+              color: #8b949e;
+
+              .item-tag {
+                padding: 0 5px;
+                border-radius: 3px;
+                line-height: 16px;
+
+                &.upload {
+                  color: #79c0ff;
+                  background: rgba(10, 132, 255, 0.15);
+                }
+
+                &.download {
+                  color: #8b949e;
+                  background: rgba(139, 148, 158, 0.15);
+                }
+
+                &.folded {
+                  color: #8b949e;
+                  background: rgba(139, 148, 158, 0.15);
+                }
+              }
+            }
+
+            .link-href {
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+          }
+
+          .agent-icon {
+            width: 26px;
+            height: 26px;
+            background-image: var(--img-chat-agent-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+
+          .agent-status {
+            display: inline-flex;
+            align-items: center;
+            gap: 4px;
+            padding: 0 5px;
+            border-radius: 3px;
+            line-height: 16px;
+
+            .status-dot {
+              width: 6px;
+              height: 6px;
+              border-radius: 50%;
+              background: currentColor;
+            }
+
+            &.running {
+              color: #0a84ff;
+              background: rgba(10, 132, 255, 0.15);
+              box-shadow: 0 0 6px rgba(10, 132, 255, 0.25);
+
+              .status-dot {
+                animation: status-pulse 1.2s ease-in-out infinite;
+              }
+            }
+
+            &.waiting {
+              color: #a371f7;
+              background: rgba(163, 113, 247, 0.12);
+            }
+
+            &.done {
+              color: #3fb950;
+              background: rgba(63, 185, 80, 0.12);
+            }
+
+            &.interrupted {
+              color: #d29922;
+              background: rgba(210, 153, 34, 0.12);
+            }
+          }
+
+          .download-btn {
+            width: 24px;
+            height: 24px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 0;
+            border: none;
+            border-radius: 4px;
+            background: transparent;
+            cursor: pointer;
+            flex-shrink: 0;
+            transition:
+              background 0.2s,
+              transform 0.1s;
+
+            .download-btn-icon {
+              width: 14px;
+              height: 14px;
+              background-image: var(--img-chat-download-icon-btn);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+            }
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.25);
+            }
+
+            &:active {
+              background: rgba(10, 132, 255, 0.4);
+              transform: scale(0.88);
+            }
+
+            &:focus-visible {
+              outline: 1px solid rgba(10, 132, 255, 0.8);
+            }
+          }
+
+          .locate-btn {
+            width: 24px;
+            height: 24px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding: 0;
+            border: none;
+            border-radius: 4px;
+            background: transparent;
+            cursor: pointer;
+            flex-shrink: 0;
+            transition:
+              background 0.2s,
+              transform 0.1s;
+
+            .locate-btn-icon {
+              width: 14px;
+              height: 14px;
+              background-image: var(--img-chat-locate-icon);
+              background-repeat: no-repeat;
+              background-size: 100% 100%;
+              opacity: 0.8;
+              transition: opacity 0.2s;
+            }
+
+            &:hover {
+              background: rgba(10, 132, 255, 0.25);
+
+              .locate-btn-icon {
+                opacity: 1;
+              }
+            }
+
+            &:active {
+              background: rgba(10, 132, 255, 0.4);
+              transform: scale(0.88);
+            }
+
+            &:focus-visible {
+              outline: 1px solid rgba(10, 132, 255, 0.8);
+            }
+          }
+
+          .link-glyph {
+            width: 26px;
+            height: 26px;
+            background-image: var(--img-chat-link-icon);
+            background-repeat: no-repeat;
+            background-size: 14px 14px;
+            background-position: center;
+            flex-shrink: 0;
+            opacity: 0.8;
+            transition: opacity 0.2s;
+          }
+
+          &:hover .link-glyph {
+            opacity: 1;
+          }
+
+          .link-arrow {
+            width: 10px;
+            height: 10px;
+            background-image: var(--img-chat-next-icon);
+            background-repeat: no-repeat;
+            background-size: 100% 100%;
+            flex-shrink: 0;
+          }
+        }
+
+        .popup-empty {
+          padding: 20px 12px;
+          text-align: center;
+
+          .popup-empty-title {
+            font-size: 12px;
+            color: #c9d1d9;
+          }
+
+          .popup-empty-desc {
+            margin-top: 4px;
+            font-size: 11px;
+            line-height: 1.5;
+            color: #8b949e;
+          }
+        }
+      }
+    }
+  }
+
+  .capsule-fade-enter-active,
+  .capsule-fade-leave-active {
+    transition:
+      opacity 0.15s ease,
+      transform 0.15s ease;
+  }
+
+  .capsule-fade-enter-from,
+  .capsule-fade-leave-to {
+    opacity: 0;
+    transform: translateY(-4px);
+  }
+
+  // 分区折叠过渡(与思考卡片折叠动画同一模式)
+  .collapse-enter-active,
+  .collapse-leave-active {
+    transition: all 0.25s ease;
+    overflow: hidden;
+  }
+
+  .collapse-enter-from,
+  .collapse-leave-to {
+    opacity: 0;
+    max-height: 0;
+  }
+
+  .collapse-enter-to,
+  .collapse-leave-from {
+    opacity: 1;
+    max-height: 600px;
+  }
+
+  @keyframes status-pulse {
+    0%,
+    100% {
+      opacity: 1;
+    }
+    50% {
+      opacity: 0.3;
+    }
+  }
+
+  @keyframes row-spin {
+    to {
+      transform: rotate(360deg);
+    }
+  }
+</style>

+ 107 - 0
src/views/ventAI/manageAssistent/components/chatModal/capsuleData.ts

@@ -0,0 +1,107 @@
+import type { Task, Message, CapsuleFileItem, CapsuleLinkItem, CapsuleAgentItem } from './types';
+import { extractMarkdownLinks, groupStepsByAgent } from './utils';
+
+// 胶囊聚合数据构建(纯函数):由主弹框的 computed 接线调用,
+// 与响应式状态管理解耦,便于单独测试
+
+// 聚合文件:当前对话下用户上传的附件 + AI 返回的下载链接(含 Word 报告,按 url 去重)
+export const buildCapsuleFiles = (task: Task | undefined, messages: Message[]): CapsuleFileItem[] => {
+  if (!task) return [];
+  const items: CapsuleFileItem[] = [];
+  const seenUrls = new Set<string>();
+  task.attachedFiles.forEach((f) => {
+    items.push({ kind: 'upload', name: f.name, size: f.size, file: f });
+  });
+  for (const msg of messages) {
+    if (msg.type !== 'ai') continue;
+    msg.downloadFiles?.forEach((d) => {
+      if (!d.url || seenUrls.has(d.url)) return;
+      seenUrls.add(d.url);
+      items.push({ kind: 'download', name: d.filename || '文件', url: d.url });
+    });
+    if (msg.wordDownloadUrl && !seenUrls.has(msg.wordDownloadUrl)) {
+      seenUrls.add(msg.wordDownloadUrl);
+      items.push({ kind: 'download', name: '报告文件.docx', url: msg.wordDownloadUrl });
+    }
+  }
+  if (task.wordUrl && !seenUrls.has(task.wordUrl)) {
+    seenUrls.add(task.wordUrl);
+    items.push({ kind: 'download', name: '报告文件.docx', url: task.wordUrl });
+  }
+  return items;
+};
+
+// 聚合链接:AI 回复文本中的可跳转链接(与消息内 markdown 渲染共用同一套解析,按 href 去重)。
+// 已在"文件"区展示的下载地址不再重复列入链接区;
+// 除精确匹配外,还按"路径+查询+哈希"匹配,覆盖正文相对路径与后端完整地址写法不一致的情况
+export const buildCapsuleLinks = (files: CapsuleFileItem[], messages: Message[]): CapsuleLinkItem[] => {
+  const normalize = (u: string) => u.trim().replace(/&amp;/g, '&');
+  const pathKey = (u: string): string => {
+    try {
+      const parsed = new URL(u, window.location.href);
+      return `${parsed.pathname}${parsed.search}${parsed.hash}`;
+    } catch {
+      return '';
+    }
+  };
+  const seenDownload = new Set<string>();
+  const seenDownloadPath = new Set<string>();
+  for (const item of files) {
+    if (!item.url) continue;
+    const href = normalize(item.url);
+    seenDownload.add(href);
+    const key = pathKey(href);
+    if (key) seenDownloadPath.add(key);
+  }
+  const seenLink = new Set<string>();
+  const links: CapsuleLinkItem[] = [];
+  for (const msg of messages) {
+    if (msg.type !== 'ai' || !msg.content) continue;
+    for (const link of extractMarkdownLinks(msg.content)) {
+      const href = normalize(link.href);
+      if (!href || seenLink.has(href)) continue;
+      const key = pathKey(href);
+      if (seenDownload.has(href) || (key && seenDownloadPath.has(key))) continue;
+      seenLink.add(href);
+      links.push({ title: link.title, href });
+    }
+  }
+  return links;
+};
+
+// 聚合智能体:thinking 卡片中的智能体调用记录,按(消息、智能体)聚合,
+// 每次调用一个条目、状态独立(start→done 生命周期),条目身份与右侧详情面板对齐。
+// 仅收录有实际执行活动的组(含 start/done/工具调用/执行中),纯思考 token 的"系统"组不进入。
+// isAnswerVisible 由调用方传入(多回答折叠组中该消息是否为当前显示的回答)
+export const buildCapsuleAgents = (messages: Message[], isAnswerVisible?: (index: number) => boolean | undefined): CapsuleAgentItem[] => {
+  const agents: CapsuleAgentItem[] = [];
+  for (let i = 0; i < messages.length; i++) {
+    const msg = messages[i];
+    if (msg.type !== 'ai' || !msg.thinkingSteps?.length) continue;
+    // 该消息所属问答轮次:此前用户消息条数 + 1
+    let round = 1;
+    for (let j = 0; j < i; j++) {
+      if (messages[j].type === 'user') round++;
+    }
+    for (const group of groupStepsByAgent(msg.thinkingSteps)) {
+      const hasActivity = group.steps.some((s) => s.type !== 'token');
+      if (!hasActivity) continue;
+      const done = group.steps.some((s) => s.type === 'agent_done');
+      const toolCount = group.steps.filter((s) => s.type === 'tool_call').length;
+      // 状态优先级:完成 > 审批等待(暂停) > 流式中 > 已中断
+      const status: CapsuleAgentItem['status'] = done ? 'done' : msg.isPendingApproval ? 'waiting' : msg.isLoading ? 'running' : 'interrupted';
+      agents.push({
+        messageIndex: i,
+        agent: group.agent,
+        agentId: group.agentId,
+        status,
+        round,
+        toolCount,
+        stepCount: group.steps.length,
+        // 多回答折叠组中被隐藏的历史回答(重新生成产生),供胶囊面板标注
+        hiddenAnswer: isAnswerVisible ? isAnswerVisible(i) === false : false,
+      });
+    }
+  }
+  return agents;
+};

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

@@ -21,11 +21,7 @@ const cleanContent = (content: string): string =>
 export const buildConversationMarkdown = (messages: Message[]): string => {
   const lines: string[] = [];
   for (const msg of messages) {
-    const time = msg.createdAt
-      ? `(${dayjs(msg.createdAt).format('YYYY-MM-DD HH:mm:ss')})`
-      : msg.time
-      ? `(${msg.time})`
-      : '';
+    const time = msg.createdAt ? `(${dayjs(msg.createdAt).format('YYYY-MM-DD HH:mm:ss')})` : msg.time ? `(${msg.time})` : '';
     if (msg.type === 'user') {
       lines.push(`### 用户 ${time}`.trimEnd());
       lines.push('');

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

@@ -11,6 +11,39 @@ export interface AttachedFile {
   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;

+ 84 - 1
src/views/ventAI/manageAssistent/components/chatModal/utils.ts

@@ -1,8 +1,10 @@
-import { marked, Renderer } from 'marked';
+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);
@@ -70,6 +72,48 @@ export const renderMarkdown = (
   return icons ? wrapTables(html, icons) : 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 }> = [];
+  try {
+    const tokens = marked.lexer(text);
+    walkTokens(tokens, (token) => {
+      if (token.type === 'link') {
+        const linkToken = token as Tokens.Link;
+        const href = linkToken.href || '';
+        if (href) {
+          links.push({ title: linkToken.text || href, href });
+        }
+      }
+    });
+  } catch {
+    // 文本解析异常时返回已收集到的部分链接
+  }
+  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);
@@ -211,3 +255,42 @@ export const getFileIconVar = (filename: string): string => {
   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;
+};

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.