Procházet zdrojové kódy

优化响应速度

zkn před 3 týdny
rodič
revize
d6e9fb7870

+ 48 - 0
shudao-vue-frontend/src/utils/chatHistoryPersistence.js

@@ -82,6 +82,54 @@ export const normalizeReportsForPersistence = (reports) => {
   return dedupeReportsByFileAndScene(hydratePersistedReports(reports))
 }
 
+export const applyReportChunkToMessage = (message, streamingReports, event) => {
+  if (!message || !Array.isArray(message.reports) || !event) {
+    return null
+  }
+
+  const fileIndex = event.file_index
+  let reportIndex = streamingReports?.get?.(fileIndex)
+  if (reportIndex === undefined) {
+    reportIndex = message.reports.findIndex(report => report?.file_index === fileIndex)
+  }
+
+  const target = message.reports[reportIndex]
+  if (!target || target.type === 'category_title') {
+    return null
+  }
+
+  const partialReport = event.partial_report && typeof event.partial_report === 'object'
+    ? event.partial_report
+    : {}
+  const fields = ['display_name', 'summary', 'analysis', 'clauses']
+
+  target.report = {
+    display_name: target.report?.display_name || '',
+    summary: target.report?.summary || '',
+    analysis: target.report?.analysis || '',
+    clauses: target.report?.clauses || ''
+  }
+  target._fullContent = {
+    display_name: target._fullContent?.display_name || target.report.display_name || '',
+    summary: target._fullContent?.summary || target.report.summary || '',
+    analysis: target._fullContent?.analysis || target.report.analysis || '',
+    clauses: target._fullContent?.clauses || target.report.clauses || ''
+  }
+
+  fields.forEach((field) => {
+    if (typeof partialReport[field] === 'string') {
+      target.report[field] = partialReport[field]
+      target._fullContent[field] = partialReport[field]
+    }
+  })
+
+  target.status = target.status || 'streaming'
+  target._streamingStarted = true
+  target._rawReportChunk = `${target._rawReportChunk || ''}${event.chunk || ''}`
+
+  return target
+}
+
 const extractBalancedJson = (text) => {
   if (typeof text !== 'string') {
     return ''

+ 35 - 0
shudao-vue-frontend/src/utils/chatHistoryPersistence.test.js

@@ -5,6 +5,7 @@ import {
   buildPersistedAIMessageContent,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
+  applyReportChunkToMessage,
   hydratePersistedReports,
   normalizeReportsForPersistence,
   shouldClearSummaryForOnlineAnswer,
@@ -12,6 +13,40 @@ import {
 } from './chatHistoryPersistence'
 
 describe('chatHistoryPersistence', () => {
+  it('applies streamed report chunks to an existing placeholder report', () => {
+    const message = {
+      reports: [
+        {
+          file_index: 1,
+          source_file: 'bridge-spec.pdf',
+          status: 'streaming',
+          report: {
+            display_name: '',
+            summary: '',
+            analysis: '',
+            clauses: ''
+          }
+        }
+      ]
+    }
+    const streamingReports = new Map([[1, 0]])
+
+    const target = applyReportChunkToMessage(message, streamingReports, {
+      file_index: 1,
+      chunk: '{"summary":"partial',
+      partial_report: {
+        display_name: 'bridge-spec',
+        summary: 'partial summary'
+      }
+    })
+
+    expect(target).toBe(message.reports[0])
+    expect(target.report.display_name).toBe('bridge-spec')
+    expect(target.report.summary).toBe('partial summary')
+    expect(target._streamingStarted).toBe(true)
+    expect(target._rawReportChunk).toBe('{"summary":"partial')
+  })
+
   it('fills report fields from _fullContent before persistence', () => {
     const reports = [
       {

+ 13 - 7
shudao-vue-frontend/src/views/Chat.vue

@@ -738,6 +738,7 @@ import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 // import { getUserId } from '@/utils/userManager.js'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
@@ -3080,8 +3081,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       
     case 'report':
@@ -3105,8 +3107,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       let targetReport
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -3115,12 +3119,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           status: 'completed',
           metadata: {
@@ -3132,7 +3137,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)

+ 13 - 7
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -483,6 +483,7 @@ import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
+  applyReportChunkToMessage,
   buildAIMessageUpdatePayload,
   dedupeReportsByFileAndScene,
   extractRelatedQuestions,
@@ -2591,8 +2592,9 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       break
     
     case 'report_chunk':
-      // 不再处理report_chunk,等待完整的report消息后用打字机效果显示
-      // 这样可以避免与打字机效果冲突
+      if (applyReportChunkToMessage(aiMessage, streamingReports.value, data)) {
+        updateMessageStatus(aiMessage, 'deep_thinking')
+      }
       break
       
     case 'report':
@@ -2616,8 +2618,10 @@ const handleSSEMessage = (data, aiMessageIndex) => {
       
       let targetReport
       if (idx !== undefined) {
+        const existingReport = aiMessage.reports[idx]
+        const hadStreamingContent = Boolean(existingReport?._streamingStarted)
         const displayCategory = reportData.metadata?.primary_category ||
-          aiMessage.reports[idx].metadata?._displayCategory ||
+          existingReport?.metadata?._displayCategory ||
           aiMessage.currentCategory
         const fullSummary = reportData.report?.summary || ''
         const fullAnalysis = reportData.report?.analysis || ''
@@ -2626,12 +2630,13 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         
         // 创建带空内容的报告对象,保留所有原始字段
         aiMessage.reports[idx] = { 
+          ...existingReport,
           ...reportData, // 保留所有原始字段,包括可能的链接字段
           report: {
             display_name: fullDisplayName, // 直接显示
-            summary: '',
-            analysis: '',
-            clauses: ''
+            summary: hadStreamingContent ? fullSummary : '',
+            analysis: hadStreamingContent ? fullAnalysis : '',
+            clauses: hadStreamingContent ? fullClauses : ''
           },
           status: 'completed',
           metadata: {
@@ -2643,7 +2648,8 @@ const handleSSEMessage = (data, aiMessageIndex) => {
             summary: fullSummary,
             analysis: fullAnalysis,
             clauses: fullClauses
-          }
+          },
+          _typewriterCompleted: hadStreamingContent || existingReport?._typewriterCompleted || false
         }
         targetReport = aiMessage.reports[idx]
         streamingReports.value.delete(reportData.file_index)