Quellcode durchsuchen

feat:调整ai助手文件下载按钮放到内容中

Jerry Liang vor 1 Monat
Ursprung
Commit
81925d3fd3

+ 15 - 2
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -158,8 +158,8 @@ function formatSize(bytes: number): string {
         </NTag>
       </div>
 
-      <!-- 文件下载按钮 -->
-      <div v-if="message.files?.length" class="my-2 space-y-1">
+      <!-- 文件下载按钮(旧消息回退:无 segments 时集中展示在顶部;有 segments 时按标记位置内联渲染) -->
+      <div v-if="!useSegments && message.files?.length" class="my-2 space-y-1">
         <div v-for="file in message.files" :key="file.key" class="flex items-center gap-2">
           <NButton
             size="tiny"
@@ -198,6 +198,19 @@ function formatSize(bytes: number): string {
               <span class="ml-1 opacity-70">· {{ toolStatusLabel(seg.tool.status) }}</span>
             </NTag>
           </div>
+          <div v-else-if="seg.kind === 'file'" class="my-2">
+            <NButton
+              size="tiny"
+              type="primary"
+              @click="downloadFile(seg.file.key, seg.file.fileName)"
+            >
+              <template #icon>
+                <span class="i-ph:download-simple" />
+              </template>
+              {{ seg.file.fileName }}
+              <span class="ml-1 text-xs opacity-70">({{ formatSize(seg.file.size) }})</span>
+            </NButton>
+          </div>
         </template>
       </div>
       <!-- 旧消息回退:整体文本渲染 -->

+ 8 - 2
src/layout/header/Actions/AiChat/interface.ts

@@ -29,8 +29,14 @@ export interface ToolSegment {
   tool: ToolCallInfo
 }
 
-// 消息片段:按到达顺序交错的文本与工具调用
-export type MessageSegment = TextSegment | ToolSegment
+// 文件下载片段:内联展示的文件下载按钮(出现在 $$FILE_DOWNLOAD$$ 标记的位置)
+export interface FileSegment {
+  kind: 'file'
+  file: FileInfo
+}
+
+// 消息片段:按到达顺序交错的文本 / 工具调用 / 文件下载
+export type MessageSegment = TextSegment | ToolSegment | FileSegment
 
 export interface PermissionRequest {
   requestId: string

+ 122 - 57
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -1,5 +1,5 @@
 import { ref, toRaw } from 'vue'
-import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile } from './interface'
+import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile, MessageSegment } from './interface'
 import { useUserStore } from '@/stores/user'
 import { chatDB } from './chatDB'
 import {
@@ -21,6 +21,42 @@ export interface SessionLoadDoneInfo {
   suggestNewSession: boolean
 }
 
+// 文件下载标记:$$FILE_DOWNLOAD:{...}$$(模块作用域,避免每次调用重新编译)
+const FILE_MARKER_RE = /\$\$FILE_DOWNLOAD:([\s\S]*?)\$\$/g
+
+// 解析出的行内文件片段(用于内联下载按钮)
+interface ParsedFileChunk {
+  kind: 'file'
+  file: { key: string, fileName: string, size: number }
+}
+// 解析出的文本片段
+interface ParsedTextChunk {
+  kind: 'text'
+  content: string
+}
+type ParsedChunk = ParsedTextChunk | ParsedFileChunk
+
+// 按 $$FILE_DOWNLOAD$$ 标记把文本切分为「文本 / 文件」有序片段。
+// 非法/残缺标记会被丢弃(既不产生文件片段,也不保留标记文本)。
+function splitByFileMarker(text: string): ParsedChunk[] {
+  const chunks: ParsedChunk[] = []
+  let lastIndex = 0
+  for (const m of text.matchAll(FILE_MARKER_RE)) {
+    const idx = m.index ?? 0
+    const before = text.slice(lastIndex, idx)
+    if (before) chunks.push({ kind: 'text', content: before })
+    try {
+      const fileData = JSON.parse(m[1])
+      chunks.push({ kind: 'file', file: { key: fileData.key, fileName: fileData.fileName, size: fileData.size } })
+    }
+    catch { /* ignore malformed / partial marker */ }
+    lastIndex = idx + m[0].length
+  }
+  const rest = text.slice(lastIndex)
+  if (rest) chunks.push({ kind: 'text', content: rest })
+  return chunks
+}
+
 export function useAiChat() {
   const messages = ref<ChatMessage[]>([])
   const isStreaming = ref(false)
@@ -267,6 +303,8 @@ export function useAiChat() {
               content: stripContextPrefix(payload.content, payload.role),
               timestamp: Date.now() - 1000 + messageIndex,
             }
+            // 解析历史消息中的下载标记,生成内联下载按钮
+            hydrateReplayMessage(chatMsg)
             messages.value.push(chatMsg)
             messageIndex++
           }
@@ -313,6 +351,7 @@ export function useAiChat() {
               content: stripContextPrefix(payload.content, payload.role),
               timestamp: Date.now() - 1000 + messageIndex,
             }
+            hydrateReplayMessage(chatMsg)
             messages.value.push(chatMsg)
           }
           else if (eventType === 'done') {
@@ -548,6 +587,20 @@ export function useAiChat() {
     }
   }
 
+  // 将一段文本追加到 segments:末尾是文本片段则合并,否则新建文本片段。
+  // file/tool 片段会“打断”文本合并,从而让后续文本另起片段,保持先后顺序。
+  function appendTextSegment(aiMessage: ChatMessage, text: string) {
+    if (!text) return
+    if (!aiMessage.segments) aiMessage.segments = []
+    const last = aiMessage.segments.at(-1)
+    if (last && last.kind === 'text') {
+      last.content += text
+    }
+    else {
+      aiMessage.segments.push({ kind: 'text', content: text })
+    }
+  }
+
   function parseSSEEvent(raw: string, aiMessage: ChatMessage) {
     const lines = raw.split('\n')
     let eventType = ''
@@ -569,31 +622,22 @@ export function useAiChat() {
       switch (eventType || payload.type) {
         case 'text': {
           const text = payload.content || ''
-          // 实时检测 $$FILE_DOWNLOAD:{...}$$ 标记
-          const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
-          let match
-          let cleanText = text
-          while ((match = fileRegex.exec(text)) !== null) {
-            try {
-              const fileData = JSON.parse(match[1])
-              if (!aiMessage.files) aiMessage.files = []
-              aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
-            }
-            catch { /* ignore malformed */ }
-            cleanText = cleanText.replace(match[0], '')
-          }
-          aiMessage.content += cleanText
-          // 同步写入有序 segments:追加到末尾的文本片段,若末尾不是文本片段则新建
-          if (cleanText) {
-            if (!aiMessage.segments) aiMessage.segments = []
-            const last = aiMessage.segments.at(-1)
-            if (last && last.kind === 'text') {
-              last.content += cleanText
+          // 按 $$FILE_DOWNLOAD:{...}$$ 标记位置切分文本,保持「文本 / 文件按钮」的先后顺序
+          if (!aiMessage.segments) aiMessage.segments = []
+          let cleanText = ''
+          for (const chunk of splitByFileMarker(text)) {
+            if (chunk.kind === 'text') {
+              cleanText += chunk.content
+              appendTextSegment(aiMessage, chunk.content)
             }
             else {
-              aiMessage.segments.push({ kind: 'text', content: cleanText })
+              // 内联插入 file segment,同时写入 files 供旧渲染回退(按 key 去重)
+              if (!aiMessage.files) aiMessage.files = []
+              if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
+              aiMessage.segments.push({ kind: 'file', file: chunk.file })
             }
           }
+          aiMessage.content += cleanText
           break
         }
         case 'tool_call':
@@ -681,16 +725,9 @@ export function useAiChat() {
         case 'error': {
           const errText = `\n\n[错误: ${payload.content || '未知'}]`
           aiMessage.content += errText
-          // 同步写入 segments:当本轮已产生 tool/text 片段时,渲染走 segments 分支,
+          // 同步写入 segments:当本轮已产生 tool/text/file 片段时,渲染走 segments 分支,
           // 若不同步写入,错误信息将不会显示(只存在于 content 中)。
-          if (!aiMessage.segments) aiMessage.segments = []
-          const last = aiMessage.segments.at(-1)
-          if (last && last.kind === 'text') {
-            last.content += errText
-          }
-          else {
-            aiMessage.segments.push({ kind: 'text', content: errText })
-          }
+          appendTextSegment(aiMessage, errText)
           break
         }
       }
@@ -706,36 +743,64 @@ export function useAiChat() {
     return content.replace(/^\[context\] userId=.*?, sessionId=.*?\n\n/, '')
   }
 
-  // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记
+  // 最终扫描:处理跨 chunk 拆分导致实时匹配遗漏的标记。
+  // 1) 从完整 content 提取所有文件,去重补进 files(保证按钮至少能显示);
+  // 2) 归一化 segments:合并相邻文本片段后按标记切分,重建内联的 file 片段,
+  //    同时保留 tool / 已有 file 片段的原始顺序作为锚点。
   function extractFileDownloads(aiMessage: ChatMessage) {
-    const fileRegex = /\$\$FILE_DOWNLOAD:(.*?)\$\$/g
-    let match
-    let hasMatch = false
-    while ((match = fileRegex.exec(aiMessage.content)) !== null) {
-      try {
-        const fileData = JSON.parse(match[1])
-        if (!aiMessage.files) aiMessage.files = []
-        // 避免重复添加(实时匹配可能已添加)
-        const exists = aiMessage.files.some(f => f.key === fileData.key)
-        if (!exists) {
-          aiMessage.files.push({ key: fileData.key, fileName: fileData.fileName, size: fileData.size })
-        }
-        hasMatch = true
+    // —— 1) content 层:提取 + 去重补进 files,并清除标记文本 ——
+    for (const chunk of splitByFileMarker(aiMessage.content)) {
+      if (chunk.kind !== 'file') continue
+      if (!aiMessage.files) aiMessage.files = []
+      if (!aiMessage.files.some(f => f.key === chunk.file.key)) aiMessage.files.push(chunk.file)
+    }
+    aiMessage.content = aiMessage.content.replace(FILE_MARKER_RE, '').trim()
+
+    // —— 2) segments 层:归一化重建 ——
+    if (!aiMessage.segments || aiMessage.segments.length === 0) return
+
+    const rebuilt: MessageSegment[] = []
+    let textBuf = ''
+
+    // 把累积的文本按标记切分后刷入 rebuilt(文本、内联 file 交错)
+    const flushText = () => {
+      if (!textBuf) return
+      for (const chunk of splitByFileMarker(textBuf)) {
+        rebuilt.push(chunk)
       }
-      catch { /* ignore */ }
-    }
-    // 清除内容中的标记文本
-    if (hasMatch) {
-      aiMessage.content = aiMessage.content.replace(/\$\$FILE_DOWNLOAD:.*?\$\$/g, '').trim()
-      // 同步清除 segments 中文本片段的标记文本
-      if (aiMessage.segments) {
-        for (const seg of aiMessage.segments) {
-          if (seg.kind === 'text') {
-            seg.content = seg.content.replace(/\$\$FILE_DOWNLOAD:.*?\$\$/g, '')
-          }
-        }
+      textBuf = ''
+    }
+
+    for (const seg of aiMessage.segments) {
+      if (seg.kind === 'text') {
+        // 累积相邻文本,使跨片段的标记残片能拼接成完整标记
+        textBuf += seg.content
+      }
+      else {
+        // tool / 已有 file 片段:作为锚点,刷出前面的文本后原样保留
+        flushText()
+        rebuilt.push(seg)
       }
     }
+    flushText()
+
+    // 去除重复的 file 片段(同一 key 只保留首次出现)
+    const seenKeys = new Set<string>()
+    aiMessage.segments = rebuilt.filter((seg) => {
+      if (seg.kind !== 'file') return true
+      if (seenKeys.has(seg.file.key)) return false
+      seenKeys.add(seg.file.key)
+      return true
+    })
+  }
+
+  // 会话回放:为一条 assistant 历史消息解析 $$FILE_DOWNLOAD$$ 标记,
+  // 生成内联 file 片段(复用 extractFileDownloads 的归一化逻辑)。
+  // 历史消息文本一次到位,先整体作为一个文本片段,再交给归一化切分。
+  function hydrateReplayMessage(chatMsg: ChatMessage) {
+    if (chatMsg.role !== 'assistant' || !chatMsg.content) return
+    chatMsg.segments = [{ kind: 'text', content: chatMsg.content }]
+    extractFileDownloads(chatMsg)
   }
 
   async function resolvePermission(optionId: string) {