فهرست منبع

优化安全培训、合并考试工坊分支、AI写作

zkn 3 هفته پیش
والد
کامیت
7ef045b7e1

+ 107 - 0
shudao-vue-frontend/src/utils/aiProgressState.js

@@ -0,0 +1,107 @@
+export const getPendingRenderCount = (message) => {
+  const renders = message?._pendingOutputRenders
+  return renders instanceof Set ? renders.size : 0
+}
+
+export const hasPendingOutputRender = (message) => getPendingRenderCount(message) > 0
+
+export const shouldShowProgressStatus = (message) => (
+  Boolean(message?.showStats) &&
+  message.showProgressStatus !== false &&
+  !message._progressStatusFinalized
+)
+
+const hasCompletedReportExport = (message) => (
+  Number(message?.progress || 0) >= 100 &&
+  Array.isArray(message?.reports) &&
+  message.reports.length > 0
+)
+
+export const shouldShowMessageStatsCard = (message) => (
+  shouldShowProgressStatus(message) || hasCompletedReportExport(message)
+)
+
+export const shouldShowMessageProgress = (message) => {
+  if (!shouldShowProgressStatus(message)) {
+    return false
+  }
+
+  return Number(message.progress || 0) < 100 ||
+    Boolean(message.isTyping) ||
+    hasPendingOutputRender(message)
+}
+
+export const shouldHideStatsForStreamingAnswer = (message) => !message?.showStats
+
+export const getOutputtingProgress = (progress) => Math.max(Number(progress) || 0, 90)
+
+export const shouldApplyMessageProgressStatus = (message, status) => (
+  !message?._progressStatusFinalized ||
+  status === 'completed' ||
+  status === 'error'
+)
+
+export const shouldFinalizeMessageOnSSEComplete = (message) => (
+  message?.type === 'ai' &&
+  (
+    Boolean(message.isTyping) ||
+    shouldShowProgressStatus(message) ||
+    hasPendingOutputRender(message)
+  )
+)
+
+export const markMessageProgressComplete = (message) => {
+  if (!message) {
+    return
+  }
+
+  message.progress = 100
+  message.currentStatus = 'completed'
+
+  if (message.showStats && !message._progressStatusFinalized) {
+    message.showProgressStatus = true
+  }
+}
+
+export const hideMessageProgressStatus = (message) => {
+  if (!message) {
+    return
+  }
+
+  message.showProgressStatus = false
+  message._progressStatusFinalized = true
+  message.isTyping = false
+}
+
+export const trackMessageOutputRender = (message, promise) => {
+  if (!message || !promise || typeof promise.then !== 'function') {
+    return promise
+  }
+
+  if (!(message._pendingOutputRenders instanceof Set)) {
+    message._pendingOutputRenders = new Set()
+  }
+
+  const trackedPromise = Promise.resolve(promise).finally(() => {
+    message._pendingOutputRenders?.delete(trackedPromise)
+  })
+
+  message._pendingOutputRenders.add(trackedPromise)
+  return trackedPromise
+}
+
+export const waitForMessageOutputRenders = async (message) => {
+  const renders = message?._pendingOutputRenders
+  if (!(renders instanceof Set) || renders.size === 0) {
+    return
+  }
+
+  await Promise.allSettled(Array.from(renders))
+}
+
+export const clearMessageOutputRenders = (message) => {
+  const renders = message?._pendingOutputRenders
+  if (renders instanceof Set) {
+    renders.clear()
+  }
+}

+ 122 - 0
shudao-vue-frontend/src/utils/aiProgressState.test.js

