Эх сурвалжийг харах

[Mod 0000]AI助手增加对话中的附件上传回显

wangkeyi 1 өдөр өмнө
parent
commit
b77b45234d

+ 70 - 0
src/views/ventAI/manageAssistent/api/attachment.ts

@@ -0,0 +1,70 @@
+// api/attachment.ts:会话附件历史与附件下载。
+//
+// 会话附件查询用于刷新/切换会话后的历史回显:后端按「当前用户 + 会话」返回附件元数据,
+// message_id 指向上传该附件的用户消息;下载接口需要带令牌,不能直接用 <a href>。
+import { defHttp } from '/@/utils/http/axios/index';
+import { getToken } from '/@/utils/auth/index';
+import { throwStreamHttpError } from './errors';
+
+enum Api {
+  sessions = '/ventAI/api/sessions',
+  files = '/ventAI/api/files',
+}
+
+/** GET /sessions/{session_id}/attachments 的附件项 */
+export interface SessionAttachment {
+  file_id: string;
+  /** 关联的用户消息主键;null 表示上传成功但用户消息未落库 */
+  message_id: number | null;
+  filename: string;
+  size_bytes: number;
+  /** 上传时的 Content-Type,可能为空 */
+  content_type?: string;
+  created_at: string;
+  /** 下载地址(后端返回 /api/files/...,需带令牌) */
+  download_url: string;
+}
+
+export interface SessionAttachmentsResponse {
+  session_id: string;
+  attachments: SessionAttachment[];
+}
+
+/**
+ * 查询会话附件(历史回显用)。
+ *
+ * errorMessageMode: 'none':旧环境尚未上线该接口时,不希望每次切换会话都弹一条全局红错;
+ * 失败由调用方按非阻断方式处理。
+ */
+export const getSessionAttachments = (sessionId: string): Promise<SessionAttachmentsResponse> =>
+  defHttp.get<SessionAttachmentsResponse>(
+    { url: `${Api.sessions}/${sessionId}/attachments` },
+    { isTransformResponse: false, errorMessageMode: 'none' }
+  );
+
+/**
+ * 把后端返回的下载地址归一为前端代理前缀。
+ * 文档中的 download_url 是 /api/files/{id},而基座统一通过 /ventAI 前缀访问 TfAgents。
+ */
+const normalizeFileUrl = (fileId: string, downloadUrl?: string): string => {
+  const raw = (downloadUrl || '').trim();
+  if (raw.startsWith('/api/')) return `/ventAI${raw}`;
+  if (raw) return raw;
+  return `${Api.files}/${encodeURIComponent(fileId)}`;
+};
+
+/**
+ * 带令牌拉取附件二进制。下载与预览共用;非 2xx 复用统一错误解码(404 "文件不存在" 等)。
+ */
+export const fetchAttachmentBlob = async (fileId: string, downloadUrl?: string): Promise<Blob> => {
+  const response = await fetch(normalizeFileUrl(fileId, downloadUrl), {
+    method: 'GET',
+    headers: {
+      'X-Access-Token': getToken(),
+    },
+  });
+  if (!response.ok) {
+    await throwStreamHttpError(response);
+  }
+  return response.blob();
+};

+ 1 - 0
src/views/ventAI/manageAssistent/api/index.ts

@@ -5,6 +5,7 @@ export * from './sse';
 export * from './streamPost';
 export * from './chat';
 export * from './session';
+export * from './attachment';
 export * from './user';
 export * from './usage';
 export * from './model';

+ 52 - 39
src/views/ventAI/manageAssistent/components/AiAssistantModal.vue

@@ -65,21 +65,24 @@
             @download-word="downloadWordReport"
             @export-md="handleExportMarkdown"
             @export-word="handleExportWord"
-            @file-preview="openFilePreview"
+            @file-preview="handleAttachmentPreview"
+            @download-attachment="handleAttachmentDownload"
             @agent-detail-click="handleAgentDetailClick"
             @locate-message="handleLocateMessage"
             @open-web="openWebTab"
           />
 
           <!-- 附件列表面板 -->
