Selaa lähdekoodia

fix:优化AI助手,把思考中这个词条固定在下方,优化工具调用词条显示,聊天框添加上传附件显示

Jerry Liang 2 kuukautta sitten
vanhempi
commit
9331390e29

+ 15 - 14
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -77,9 +77,12 @@ onMounted(() => {
   fetchSession()
 })
 
-// 自动滚动到底部
+// 自动滚动到底部(文本增量或工具调用片段变化时)
 watch(
-  () => messages.value[messages.value.length - 1]?.content,
+  () => {
+    const last = messages.value[messages.value.length - 1]
+    return `${last?.content ?? ''}|${last?.segments?.length ?? 0}`
+  },
   () => {
     nextTick(() => {
       scrollRef.value?.scrollTo({ top: 99999, behavior: 'smooth' })
@@ -385,20 +388,18 @@ function formatExpiry(expiresAt: number): string {
           :message="msg"
           @resolve-permission="resolvePermission"
         />
-        <!-- 流式输出中的提示 -->
-        <div
-          v-if="isStreaming && messages[messages.length - 1]?.content === ''"
-          class="flex justify-start"
-        >
-          <div
-            class="rounded-lg bg-neutral-100 px-3 py-2 text-sm text-neutral-500 dark:bg-neutral-700"
-          >
-            <span class="animate-pulse">思考中...</span>
-          </div>
-        </div>
       </div>
     </NScrollbar>
 
+    <!-- 流式输出中提示:固定在输入区上方,不随消息列表滚动 -->
+    <div
+      v-if="isStreaming"
+      class="flex items-center gap-1.5 border-t px-3 py-1.5 text-xs text-neutral-500 dark:border-neutral-700 dark:text-neutral-400"
+    >
+      <NSpin :size="12" />
+      <span class="animate-pulse">思考中...</span>
+    </div>
+
     <!-- 输入区域 -->
     <div class="border-t p-3 dark:border-neutral-700">
       <!-- 已上传文件标签 -->
@@ -454,7 +455,7 @@ function formatExpiry(expiresAt: number): string {
           v-if="!isStreaming"
           type="primary"
           size="small"
-          :disabled="!inputText.trim() && pendingFiles.length === 0"
+          :disabled="!inputText.trim()"
           @click="handleSend"
         >
           <span class="i-ph:paper-plane-tilt" />

+ 56 - 7
src/layout/header/Actions/AiChat/MessageItem.vue

@@ -5,7 +5,7 @@ import { marked, Renderer } from 'marked'
 import DOMPurify from 'dompurify'
 import { useClipboard } from '@vueuse/core'
 import { downloadAiFile } from '@/service/aiChat'
-import type { ChatMessage } from './interface'
+import type { ChatMessage, ToolCallInfo } from './interface'
 
 const props = defineProps<{
   message: ChatMessage
@@ -17,6 +17,11 @@ const emit = defineEmits<{
 
 const isUser = computed(() => props.message.role === 'user')
 
+// 是否使用有序 segments 渲染(新消息带 segments;旧消息回退到旧逻辑)
+const useSegments = computed(
+  () => !isUser.value && Array.isArray(props.message.segments) && props.message.segments.length > 0,
+)
+
 // 自定义 marked renderer,为代码块添加复制按钮
 const renderer = new Renderer()
 renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
@@ -29,16 +34,34 @@ renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
   return `<div class="code-block-wrapper">${langLabel}<button class="copy-btn" data-code="${escaped}">复制</button><pre><code>${text}</code></pre></div>`
 }
 
+// 将一段 AI 文本渲染为安全 HTML
+function renderMarkdown(text: string): string {
+  const raw = marked.parse(text, { async: false, renderer }) as string
+  return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
+}
+
 const formattedContent = computed(() => {
   if (isUser.value) {
     // 用户消息保持纯文本显示
     return props.message.content.replace(/\n/g, '<br/>')
   }
   // AI 回复使用 Markdown 渲染
-  const raw = marked.parse(props.message.content, { async: false, renderer }) as string
-  return DOMPurify.sanitize(raw, { ADD_ATTR: ['data-code'] })
+  return renderMarkdown(props.message.content)
 })
 
+// 工具标签展示映射:区分执行中 / 已完成 / 失败
+function toolTagType(status: ToolCallInfo['status']) {
+  if (status === 'running') return 'warning'
+  if (status === 'completed') return 'success'
+  return 'error'
+}
+
+function toolStatusLabel(status: ToolCallInfo['status']) {
+  if (status === 'running') return '执行中'
+  if (status === 'completed') return '已完成'
+  return '失败'
+}
+
 // 复制到剪贴板
 const { copy } = useClipboard()
 const copiedIndex = ref<string | null>(null)
@@ -88,19 +111,20 @@ function formatSize(bytes: number): string {
         ? 'bg-blue-500 text-white'
         : 'bg-neutral-100 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200'"
     >
-      <!-- 工具调用标签 -->
-      <div v-if="message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
+      <!-- 工具调用标签(旧消息回退:无 segments 时集中展示在顶部) -->
+      <div v-if="!useSegments && message.toolCalls?.length" class="mb-2 flex flex-wrap gap-1">
         <NTag
           v-for="tc in message.toolCalls"
           :key="tc.id"
           size="small"
           :title="tc.title"
-          :type="tc.status === 'running' ? 'warning' : tc.status === 'completed' ? 'success' : 'error'"
+          :type="toolTagType(tc.status)"
         >
           <template #icon>
             <NSpin v-if="tc.status === 'running'" :size="10" />
           </template>
           {{ tc.description }}
+          <span class="ml-1 opacity-70">· {{ toolStatusLabel(tc.status) }}</span>
         </NTag>
       </div>
 
@@ -152,8 +176,33 @@ function formatSize(bytes: number): string {
       </div>
 
       <!-- 消息内容 -->
+      <!-- 有序 segments:文本与工具调用按到达顺序交错渲染 -->
+      <div v-if="useSegments" class="segments" @click="handleContentClick">
+        <template v-for="(seg, idx) in message.segments" :key="idx">
+          <div
+            v-if="seg.kind === 'text' && seg.content.trim()"
+            class="markdown-body"
+            v-html="renderMarkdown(seg.content)"
+          />
+          <div v-else-if="seg.kind === 'tool'" class="my-1 flex flex-wrap gap-1">
+            <NTag
+              size="small"
+              :title="seg.tool.title"
+              :type="toolTagType(seg.tool.status)"
+              :bordered="false"
+            >
+              <template #icon>
+                <NSpin v-if="seg.tool.status === 'running'" :size="10" />
+              </template>
+              {{ seg.tool.description }}
+              <span class="ml-1 opacity-70">· {{ toolStatusLabel(seg.tool.status) }}</span>
+            </NTag>
+          </div>
+        </template>
+      </div>
+      <!-- 旧消息回退:整体文本渲染 -->
       <div
-        v-if="message.content"
+        v-else-if="message.content"
         :class="{ 'markdown-body': !isUser }"
         v-html="formattedContent"
         @click="handleContentClick"

+ 17 - 0
src/layout/header/Actions/AiChat/interface.ts

@@ -3,6 +3,8 @@ export interface ChatMessage {
   role: 'user' | 'assistant'
   content: string
   toolCalls?: ToolCallInfo[]
+  // 按到达顺序交错排列的文本 / 工具调用片段,用于让工具标签散落在文本流中
+  segments?: MessageSegment[]
   permission?: PermissionRequest
   files?: FileInfo[]
   timestamp: number
@@ -15,6 +17,21 @@ export interface ToolCallInfo {
   status: 'running' | 'completed' | 'error'
 }
 
+// 文本片段:一段连续的 AI 文本输出
+export interface TextSegment {
+  kind: 'text'
+  content: string
+}
+
+// 工具调用片段:内联展示的工具调用标签
+export interface ToolSegment {
+  kind: 'tool'
+  tool: ToolCallInfo
+}
+
+// 消息片段:按到达顺序交错的文本与工具调用
+export type MessageSegment = TextSegment | ToolSegment
+
 export interface PermissionRequest {
   requestId: string
   title: string

+ 41 - 7
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -445,6 +445,12 @@ export function useAiChat() {
 
     // 构建实际发送的消息(包含文件上下文)
     let actualMessage = content.trim()
+    // 记录本次随消息发送的附件,用于在用户气泡中展示
+    const attachedFiles = pendingFiles.value.map(f => ({
+      key: f.key,
+      fileName: f.fileName,
+      size: f.size,
+    }))
     if (pendingFiles.value.length > 0) {
       const filePaths = pendingFiles.value.map(f => f.path).join(', ')
       actualMessage = `[用户上传了文件: ${filePaths},请使用 readTextFile 工具读取文件内容后进行分析]\n\n${actualMessage}`
@@ -452,11 +458,12 @@ export function useAiChat() {
       pendingFiles.value = []
     }
 
-    // 添加用户消息(显示原始内容,不含注入的文件提示)
+    // 添加用户消息(显示原始内容,不含注入的文件提示;附带展示上传的附件)
     messages.value.push({
       id: generateId(),
       role: 'user',
       content: content.trim(),
+      files: attachedFiles.length > 0 ? attachedFiles : undefined,
       timestamp: Date.now(),
     })
 
@@ -466,6 +473,7 @@ export function useAiChat() {
       role: 'assistant',
       content: '',
       toolCalls: [],
+      segments: [],
       timestamp: Date.now(),
     })
     const aiMessage: ChatMessage = messages.value.at(-1)!
@@ -565,16 +573,34 @@ export function useAiChat() {
             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
+            }
+            else {
+              aiMessage.segments.push({ kind: 'text', content: cleanText })
+            }
+          }
           break
         }
         case 'tool_call':
           if (!aiMessage.toolCalls) aiMessage.toolCalls = []
-          aiMessage.toolCalls.push({
-            id: payload.toolCallId || '',
-            description: payload.description || '调用工具',
-            title: payload.name || '',
-            status: 'running',
-          })
+          {
+            // 工具对象在 toolCalls 与 segments 中共享同一引用,
+            // 以便 tool_update / done 更新状态时两处同步生效
+            const tool: ToolCallInfo = {
+              id: payload.toolCallId || '',
+              description: payload.description || '调用工具',
+              title: payload.name || '',
+              status: 'running',
+            }
+            aiMessage.toolCalls.push(tool)
+            if (!aiMessage.segments) aiMessage.segments = []
+            aiMessage.segments.push({ kind: 'tool', tool })
+          }
           break
         case 'tool_update': {
           // 按 toolCallId 关联对应的工具调用
@@ -679,6 +705,14 @@ export function useAiChat() {
     // 清除内容中的标记文本
     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, '')
+          }
+        }
+      }
     }
   }