@@ -0,0 +1,122 @@
+import { describe, expect, it } from 'vitest'
+import {
+  getOutputtingProgress,
+  getPendingRenderCount,
+  hideMessageProgressStatus,
+  markMessageProgressComplete,
+  shouldApplyMessageProgressStatus,
+  shouldFinalizeMessageOnSSEComplete,
+  shouldHideStatsForStreamingAnswer,
+  shouldShowMessageStatsCard,
+  shouldShowMessageProgress
+} from './aiProgressState'
+
+describe('aiProgressState', () => {
+  it('keeps the progress bar visible while final answer output is still rendering', () => {
+    expect(shouldShowMessageProgress({
+      showStats: true,
+      progress: 100,
+      isTyping: true
+    })).toBe(true)
+  })
+
+  it('keeps an existing stats card visible when a streaming answer starts outputting', () => {
+    expect(shouldHideStatsForStreamingAnswer({
+      showStats: true,
+      progress: 90
+    })).toBe(false)
+  })
+
+  it('allows non-stats answers to remain plain text answers', () => {
+    expect(shouldHideStatsForStreamingAnswer({
+      showStats: false,
+      progress: 0
+    })).toBe(true)
+  })
+
+  it('counts tracked frontend render work as pending output', () => {
+    expect(getPendingRenderCount({
+      _pendingOutputRenders: new Set([Promise.resolve()])
+    })).toBe(1)
+  })
+
+  it('never moves outputting progress backwards', () => {
+    expect(getOutputtingProgress(95)).toBe(95)
+    expect(getOutputtingProgress(45)).toBe(90)
+  })
+
+  it('shows 100 percent before removing the progress status', () => {
+    const message = {
+      showStats: true,
+      progress: 90,
+      isTyping: true
+    }
+
+    markMessageProgressComplete(message)
+
+    expect(message.progress).toBe(100)
+    expect(message.isTyping).toBe(true)
+    expect(shouldShowMessageProgress(message)).toBe(true)
+
+    hideMessageProgressStatus(message)
+
+    expect(message.showStats).toBe(true)
+    expect(message.showProgressStatus).toBe(false)
+    expect(message.isTyping).toBe(false)
+    expect(shouldShowMessageProgress(message)).toBe(false)
+  })
+
+  it('keeps the stats card available for completed report export', () => {
+    expect(shouldShowMessageStatsCard({
+      showStats: true,
+      showProgressStatus: false,
+      progress: 100,
+      reports: [{ id: 1 }]
+    })).toBe(true)
+
+    expect(shouldShowMessageStatsCard({
+      showStats: true,
+      showProgressStatus: false,
+      progress: 100,
+      reports: []
+    })).toBe(false)
+  })
+
+  it('does not re-open a progress status that has already been finalized', () => {
+    const message = {
+      showStats: true,
+      showProgressStatus: true,
+      progress: 100,
+      isTyping: false
+    }
+
+    hideMessageProgressStatus(message)
+    markMessageProgressComplete(message)
+
+    expect(message.showProgressStatus).toBe(false)
+    expect(shouldShowMessageProgress(message)).toBe(false)
+  })
+
+  it('finalizes visible progress on SSE close even when the typewriter already stopped', () => {
+    expect(shouldFinalizeMessageOnSSEComplete({
+      type: 'ai',
+      showStats: true,
+      showProgressStatus: true,
+      progress: 90,
+      isTyping: false
+    })).toBe(true)
+  })
+
+  it('blocks late outputting updates after the progress status is finalized', () => {
+    const message = {
+      showStats: true,
+      progress: 100,
+      isTyping: false
+    }
+
+    hideMessageProgressStatus(message)
+
+    expect(shouldApplyMessageProgressStatus(message, 'outputting')).toBe(false)
+    expect(shouldApplyMessageProgressStatus(message, 'completed')).toBe(true)
+  })
+})

+ 59 - 0
shudao-vue-frontend/src/views/Chat.inputDuringTyping.test.js