-          <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="openFilePreview" />
+          <FileListPanel :visible="showFileList" :files="getCurrentTaskFiles()" @close="showFileList = false" @file-click="handleAttachmentPreview" />
 
           <!-- 消息列表 -->
           <ChatMessages
             ref="chatMessagesRef"
             :messages="messages"
             :history-loading="isCurrentHistoryLoading"
-            @file-preview="openFilePreview"
+            :orphan-attachments="currentTask?.orphanAttachments || []"
+            @file-preview="handleAttachmentPreview"
+            @download-attachment="handleAttachmentDownload"
             @download-word="downloadWordFile"
             @open-file-preview="handleOpenFilePreview"
             @ask-user-submit="handleAskUserSubmit"
@@ -190,7 +193,7 @@
   import SchedulePanel from '/@/views/ventAI/manageAssistent/components/panels/SchedulePanel.vue';
   import RightPanelTabs from '/@/views/ventAI/manageAssistent/components/panels/RightPanelTabs.vue';
   import { getScheduleList } from '/@/views/ventAI/manageAssistent/api';
-  import type { Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
+  import type { AttachedFile, Message, ScheduleTask, Task } from '/@/views/ventAI/manageAssistent/types/index';
   import { buildCapsuleFiles, buildCapsuleLinks, buildCapsuleAgents } from '/@/views/ventAI/manageAssistent/lib/capsule';
   import { useChatStream } from '../hooks/useChatStream';
   import { useChatTasks } from '../hooks/useChatTasks';
@@ -203,6 +206,7 @@
   import { useFileDrop } from '../hooks/useFileDrop';
   import { useChatQuota } from '../hooks/useChatQuota';
   import { useChatExport } from '../hooks/useChatExport';
+  import { useChatAttachments } from '../hooks/useChatAttachments';
 
   interface Props {
     visible: boolean;
@@ -295,6 +299,10 @@
     defaultFullscreen: !!props.defaultFullscreen,
   });
 