@@ -0,0 +1,59 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+const readView = (path) => readFileSync(resolve(__dirname, path), 'utf8')
+
+const getInputElement = (source) => {
+  const inputIndex = source.indexOf('class="message-input"')
+  expect(inputIndex).toBeGreaterThanOrEqual(0)
+  const elementStart = Math.max(
+    source.lastIndexOf('<textarea', inputIndex),
+    source.lastIndexOf('<input', inputIndex)
+  )
+  expect(elementStart).toBeGreaterThanOrEqual(0)
+  const textareaEnd = source.indexOf('</textarea>', inputIndex)
+  const inputEnd = source.indexOf('>', inputIndex)
+  const elementEnd = textareaEnd >= 0 && textareaEnd < inputEnd
+    ? textareaEnd + '</textarea>'.length
+    : inputEnd + 1
+  return source.slice(elementStart, elementEnd)
+}
+
+describe('Chat input during AI output', () => {
+  it('keeps the desktop text input editable while an AI message is generating', () => {
+    const inputElement = getInputElement(readView('Chat.vue'))
+
+    expect(inputElement).not.toContain(':disabled="isSending || hasTypingMessage"')
+  })
+
+  it('keeps the mobile text input editable while an AI message is generating', () => {
+    const inputElement = getInputElement(readView('mobile/m-Chat.vue'))
+
+    expect(inputElement).not.toContain(':disabled="isSending || hasTypingMessage"')
+  })
+
+  it('still blocks sending a new mobile message while an AI message is typing', () => {
+    const source = readView('mobile/m-Chat.vue')
+
+    expect(source).toContain(':disabled="hasTypingMessage || !messageText.trim()"')
+  })
+
+  it('finalizes desktop progress statuses on SSE close even after typing already stopped', () => {
+    const source = readView('Chat.vue')
+
+    expect(source).toContain('shouldFinalizeMessageOnSSEComplete')
+    expect(source).toContain('filter(message => shouldFinalizeMessageOnSSEComplete(message))')
+  })
+
+  it('finalizes mobile progress statuses on SSE close even after typing already stopped', () => {
+    const source = readView('mobile/m-Chat.vue')
+
+    expect(source).toContain('shouldFinalizeMessageOnSSEComplete')
+    expect(source).toContain('filter(message => shouldFinalizeMessageOnSSEComplete(message))')
+  })
+})

+ 96 - 40
shudao-vue-frontend/src/views/Chat.vue

@@ -196,7 +196,7 @@
                   <!-- 完整的AI回复内容 -->
                   <div class="ai-response-content">
                     <!-- 状态统计卡片 - 优先显示(不等待后端数据) -->
-                    <div v-if="message.showStats" 
+                    <div v-if="shouldShowMessageStatsCard(message)" 
                          class="files-stats-white"
                          :class="{ 'sticky': messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing }"
                          :style="(messageScrollStates[index]?.isSticky && messageScrollStates[index]?.initialized && !messageScrollStates[index]?.isInitializing && messageScrollStates[index]?.initialLeft > 0 && messageScrollStates[index]?.initialWidth > 0) ? {
@@ -207,7 +207,7 @@
                            zIndex: 999
                          } : {}"
                          :data-message-index="index">
-                      <div class="stats-left">
+                      <div v-if="shouldShowProgressStatus(message)" class="stats-left">
                         <StatusAvatar 
                           :status="getAvatarStatus(message.currentStatus, message.progress)" 
                           :size="36"
@@ -217,7 +217,7 @@
                       </div>
                       
                       <!-- 进度条胶囊 - 在白色卡片内 -->
-                      <div v-if="message.progress < 100" class="progress-capsule-inline">
+                      <div v-if="shouldShowMessageProgress(message)" class="progress-capsule-inline">
                         <div class="progress-bar-mini">
                           <div class="progress-fill" :style="{ width: message.progress + '%' }"></div>
                           <div class="progress-dot" :style="{ left: message.progress + '%' }"></div>
@@ -508,7 +508,6 @@
               v-model="messageText"
               @keydown.enter="handleMessageInputEnterKey"
               @input="handleInput"
-              :disabled="isSending || hasTypingMessage"
               maxlength="2000"
               rows="3"
             ></textarea>
@@ -752,6 +751,20 @@ import {
   buildDocumentGenerationUserMessage,
   shouldAttachDocumentToRequest
 } from '@/utils/aiWritingRequest.js'
+import {
+  clearMessageOutputRenders,
+  getOutputtingProgress,
+  hideMessageProgressStatus,
+  markMessageProgressComplete,
+  shouldApplyMessageProgressStatus,
+  shouldFinalizeMessageOnSSEComplete,
+  shouldHideStatsForStreamingAnswer,
+  shouldShowMessageStatsCard,
+  shouldShowProgressStatus,
+  shouldShowMessageProgress,
+  trackMessageOutputRender,
+  waitForMessageOutputRenders
+} from '@/utils/aiProgressState.js'
 import { buildUploadedDocumentPayload } from '@/utils/attachmentContext.js'
 import { getAttachmentCardIcon } from '@/utils/attachmentFile.js'
 import { prepareAIWritingEditorHtml } from '@/utils/aiWritingContent.js'
@@ -1600,6 +1613,32 @@ const clearAllTypeIntervals = () => {
     clearInterval(interval)
   })
   reportTypewriters.clear()