+  // —— 会话附件历史:列表按会话拉取,附件内容在预览/下载时按需取回 ——
+  const { loadHistoryAttachments, handleAttachmentPreview: previewAttachment, handleAttachmentDownload } = useChatAttachments();
+  const handleAttachmentPreview = (file: AttachedFile) => previewAttachment(file, openFilePreview);
+
   // —— 拖拽/居中职责已下沉到 useChatDrag(与面板全屏共用 lib/modalWrap) ——
   const drag = useChatDrag({
     getIsFullscreen: () => isFullscreen.value,
@@ -380,6 +388,17 @@
     if (nextTask) switchTask(nextTask.id);
   };
 
+  // 消息被删除后,附件文件仍保留在服务端(message_id 变为 null);
+  // 这里把本地附件立即转入兜底列表,避免要刷新后才重新出现。
+  const moveAttachmentsToOrphans = (task: Task | undefined, files: AttachedFile[]) => {
+    if (!task || !files.length) return;
+    const merged = [...(task.orphanAttachments || [])];
+    for (const file of files) {
+      if (!merged.some((item) => item.id === file.id)) merged.push(file);
+    }
+    task.orphanAttachments = merged;
+  };
+
   // 删除单条消息:先删数据库行,后端再清空该会话内存线程,下轮从数据库重建上下文
   const handleDeleteMessage = (msg: Message) => {
     if (!msg.id) return;
@@ -407,6 +426,7 @@
           message.success('消息已删除');
           if (ownerTask) {
             ownerTask.messages = ownerTask.messages.filter((m) => m !== msg);
+            moveAttachmentsToOrphans(ownerTask, [msg.attachedFile, ...(msg.attachedFiles || [])].filter(Boolean) as AttachedFile[]);
           }
         } catch {
           message.error('删除失败');
@@ -566,6 +586,30 @@
     return currentTask?.sessionId || '';
   };
 
+  /**
+   * 拉取单个会话的历史消息与附件。
+   * 流式中的任务不拉历史(避免覆盖在途消息);附件加载失败不阻断消息展示。
+   */
+  const loadTaskHistory = async (task: Task) => {
+    const sessionId = task.sessionId;
+    if (!sessionId || historyLoadedTasks.has(task.id) || task.isStreaming) return;
+
+    setHistoryLoading(task.id, true);
+    try {
+      const res = await getDetail(sessionId);
+      const history = Array.isArray(res?.messages) ? transformHistoryToMessages(res.messages) : [];
+      task.messages = history;
+      await loadHistoryAttachments(task, sessionId, history);
+      historyLoadedTasks.add(task.id);
+    } catch (error) {
+      // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
+      console.error('加载历史会话失败:', error);
+      message.error('加载历史会话失败,请稍后重试');
+    } finally {
+      setHistoryLoading(task.id, false);
+    }
+  };
+
   const switchTask = async (taskId: string) => {
     activePanel.value = 'chat';
 
@@ -579,26 +623,8 @@
     showFileList.value = false;
 
     if (newTask.sessionId && !historyLoadedTasks.has(taskId)) {
-      // 流式中的任务不拉历史(避免覆盖在途消息),但必须继续走完下面的初始化:
-      // currentTaskId 已经切过来了,提前 return 会让上下文用量/模式/进度条/滚动全部停在上一个任务上。
-      if (!newTask.isStreaming) {
-        // 标记加载中:消息区据 isCurrentHistoryLoading 显示加载态,而不是先闪一屏空会话
-        setHistoryLoading(taskId, true);
-        try {
-          const res = await getDetail(newTask.sessionId);
-          const messages = res?.messages;
-          if (Array.isArray(messages)) {
-            newTask.messages = transformHistoryToMessages(messages);
-          }
-          historyLoadedTasks.add(taskId);
-        } catch (error) {
-          // 拉不到历史时界面会是空白会话,容易被误认为「对话丢了」,必须给出提示
-          console.error('加载历史会话失败:', error);
-          message.error('加载历史会话失败,请稍后重试');
-        } finally {
-          setHistoryLoading(taskId, false);
-        }
-      }
+      // 流式中的任务由 loadTaskHistory 内部跳过,但下面的上下文/模式/进度条初始化必须继续执行
+      await loadTaskHistory(newTask);
     }
 
     if (newTask.sessionId) {
@@ -889,22 +915,9 @@
 
         currentTaskId.value = targetTask.id;
 
-        // 如果是历史任务,加载其消息
+        // 如果是历史任务,加载其消息与附件
         if (targetTask.sessionId && !historyLoadedTasks.has(targetTask.id)) {
-          setHistoryLoading(targetTask.id, true);
-          try {
-            const res = await getDetail(targetTask.sessionId);
-            const messages = res?.messages;
-            if (Array.isArray(messages)) {
-              targetTask.messages = transformHistoryToMessages(messages);
-            }
-            historyLoadedTasks.add(targetTask.id);
-          } catch (error) {
-            console.error('加载历史会话失败:', error);
-            message.error('加载历史会话失败,请稍后重试');
-          } finally {
-            setHistoryLoading(targetTask.id, false);
-          }
+          await loadTaskHistory(targetTask);
         }
 
         if (targetTask.sessionId) {

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

@@ -40,6 +40,7 @@
       :links="capsuleLinks"
       :agents="capsuleAgents"
       @file-preview="emit('file-preview', $event)"
+      @download-attachment="emit('download-attachment', $event)"
       @agent-detail-click="emit('agent-detail-click', $event)"
       @locate-message="emit('locate-message', $event)"
       @open-web="emit('open-web', $event)"
@@ -99,6 +100,7 @@
     'export-word': [];
     // 胶囊面板中点击上传附件,交给父组件打开右侧预览
     'file-preview': [file: AttachedFile];
+    'download-attachment': [file: AttachedFile];
     // 胶囊面板中点击智能体条目,交给父组件打开右侧智能体详情
     'agent-detail-click': [data: { messageIndex: number; agent: string; agentId?: string }];
     // 胶囊面板中点击"定位到消息",交给父组件滚动消息列表

+ 157 - 53
src/views/ventAI/manageAssistent/components/chat/ChatMessages.vue

@@ -15,8 +15,8 @@
       <div v-if="messages.length === 0 && historyLoading" class="history-loading">
         <a-spin size="large" />
       </div>
-      <!-- 新对话缺省图 -->
-      <div v-else-if="messages.length === 0" class="welcome-empty">
+      <!-- 新对话缺省图:有兜底附件时不显示,避免附件被居中的欢迎图盖住 -->
+      <div v-else-if="messages.length === 0 && !orphanAttachments?.length" class="welcome-empty">
         <img :src="getCssUrl('--img-chat-welcome-img')" class="welcome-img" />
       </div>
       <div
@@ -127,12 +127,24 @@
                 v-html="renderMarkdownCached(message.content, tableIcons)"
                 @click="handleMessageTextClick($event, index)"
               ></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
+                v-for="file in getMessageAttachments(message)"
+                :key="file.id"
+                class="attached-file-card"
+                @click="emit('file-preview', file)"
+              >
+                <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.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 class="file-name-card">{{ file.name }}</div>
+                  <div class="file-size-card">{{ formatFileSize(file.size) }}</div>
                 </div>
+                <button
+                  type="button"
+                  class="file-download-btn"
+                  title="下载附件"
+                  aria-label="下载附件"
+                  @click.stop="emit('download-attachment', file)"
+                ></button>
               </div>
             </template>
           </div>
@@ -147,6 +159,30 @@
           />
         </div>
       </div>
+
+      <!-- 无法按 message_id 挂到具体消息的历史附件:兜底展示 -->
+      <div v-if="orphanAttachments?.length" class="orphan-attachments">
+        <div class="orphan-attachments-title">其他附件 <span>{{ orphanAttachments.length }}</span></div>
+        <div
+          v-for="file in orphanAttachments"
+          :key="file.id"
+          class="attached-file-card orphan-file-card"
+          @click="emit('file-preview', file)"
+        >
+          <div class="file-icon-card" :style="{ backgroundImage: getFileIconVar(file.name) }"></div>
+          <div class="file-info-card">
+            <div class="file-name-card">{{ file.name }}</div>
+            <div class="file-size-card">{{ formatFileSize(file.size) }}</div>
+          </div>
+          <button
+            type="button"
+            class="file-download-btn"
+            title="下载附件"
+            aria-label="下载附件"
+            @click.stop="emit('download-attachment', file)"
+          ></button>
+        </div>
+      </div>
     </div>
 
     <!-- 回到底部按钮 -->
@@ -189,10 +225,13 @@
     messages: Message[];
     /** 当前任务的历史是否正在拉取:为真且暂无消息时显示加载态,而不是空会话缺省图 */
     historyLoading?: boolean;
+    /** 历史附件中无法按 message_id 挂到具体消息的条目,在消息区底部兜底展示 */
+    orphanAttachments?: AttachedFile[];
   }>();
 
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-attachment', file: AttachedFile): void;
     (e: 'download-word', url: string): void;
     (e: 'open-file-preview', file: AttachedFile): void;
     (e: 'ask-user-submit', answers: string[]): void;
@@ -209,6 +248,16 @@
   const editingKey = ref('');
   const editDraft = ref('');
 
+  // 用户消息的附件:本地刚发送的在 attachedFile,历史回显的在 attachedFiles;去重后统一渲染
+  const getMessageAttachments = (message: Message): AttachedFile[] => {
+    const files: AttachedFile[] = [];
+    if (message.attachedFile) files.push(message.attachedFile);
+    for (const file of message.attachedFiles || []) {
+      if (!files.some((f) => f.id === file.id)) files.push(file);
+    }
+    return files;
+  };
+
   // ===== 多回答折叠:重新生成产生的连续多条 AI 消息在视觉上合并为一条,可通过 prev/next 切换查看 =====
   interface AnswerGroup {
     indices: number[];
@@ -1092,53 +1141,6 @@
             }
           }
 
-          .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;
-              }
-            }
-          }
         }
       }
 
@@ -1223,6 +1225,108 @@
     }
   }
 
+  /* 用户消息附件卡与底部兜底附件共用 */
+  .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;
+      }
+    }
+
+    .file-download-btn {
+      flex-shrink: 0;
+      width: 26px;
+      height: 26px;
+      padding: 0;
+      border: none;
+      border-radius: 4px;
+      background-color: transparent;
+      background-image: var(--img-chat-download-icon);
+      background-repeat: no-repeat;
+      background-size: 14px 14px;
+      background-position: center;
+      opacity: 0.55;
+      cursor: pointer;
+      transition:
+        opacity 0.2s,
+        background-color 0.2s;
+
+      &:hover {
+        opacity: 1;
+        background-color: rgba(255, 255, 255, 0.12);
+      }
+
+      &:focus-visible {
+        outline: 1px solid #5cb8ff;
+        outline-offset: 1px;
+      }
+    }
+  }
+
+  /* message_id 为空或未匹配到消息的历史附件,统一在消息区底部兜底展示 */
+  .orphan-attachments {
+    margin-top: 18px;
+    padding-top: 12px;
+    border-top: 1px dashed rgba(63, 80, 106, 0.35);
+
+    .orphan-attachments-title {
+      margin-bottom: 8px;
+      font-size: 12px;
+      color: #8b949e;
+
+      span {
+        margin-left: 4px;
+        color: #6a7a8a;
+      }
+    }
+
+    .orphan-file-card {
+      margin-top: 0;
+      margin-bottom: 8px;
+      background: rgba(10, 132, 255, 0.08);
+    }
+  }
+
   .message-edit-textarea {
     width: 100%;
     min-width: 500px;

+ 6 - 0
src/views/ventAI/manageAssistent/components/resource/ResourceCapsule.vue

@@ -153,6 +153,7 @@
 
   const emit = defineEmits<{
     (e: 'file-preview', file: AttachedFile): void;
+    (e: 'download-attachment', file: AttachedFile): void;
     (e: 'agent-detail-click', data: { messageIndex: number; agent: string; agentId?: string }): void;
     (e: 'locate-message', messageIndex: number): void;
     (e: 'open-web', payload: { url: string; title?: string }): void;
@@ -234,6 +235,11 @@
     // 上传附件:优先用原始文件对象下载(字节保真),否则由本地缓存内容构造 Blob
     const file = item.file;
     if (!file) return;
+    // 历史回显附件只有服务端 ID,下载需要带令牌,交给父组件统一处理
+    if (file.fileId) {
+      emit('download-attachment', file);
+      return;
+    }
     if (file.originalFile) {
       const url = URL.createObjectURL(file.originalFile);
       triggerDownload(url, file.originalFile.name || file.name);

+ 163 - 0
src/views/ventAI/manageAssistent/hooks/useChatAttachments.ts

@@ -0,0 +1,163 @@
+import { message } from 'ant-design-vue';
+import { fetchAttachmentBlob, getSessionAttachments } from '/@/views/ventAI/manageAssistent/api';
+import type { SessionAttachment } from '/@/views/ventAI/manageAssistent/api';
+import { isCsvFile, isDwgFile, isDxfFile, isExcelFile, isTextFile, isWordFile } from '/@/views/ventAI/manageAssistent/lib';
+import type { AttachedFile, Message, Task } from '/@/views/ventAI/manageAssistent/types/index';
+
+/**
+ * 会话附件历史:加载后按 message_id 挂回用户消息,无法匹配的进入兜底列表;
+ * 附件内容按需下载(预览时才取二进制),下载/预览都带令牌走附件下载接口。
+ */
+
+const toAttachedFile = (item: SessionAttachment): AttachedFile => ({
+  id: item.file_id,
+  fileId: item.file_id,
+  messageId: item.message_id,
+  name: item.filename,
+  size: item.size_bytes,
+  type: item.content_type || '',
+  uploadTime: item.created_at,
+  createdAt: item.created_at,
+  downloadUrl: item.download_url,
+});
+
+const fileKey = (file: AttachedFile): string => file.fileId || file.id;
+
+const mergeAttachedFiles = (existing: AttachedFile[], incoming: AttachedFile[]): AttachedFile[] => {
+  const map = new Map<string, AttachedFile>();
+  for (const file of [...existing, ...incoming]) {
+    map.set(fileKey(file), file);
+  }
+  return Array.from(map.values());
+};
+
+const saveBlob = (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
+  setTimeout(() => URL.revokeObjectURL(url), 0);
+};
+
+const hasContent = (file: AttachedFile): boolean =>
+  file.content !== undefined || file.arrayBuffer !== undefined || file.preview !== undefined || !!file.originalFile;
+
+/** 历史附件没有原始内容,首次预览时按文件类型取回并缓存到附件对象上 */
+const ensureAttachmentContent = async (file: AttachedFile): Promise<AttachedFile> => {
+  if (hasContent(file)) return file;
+  if (!file.fileId) throw new Error('附件缺少下载标识');
+
+  const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
+  if (isTextFile(file.name) || isCsvFile(file.name) || isDxfFile(file.name)) {
+    file.content = await blob.text();
+  } else if (isWordFile(file.name) || isExcelFile(file.name) || isDwgFile(file.name)) {
+    file.arrayBuffer = await blob.arrayBuffer();
+  } else {
+    file.preview = URL.createObjectURL(blob);
+  }
+  file.originalFile = new File([blob], file.name, {
+    type: file.type || blob.type || 'application/octet-stream',
+  });
+  return file;
+};
+
+export const useChatAttachments = () => {
+  /**
+   * 拉取会话附件并挂到历史消息上。
+   * - message_id 能匹配到用户消息:挂到该消息的 attachedFiles;
+   * - message_id 为 null 或消息已合并/跳过:进入 task.orphanAttachments,由消息区底部兜底展示。
+   * 附件接口失败不阻断历史消息展示(旧环境可能还没有这个接口)。
+   */
+  const loadHistoryAttachments = async (task: Task, sessionId: string, messages: Message[]): Promise<AttachedFile[]> => {
+    try {
+      const res = await getSessionAttachments(sessionId);
+      const attachments = (res?.attachments || []).map(toAttachedFile);
+      if (!attachments.length) {
+        task.orphanAttachments = [];
+        return [];
+      }
+
+      const userMessageById = new Map<number, Message>();
+      for (const msg of messages) {
+        if (msg.type === 'user' && msg.id != null) userMessageById.set(msg.id, msg);
+      }
+
+      const orphans: AttachedFile[] = [];
+      for (const file of attachments) {
+        const target = file.messageId != null ? userMessageById.get(file.messageId) : undefined;
+        if (target) {
+          if (!target.attachedFiles) target.attachedFiles = [];
+          if (!target.attachedFiles.some((f) => fileKey(f) === fileKey(file))) {
+            target.attachedFiles.push(file);
+          }
+        } else {
+          orphans.push(file);
+        }
+      }
+
+      task.orphanAttachments = orphans;
+      task.attachedFiles = mergeAttachedFiles(task.attachedFiles, attachments);
+      return attachments;
+    } catch (e) {
+      // 历史消息是主内容,附件是增强信息:失败只记录,不打断会话打开
+      console.error('加载会话附件失败:', e);
+      return [];
+    }
+  };
+
+  const handleAttachmentPreview = async (file: AttachedFile, openPreview: (target: AttachedFile) => void) => {
+    if (hasContent(file)) {
+      openPreview(file);
+      return;
+    }
+    if (!file.fileId) {
+      message.warning('该附件暂不支持预览');
+      return;
+    }
+
+    const closeLoading = message.loading('正在加载附件…', 0);
+    try {
+      await ensureAttachmentContent(file);
+      openPreview(file);
+    } catch (e) {
+      console.error('加载附件失败:', e);
+      message.error((e as Error)?.message || '附件加载失败');
+    } finally {
+      closeLoading();
+    }
+  };
+
+  const handleAttachmentDownload = async (file: AttachedFile) => {
+    try {
+      if (file.fileId) {
+        const closeLoading = message.loading('正在下载附件…', 0);
+        try {
+          const blob = await fetchAttachmentBlob(file.fileId, file.downloadUrl);
+          saveBlob(blob, file.name);
+        } finally {
+          closeLoading();
+        }
+        return;
+      }
+      if (file.originalFile) {
+        saveBlob(file.originalFile, file.name);
+        return;
+      }
+      if (file.preview?.startsWith('blob:') || file.preview?.startsWith('data:')) {
+        const blob = await (await fetch(file.preview)).blob();
+        saveBlob(blob, file.name);
+        return;
+      }
+      message.warning('该附件暂不支持下载');
+    } catch (e) {
+      console.error('下载附件失败:', e);
+      message.error((e as Error)?.message || '附件下载失败');
+    }
+  };
+
+  return { loadHistoryAttachments, handleAttachmentPreview, handleAttachmentDownload };
+};

+ 1 - 1
src/views/ventAI/manageAssistent/hooks/useChatPanels.ts

@@ -168,7 +168,7 @@ export const useChatPanels = (deps: ChatPanelsDeps) => {
   const openFilePreview = (file: AttachedFile) => {
     openFileTab(file, false);
 
-    const msgIndex = getMessages().findIndex((m) => m.attachedFile?.id === file.id);
+    const msgIndex = getMessages().findIndex((m) => m.attachedFile?.id === file.id || (m.attachedFiles || []).some((item) => item.id === file.id));
     if (msgIndex !== -1) {
       nextTick(() => {
         getChatMessages()?.scrollToMessage(msgIndex, 'center');

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

@@ -460,7 +460,23 @@ export const useChatSend = (deps: ChatSendDeps) => {
       }
     }
     // 本地截断到编辑位置(原地截断,始终操作同一个任务的消息数组)
+    const removedMessages = msgs.slice(index);
     msgs.splice(index);
+
+    // 被截断消息上的附件文件仍留在服务端(message_id 变 null)。
+    // 被编辑的这条消息的本地附件会随新消息重发,不转兜底;其余附件立即转入任务兜底列表,
+    // 避免要刷新页面才重新出现。
+    const detachedFiles = removedMessages
+      .flatMap((m) => [m.attachedFile, ...(m.attachedFiles || [])])
+      .filter((file): file is AttachedFile => !!file && file.id !== attachedFile?.id);
+    if (detachedFiles.length) {
+      const orphans = [...(task.orphanAttachments || [])];
+      for (const file of detachedFiles) {
+        if (!orphans.some((item) => item.id === file.id)) orphans.push(file);
+      }
+      task.orphanAttachments = orphans;
+    }
+
     const now = dayjs();
     const userMsg: Message = {
       type: 'user',

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

@@ -62,6 +62,8 @@ export interface Message {
   isPendingApproval?: boolean;
   pendingApprovalData?: PendingApprovalData;
   attachedFile?: AttachedFile;
+  /** 历史回显的附件(同一消息可挂多个;本地刚发送的附件仍走 attachedFile) */
+  attachedFiles?: AttachedFile[];
   agentAnswers?: AgentAnswer[];
   thinkingSteps?: ThinkingStep[];
   sessionId?: string;
@@ -98,6 +100,8 @@ export interface Task {
   isStreaming?: boolean;
   // 流式期间从会话列表归并得到的后端会话 id(流结束前 sessionId 尚未返回)
   pendingSessionId?: string;
+  // 历史附件中无法按 message_id 挂到具体消息的条目(message_id 为 null 或消息未能匹配)
+  orphanAttachments?: AttachedFile[];
   // 右侧辅助面板标签页状态(按任务维度保存)
   rightPanels?: RightPanelState;
 }

+ 8 - 1
src/views/ventAI/manageAssistent/types/resource.ts

@@ -8,7 +8,14 @@ export interface AttachedFile {
   content?: string;
   arrayBuffer?: ArrayBuffer;
   uploadTime: string;
-  messageId?: string;
+  /** 服务端附件 ID(历史回显时用于预览/下载) */
+  fileId?: string;
+  /** 关联的用户消息主键;null 表示附件未关联到已落库的消息 */
+  messageId?: number | null;
+  /** 服务端返回的下载地址(/api/files/...) */
+  downloadUrl?: string;
+  /** 服务端记录的上传时间 */
+  createdAt?: string;
   originalFile?: File;
 }