+  chatMessages.value.forEach(message => clearMessageOutputRenders(message))
+}
+
+const waitForProgressCompletionFrame = () => new Promise(resolve => setTimeout(resolve, 180))
+
+const completeAIMessageAfterRendered = async (message) => {
+  if (!message || message._stopped) return
+
+  if (message.showStats && Number(message.progress || 0) < 100) {
+    updateMessageStatus(message, 'completed')
+  }
+  markMessageProgressComplete(message)
+
+  await waitForMessageOutputRenders(message)
+
+  if (message._stopped) return
+
+  if (shouldShowProgressStatus(message)) {
+    message.isTyping = true
+    await waitForProgressCompletionFrame()
+  }
+
+  if (message._stopped) return
+
+  hideMessageProgressStatus(message)
+  clearMessageOutputRenders(message)
 }
 
 // 处理文件标签格式的回显
@@ -2777,6 +2816,10 @@ const getAvatarStatus = (currentStatus, progress) => {
 
 // 更新AI消息状态和进度
 const updateMessageStatus = (aiMessage, status, customMessage = null) => {
+  if (!shouldApplyMessageProgressStatus(aiMessage, status)) {
+    return
+  }
+
   const statusConfig = {
     querying_kb: {
       message: '🔍 <span class="ai-name">蜀道安全管理AI智能助手</span>正在为您分析知识库……',
@@ -2840,7 +2883,9 @@ const updateMessageStatus = (aiMessage, status, customMessage = null) => {
       aiMessage.statusMessage = customMessage || config.message
     }
     
-    aiMessage.progress = config.progress
+    aiMessage.progress = status === 'outputting'
+      ? getOutputtingProgress(aiMessage.progress)
+      : config.progress
   }
 }
 
@@ -2913,8 +2958,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       }
 
       if ((data.message || '').includes('基础回答模式')) {
-        aiMessage.currentStatus = 'outputting'
-        aiMessage.progress = Math.max(aiMessage.progress || 0, 90)
+        if (shouldApplyMessageProgressStatus(aiMessage, 'outputting')) {
+          aiMessage.currentStatus = 'outputting'
+          aiMessage.progress = getOutputtingProgress(aiMessage.progress)
+        }
       }
       break
 
@@ -2949,12 +2996,12 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
 
         // 使用打字机效果显示问题总结
-        startReportFieldTypewriter(
+        trackMessageOutputRender(aiMessage, startReportFieldTypewriter(
           { file_index: 'summary', report: aiMessage, _typewriterStates: {} },
           'summary',
           fullSummary,
           50
-        ).catch(err => {
+        )).catch(err => {
           console.error('问题总结打字机效果失败:', err)
           aiMessage.summary = fullSummary
         })
@@ -2962,7 +3009,11 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
 
     case 'online_answer': {
-      aiMessage.showStats = false
+      if (shouldHideStatsForStreamingAnswer(aiMessage)) {
+        aiMessage.showStats = false
+      } else {
+        updateMessageStatus(aiMessage, 'outputting')
+      }
       if (shouldClearSummaryForOnlineAnswer(aiMessage)) {
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
@@ -2981,7 +3032,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       const processedReply = processAIResponse(finalContent)
       const renderedReply = renderMarkdownContent(processedReply)
 
-      startTypewriterEffect(aiMessage, renderedReply, 200)
+      trackMessageOutputRender(aiMessage, startTypewriterEffect(aiMessage, renderedReply, 200))
         .catch(err => {
           console.error('在线回答打字机效果失败:', err)
           aiMessage.displayContent = renderedReply
@@ -3178,7 +3229,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         targetReport._typewriterStarted = true
         
         // 先打概述(速度200 = 每次20个字符,极快)
-        startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
+        const reportRenderPromise = startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
           .then(() => {
             // 概述完成后打解析
             return startReportFieldTypewriter(targetReport, 'analysis', targetReport._fullContent.analysis || '', 200)
@@ -3193,6 +3244,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             // 全部完成,标记为已完成
             targetReport._typewriterCompleted = true
           })
+        trackMessageOutputRender(aiMessage, reportRenderPromise)
           .catch(err => {
             console.error('报告打字机效果失败:', err)
             // 失败时直接显示完整内容
@@ -3272,7 +3324,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
           _typewriterStates: {}
         }
         
-        startReportFieldTypewriter(summaryReport, 'webSearchSummary', data.summary, 200)
+        trackMessageOutputRender(aiMessage, startReportFieldTypewriter(summaryReport, 'webSearchSummary', data.summary, 200))
           .then(() => {
             // 标记为已完成,防止重复触发
             aiMessage._webSearchSummaryCompleted = true
@@ -3309,26 +3361,30 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             .then((response) => {
               console.log('[网络搜索] AI消息保存成功,更新为完成状态')
               // 保存成功后,更新状态为completed
-              updateMessageStatus(aiMessage, 'completed')
-              aiMessage.isTyping = false
-              isSending.value = false
-              streamingReports.value.clear()
+              completeAIMessageAfterRendered(aiMessage).then(() => {
+                isSending.value = false
+                streamingReports.value.clear()
+                isAIReplyProcessComplete.value = true
+              })
               
               // 重置AI回复流程状态
-              isAIReplyProcessComplete.value = true
             })
             .catch(err => {
               console.error('[网络搜索] AI消息保存失败:', err)
               // 即使保存失败,也更新为完成状态
-              updateMessageStatus(aiMessage, 'completed')
-              aiMessage.isTyping = false
-              isSending.value = false
+              completeAIMessageAfterRendered(aiMessage).then(() => {
+                isSending.value = false
+                streamingReports.value.clear()
+                isAIReplyProcessComplete.value = true
+              })
             })
         } else {
           // 没有ai_message_id时,直接更新为完成状态
-          updateMessageStatus(aiMessage, 'completed')
-          aiMessage.isTyping = false
-          isSending.value = false
+          completeAIMessageAfterRendered(aiMessage).then(() => {
+            isSending.value = false
+            streamingReports.value.clear()
+            isAIReplyProcessComplete.value = true
+          })
         }
       }
       break
@@ -3340,21 +3396,19 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
     case 'completed':
       console.log('[SSE] 收到completed事件')
-      isSending.value = false
-      streamingReports.value.clear()
-      aiMessage.isTyping = false
-      
-      // 只有在还未完成时才更新状态
-      if (aiMessage.progress < 100) {
-        updateMessageStatus(aiMessage, 'completed')
-      }
-      const completedPayload = buildAIMessageUpdatePayload(aiMessage)
-      if (completedPayload) {
-        updateAIMessageContent(completedPayload.aiMessageId, completedPayload.content)
-          .catch(err => console.error('completed事件回写AI消息失败:', err))
-      }
-      
-      ElMessage.success('报告生成完成')
+      completeAIMessageAfterRendered(aiMessage).then(() => {
+        isSending.value = false
+        streamingReports.value.clear()
+        isAIReplyProcessComplete.value = true
+
+        const completedPayload = buildAIMessageUpdatePayload(aiMessage)
+        if (completedPayload) {
+          updateAIMessageContent(completedPayload.aiMessageId, completedPayload.content)
+            .catch(err => console.error('completed事件回写AI消息失败:', err))
+        }
+
+        ElMessage.success('报告生成完成')
+      })
       break
       
     case 'interrupted':
@@ -3392,8 +3446,10 @@ const handleSSEError = (error) => {
   ElMessage.error('连接已断开')
 }
 
-const handleSSEComplete = () => {
+const handleSSEComplete = async () => {
   isSending.value = false
+  const renderingMessages = chatMessages.value.filter(message => shouldFinalizeMessageOnSSEComplete(message))
+  await Promise.all(renderingMessages.map(message => completeAIMessageAfterRendered(message)))
   
   chatMessages.value.forEach(message => {
     if (message.type === 'ai' && message.isTyping) {

+ 90 - 37
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -139,10 +139,10 @@
                 <div class="ai-response-content">
                   <!-- 进度统计卡片 -->
                   <div
-                    v-if="message.showStats"
+                    v-if="shouldShowProgressStatus(message)"
                     class="stats-card"
                   >
-                <div class="stats-left">
+                <div v-if="shouldShowProgressStatus(message)" class="stats-left">
                   <StatusAvatar 
                     :status="getAvatarStatus(message.currentStatus, message.progress)" 
                     :size="28"
@@ -152,7 +152,7 @@
                     </div>
                 
                 <!-- 进度条 -->
-                <div v-if="message.progress < 100" class="progress-capsule-inline">
+                <div v-if="shouldShowMessageProgress(message)" class="progress-capsule-inline">
                   <div class="progress-bar-mini">
                     <div class="progress-fill" :style="{ width: message.progress + '%' }"></div>
                     <div class="progress-dot" :style="{ left: message.progress + '%' }"></div>
@@ -318,7 +318,6 @@
               class="message-input"
               v-model="messageText"
               @keyup.enter="sendMessage"
-              :disabled="isSending || hasTypingMessage"
               maxlength="2000"
             >
             <button class="voice-btn" @click="handleVoiceClick" :disabled="isSending || hasTypingMessage" :class="{ 'recording': isListening }">
@@ -491,6 +490,19 @@ import {
   normalizeReportsForPersistence,
   shouldClearSummaryForOnlineAnswer
 } from '@/utils/chatHistoryPersistence.js'
+import {
+  clearMessageOutputRenders,
+  getOutputtingProgress,
+  hideMessageProgressStatus,
+  markMessageProgressComplete,
+  shouldApplyMessageProgressStatus,
+  shouldFinalizeMessageOnSSEComplete,
+  shouldHideStatsForStreamingAnswer,
+  shouldShowProgressStatus,
+  shouldShowMessageProgress,
+  trackMessageOutputRender,
+  waitForMessageOutputRenders
+} from '@/utils/aiProgressState.js'
 import { getToken, getTokenType, getUserName, getAccountId } from '@/utils/auth.js'
 import { initNativeNavForSubPage } from '@/utils/nativeBridge.js'
 import { synthesizeSpeechToObjectUrl } from '@/services/speechService'
@@ -1467,6 +1479,32 @@ const clearAllTypeIntervals = () => {
     clearInterval(interval)
   })
   reportTypewriters.clear()
+  chatMessages.value.forEach(message => clearMessageOutputRenders(message))
+}
+
+const waitForProgressCompletionFrame = () => new Promise(resolve => setTimeout(resolve, 180))
+
+const completeAIMessageAfterRendered = async (message) => {
+  if (!message || message._stopped) return
+
+  if (message.showStats && Number(message.progress || 0) < 100) {
+    updateMessageStatus(message, 'completed')
+  }
+  markMessageProgressComplete(message)
+
+  await waitForMessageOutputRenders(message)
+
+  if (message._stopped) return
+
+  if (shouldShowProgressStatus(message)) {
+    message.isTyping = true
+    await waitForProgressCompletionFrame()
+  }
+
+  if (message._stopped) return
+
+  hideMessageProgressStatus(message)
+  clearMessageOutputRenders(message)
 }
 
 // 获取报告的完整内容(优先使用_fullContent,解决打字机效果未完成时内容为空的问题)
@@ -2271,6 +2309,10 @@ const getAvatarStatus = (currentStatus, progress) => {
 
 // 更新AI消息状态和进度
 const updateMessageStatus = (aiMessage, status, customMessage = null) => {
+  if (!shouldApplyMessageProgressStatus(aiMessage, status)) {
+    return
+  }
+
   const statusConfig = {
     querying_kb: {
       message: '正在为您分析知识库……',
@@ -2332,7 +2374,9 @@ const updateMessageStatus = (aiMessage, status, customMessage = null) => {
       aiMessage.statusMessage = customMessage || config.message
     }
     
-    aiMessage.progress = config.progress
+    aiMessage.progress = status === 'outputting'
+      ? getOutputtingProgress(aiMessage.progress)
+      : config.progress
   }
 }
 
@@ -2471,12 +2515,12 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
 
         // 使用打字机效果显示问题总结
-        startReportFieldTypewriter(
+        trackMessageOutputRender(aiMessage, startReportFieldTypewriter(
           { file_index: 'summary', report: aiMessage, _typewriterStates: {} },
           'summary',
           fullSummary,
           50
-        ).catch(err => {
+        )).catch(err => {
           console.error('问题总结打字机效果失败:', err)
           aiMessage.summary = fullSummary
         })
@@ -2484,7 +2528,11 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
 
     case 'online_answer': {
-      aiMessage.showStats = false
+      if (shouldHideStatsForStreamingAnswer(aiMessage)) {
+        aiMessage.showStats = false
+      } else {
+        updateMessageStatus(aiMessage, 'outputting')
+      }
       if (shouldClearSummaryForOnlineAnswer(aiMessage)) {
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
@@ -2689,7 +2737,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         targetReport._typewriterStarted = true
         
         // 先打概述(速度200 = 每次20个字符,极快)
-        startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
+        const reportRenderPromise = startReportFieldTypewriter(targetReport, 'summary', targetReport._fullContent.summary || '', 200)
           .then(() => {
             // 概述完成后打解析
             return startReportFieldTypewriter(targetReport, 'analysis', targetReport._fullContent.analysis || '', 200)
@@ -2704,6 +2752,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             // 全部完成,标记为已完成
             targetReport._typewriterCompleted = true
           })
+        trackMessageOutputRender(aiMessage, reportRenderPromise)
           .catch(err => {
             console.error('报告打字机效果失败:', err)
             // 失败时直接显示完整内容
@@ -2783,7 +2832,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
           _typewriterStates: {}
         }
         
-        startReportFieldTypewriter(summaryReport, 'webSearchSummary', data.summary, 200)
+        trackMessageOutputRender(aiMessage, startReportFieldTypewriter(summaryReport, 'webSearchSummary', data.summary, 200))
           .then(() => {
             // 标记为已完成,防止重复触发
             aiMessage._webSearchSummaryCompleted = true
@@ -2819,26 +2868,30 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             .then((response) => {
               console.log('[网络搜索] AI消息保存成功,更新为完成状态')
               // 保存成功后,更新状态为completed
-              updateMessageStatus(aiMessage, 'completed')
-              aiMessage.isTyping = false
-              isSending.value = false
-              streamingReports.value.clear()
+              completeAIMessageAfterRendered(aiMessage).then(() => {
+                isSending.value = false
+                streamingReports.value.clear()
+                isAIReplyProcessComplete.value = true
+              })
               
               // 重置AI回复流程状态
-              isAIReplyProcessComplete.value = true
             })
             .catch(err => {
               console.error('[网络搜索] AI消息保存失败:', err)
               // 即使保存失败,也更新为完成状态
-              updateMessageStatus(aiMessage, 'completed')
-              aiMessage.isTyping = false
-              isSending.value = false
+              completeAIMessageAfterRendered(aiMessage).then(() => {
+                isSending.value = false
+                streamingReports.value.clear()
+                isAIReplyProcessComplete.value = true
+              })
             })
         } else {
           // 没有ai_message_id时,直接更新为完成状态
-          updateMessageStatus(aiMessage, 'completed')
-          aiMessage.isTyping = false
-          isSending.value = false
+          completeAIMessageAfterRendered(aiMessage).then(() => {
+            isSending.value = false
+            streamingReports.value.clear()
+            isAIReplyProcessComplete.value = true
+          })
         }
       }
       break
@@ -2850,21 +2903,19 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
     case 'completed':
       console.log('[SSE] 收到completed事件')
-      isSending.value = false
-      streamingReports.value.clear()
-      aiMessage.isTyping = false
-      
-      // 只有在还未完成时才更新状态
-      if (aiMessage.progress < 100) {
-        updateMessageStatus(aiMessage, 'completed')
-      }
-      const completedPayload = buildAIMessageUpdatePayload(aiMessage)
-      if (completedPayload) {
-        updateAIMessageContent(completedPayload.aiMessageId, completedPayload.content)
-          .catch(err => console.error('completed事件回写AI消息失败:', err))
-      }
-      
-      showToastMessage('报告生成完成', 2000)
+      completeAIMessageAfterRendered(aiMessage).then(() => {
+        isSending.value = false
+        streamingReports.value.clear()
+        isAIReplyProcessComplete.value = true
+
+        const completedPayload = buildAIMessageUpdatePayload(aiMessage)
+        if (completedPayload) {
+          updateAIMessageContent(completedPayload.aiMessageId, completedPayload.content)
+            .catch(err => console.error('completed事件回写AI消息失败:', err))
+        }
+
+        showToastMessage('报告生成完成', 2000)
+      })
       break
       
     case 'interrupted':
@@ -2904,8 +2955,10 @@ const handleSSEError = (error) => {
 }
 
 // SSE完成处理
-const handleSSEComplete = () => {
+const handleSSEComplete = async () => {
   isSending.value = false
+  const renderingMessages = chatMessages.value.filter(message => shouldFinalizeMessageOnSSEComplete(message))
+  await Promise.all(renderingMessages.map(message => completeAIMessageAfterRendered(message)))
   
   chatMessages.value.forEach(message => {
     if (message.type === 'ai' && message.isTyping) {