소스 검색

修复bug:思考内容显示在中间、相同文档重复检索出来

zkn 4 주 전
부모
커밋
c020570cdb

+ 55 - 1
shudao-vue-frontend/src/utils/chatHistoryPersistence.js

@@ -1,3 +1,57 @@
+const getReportDisplayName = (report) => {
+  return report?.report?.display_name ||
+    report?._fullContent?.display_name ||
+    report?.source_file ||
+    ''
+}
+
+const normalizeReportKeyPart = (value, { fileName = false } = {}) => {
+  let normalized = String(value || '')
+    .normalize('NFKC')
+    .trim()
+
+  if (fileName) {
+    normalized = normalized
+      .replace(/[\u2010-\u2015\u2212-]/g, '-')
+      .replace(/[\/\\_]+/g, '')
+      .replace(/\.[a-z0-9]{1,8}$/i, '')
+      .replace(/^\d+[-_、.\s]+(?=[\u4e00-\u9fff])/, '')
+  }
+
+  return normalized
+    .replace(/\s+/g, '')
+    .toLowerCase()
+}
+
+export const dedupeReportsByFileAndScene = (reports) => {
+  if (!Array.isArray(reports)) {
+    return []
+  }
+
+  const seen = new Set()
+
+  return reports.filter((report) => {
+    if (!report || report.type === 'category_title') {
+      return true
+    }
+
+    const fileName = normalizeReportKeyPart(getReportDisplayName(report), { fileName: true })
+    if (!fileName) {
+      return true
+    }
+
+    const sceneCategory = normalizeReportKeyPart(report.metadata?.secondary_category || '')
+    const key = `${fileName}\u0000${sceneCategory}`
+
+    if (seen.has(key)) {
+      return false
+    }
+
+    seen.add(key)
+    return true
+  })
+}
+
 export const hydratePersistedReports = (reports) => {
   if (!Array.isArray(reports)) {
     return []
@@ -25,7 +79,7 @@ export const hydratePersistedReports = (reports) => {
 }
 
 export const normalizeReportsForPersistence = (reports) => {
-  return hydratePersistedReports(reports)
+  return dedupeReportsByFileAndScene(hydratePersistedReports(reports))
 }
 
 const extractBalancedJson = (text) => {

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

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
 import {
   buildAIMessageUpdatePayload,
   buildPersistedAIMessageContent,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   hydratePersistedReports,
   normalizeReportsForPersistence,
@@ -140,6 +141,110 @@ describe('chatHistoryPersistence', () => {
     )
   })
 
+  it('keeps only the first report for the same displayed file name and scene category', () => {
+    const reports = [
+      {
+        file_index: 1,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T33000-2016).json',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T33000-2016)',
+          summary: 'first report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 2,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+          summary: 'duplicate report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 3,
+        status: 'completed',
+        source_file: '企业安全生产标准化基本规范',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '动火作业'
+        },
+        report: {
+          display_name: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+          summary: 'different scene report',
+          analysis: '',
+          clauses: ''
+        }
+      }
+    ]
+
+    expect(dedupeReportsByFileAndScene(reports).map(report => report.file_index)).toEqual([1, 3])
+  })
+
+  it('treats standard-code slash, underscore, spacing, and dash variants as the same file name', () => {
+    const reports = [
+      {
+        file_index: 3,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T33000-2016).json',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范(GB/T 33000—2016)',
+          summary: 'first report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 4,
+        status: 'completed',
+        source_file: '15-企业安全生产标准化基本规范(GB_T 33000-2016)',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '安全生产管理'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范(GB/T 33000-2016)',
+          summary: 'duplicate report',
+          analysis: '',
+          clauses: ''
+        }
+      },
+      {
+        file_index: 5,
+        status: 'completed',
+        source_file: '企业安全生产标准化基本规范',
+        metadata: {
+          primary_category: '国家规范',
+          secondary_category: '动火作业'
+        },
+        report: {
+          display_name: '企业安全生产标准化基本规范',
+          summary: 'different scene report',
+          analysis: '',
+          clauses: ''
+        }
+      }
+    ]
+
+    expect(dedupeReportsByFileAndScene(reports).map(report => report.file_index)).toEqual([3, 5])
+  })
+
   it('builds an update payload for completed non-typing messages', () => {
     const payload = buildAIMessageUpdatePayload({
       type: 'ai',

+ 51 - 0
shudao-vue-frontend/src/views/Chat.thinkingPanelOrder.test.js

@@ -0,0 +1,51 @@
+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 getTemplate = (source) => {
+  const templateStart = source.indexOf('<template>')
+  const templateEnd = source.lastIndexOf('</template>')
+  expect(templateStart).toBeGreaterThanOrEqual(0)
+  expect(templateEnd).toBeGreaterThan(templateStart)
+  return source.slice(templateStart, templateEnd)
+}
+
+const expectThinkingPanelBeforeAnswer = (source) => {
+  const template = getTemplate(source)
+  const responseStart = template.indexOf('<div class="ai-response-content">')
+  const thinkingPanel = template.indexOf('class="thinking-panel"', responseStart)
+  const questionSummary = template.indexOf('class="question-summary"', responseStart)
+  const aiText = template.indexOf('class="ai-text"', responseStart)
+
+  expect(responseStart).toBeGreaterThanOrEqual(0)
+  expect(thinkingPanel).toBeGreaterThan(responseStart)
+  expect(questionSummary).toBeGreaterThan(responseStart)
+  expect(aiText).toBeGreaterThan(responseStart)
+  expect(thinkingPanel).toBeLessThan(questionSummary)
+  expect(thinkingPanel).toBeLessThan(aiText)
+  expect(template.slice(thinkingPanel - 80, thinkingPanel)).toContain('v-if="message.thinkingContent"')
+}
+
+describe('Chat thinking panel order', () => {
+  it('renders desktop thinking content before summary and answer content', () => {
+    expectThinkingPanelBeforeAnswer(readView('Chat.vue'))
+  })
+
+  it('renders mobile thinking content before summary and answer content', () => {
+    expectThinkingPanelBeforeAnswer(readView('mobile/m-Chat.vue'))
+  })
+
+  it('updates mobile thinking content as soon as SSE thinking summaries arrive', () => {
+    const source = readView('mobile/m-Chat.vue')
+
+    expect(source).toContain('const appendThinkingContent =')
+    expect(source).toContain("appendThinkingContent(aiMessage, '意图分析', data.thinking_content)")
+    expect(source).toContain("appendThinkingContent(aiMessage, '正式回答', data.thinking_content)")
+  })
+})

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

@@ -219,18 +219,13 @@
                       
                       <div v-if="message.progress === 100 && message.reports && message.reports.length > 0" class="stats-right">
                         <ExportButton
-                          :reports="message.reports.filter(r => r.status === 'completed' && r.type !== 'category_title')"
+                          :reports="dedupeReportsByFileAndScene(message.reports).filter(r => r.status === 'completed' && r.type !== 'category_title')"
                           :disabled="false"
                           :title="exportTitle"
                         />
                     </div>
                     </div>
                     
-                    <!-- 问题总结 -->
-                    <div v-if="message.summary" class="question-summary">
-                      <StreamMarkdown :content="message.summary" :streaming="false" />
-                </div>
-
                     <!-- 思考过程 -->
                     <div v-if="message.thinkingContent" class="thinking-panel">
                       <button class="thinking-panel-header" @click="toggleThinkingPanel(message)">
@@ -244,6 +239,11 @@
                         <StreamMarkdown :content="message.thinkingContent" :streaming="false" />
                       </div>
                     </div>
+
+                    <!-- 问题总结 -->
+                    <div v-if="message.summary" class="question-summary">
+                      <StreamMarkdown :content="message.summary" :streaming="false" />
+                </div>
                     
                     <!-- 报告生成中的Loading动画 - 当还没有报告时显示 -->
                     <div v-if="message.isTyping && (!message.reports || message.reports.length === 0) && message.progress < 100" class="report-loading">
@@ -257,7 +257,7 @@
               
                     <!-- 报告列表 -->
                     <div v-if="message.reports && message.reports.length > 0" class="reports-list">
-                      <template v-for="(report, rIndex) in message.reports" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
+                      <template v-for="(report, rIndex) in dedupeReportsByFileAndScene(message.reports)" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
                         <!-- 类别标题 -->
                         <CategoryTitle
                           v-if="report.type === 'category_title'"
@@ -733,6 +733,7 @@ import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
   buildAIMessageUpdatePayload,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   hydratePersistedReports,
   normalizeReportsForPersistence,

+ 123 - 2
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -161,6 +161,20 @@
                 </div>
             </div>
             
+                  <!-- 思考过程 -->
+                  <div v-if="message.thinkingContent" class="thinking-panel">
+                    <button class="thinking-panel-header" @click="toggleThinkingPanel(message)">
+                      <div class="thinking-panel-title">
+                        <span class="thinking-panel-badge">{{ message.showThinking !== false ? '已思考' : '思考过程' }}</span>
+                        <span class="thinking-panel-label">模型思考过程</span>
+                      </div>
+                      <span class="thinking-panel-arrow">{{ message.showThinking !== false ? '收起' : '展开' }}</span>
+                    </button>
+                    <div v-show="message.showThinking !== false" class="thinking-panel-body">
+                      <StreamMarkdown :content="message.thinkingContent" :streaming="false" />
+                    </div>
+                  </div>
+
                   <!-- 问题总结 -->
                   <div v-if="message.summary" class="question-summary">
                     <StreamMarkdown :content="message.summary" :streaming="false" />
@@ -176,9 +190,9 @@
                     </div>
                   </div>
                   
-                  <!-- 报告列表 -->
+              <!-- 报告列表 -->
               <div v-if="message.reports && message.reports.length > 0" class="reports-list">
-                <template v-for="(report, rIndex) in message.reports" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
+                <template v-for="(report, rIndex) in dedupeReportsByFileAndScene(message.reports)" :key="`${report.source_file}-${report.file_index}-${rIndex}`">
                   <!-- 类别标题 -->
                   <CategoryTitle
                     v-if="report.type === 'category_title'"
@@ -472,6 +486,7 @@ import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
 import {
   buildAIMessageUpdatePayload,
+  dedupeReportsByFileAndScene,
   extractRelatedQuestions,
   hydratePersistedReports,
   normalizeReportsForPersistence,
@@ -1264,6 +1279,7 @@ const getConversationMessages = async (conversationId) => {
         let displayContent = userContent || ''
         let reports = []
         let summary = message.summary || '' // 从后端恢复summary字段
+        let thinkingContent = message.thinkingContent || message.thinking_content || ''
         
         if (message.type === 'ai') {
           try {
@@ -1289,6 +1305,19 @@ const getConversationMessages = async (conversationId) => {
                   if (parsedContent.summary) {
                     summary = parsedContent.summary
                   }
+                  if (parsedContent.thinkingContent) {
+                    thinkingContent = parsedContent.thinkingContent
+                  }
+                } else if (parsedContent.answer || parsedContent.content || parsedContent.thinkingContent) {
+                  if (parsedContent.thinkingContent) {
+                    thinkingContent = parsedContent.thinkingContent
+                  }
+                  const answerContent = parsedContent.answer || parsedContent.content || ''
+                  const processedContent = String(answerContent)
+                    .replace(/\\n/g, '\n')
+                    .replace(/\\t/g, '\t')
+                    .replace(/\\r/g, '\r')
+                  displayContent = renderMarkdownContent(processedContent)
                 } else if (Array.isArray(parsedContent)) {
                   // 旧格式,直接是reports数组
                   reports = hydratePersistedReports(parsedContent)
@@ -1338,6 +1367,8 @@ const getConversationMessages = async (conversationId) => {
             displayContent: displayContent,
           reports: reports, // 添加reports数组
           summary: summary, // 添加summary字段
+          thinkingContent: thinkingContent,
+          showThinking: Boolean(thinkingContent),
           totalFiles: totalFiles, // 总文件数
           completedCount: completedCount, // 完成数
           progress: progress, // 进度
@@ -2347,6 +2378,27 @@ const startReportFieldTypewriter = (report, field, fullContent, speed = 50) => {
   })
 }
 
+const appendThinkingContent = (aiMessage, sectionTitle, content) => {
+  const normalized = (content || '').trim()
+  if (!normalized) return
+
+  const section = sectionTitle
+    ? `### ${sectionTitle}\n\n${normalized}`
+    : normalized
+
+  if (!aiMessage.thinkingContent) {
+    aiMessage.thinkingContent = section
+  } else if (!aiMessage.thinkingContent.includes(section)) {
+    aiMessage.thinkingContent = `${aiMessage.thinkingContent}\n\n---\n\n${section}`
+  }
+
+  aiMessage.showThinking = true
+}
+
+const toggleThinkingPanel = (message) => {
+  message.showThinking = message.showThinking === false
+}
+
 // SSE消息处理函数
 const handleSSEMessage = (data, aiMessageIndex) => {
   const aiMessage = chatMessages.value[aiMessageIndex]
@@ -2392,6 +2444,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
   switch (data.type) {
     case 'intent':
       aiMessage.isProfessionalQuestion = data.is_professional_question !== false
+      appendThinkingContent(aiMessage, '意图分析', data.thinking_content)
       // 检查是否为专业问题
       if (data.is_professional_question === false) {
         // 非专业问题:立即隐藏状态显示组件
@@ -2437,6 +2490,7 @@ const handleSSEMessage = (data, aiMessageIndex) => {
         aiMessage.summary = ''
         aiMessage._fullSummary = ''
       }
+      appendThinkingContent(aiMessage, '正式回答', data.thinking_content)
 
       const finalContent = data.content || ''
       aiMessage.content = finalContent
@@ -3149,6 +3203,8 @@ const handleReportGeneratorSubmit = async (data) => {
     isTyping: true,
     content: '',
     displayContent: '',
+    thinkingContent: '',
+    showThinking: true,
     timestamp: new Date().toISOString(),
     // 新增:状态管理
     currentStatus: 'querying_kb', // 当前状态
@@ -4732,6 +4788,71 @@ onActivated(async () => {
     line-height: 1.8;
     color: #606266;
   }
+
+  .thinking-panel {
+    margin-bottom: 12px;
+    background: #f8fafc;
+    border: 1px solid #e5e7eb;
+    border-radius: 8px;
+    overflow: hidden;
+  }
+
+  .thinking-panel-header {
+    width: 100%;
+    padding: 10px 12px;
+    border: none;
+    background: transparent;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    cursor: pointer;
+    text-align: left;
+  }
+
+  .thinking-panel-title {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    min-width: 0;
+  }
+
+  .thinking-panel-badge {
+    flex-shrink: 0;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    padding: 2px 8px;
+    border-radius: 999px;
+    background: #e8f0ff;
+    color: #3b82f6;
+    font-size: 12px;
+    font-weight: 600;
+    line-height: 20px;
+  }
+
+  .thinking-panel-label {
+    color: #374151;
+    font-size: 13px;
+    font-weight: 500;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .thinking-panel-arrow {
+    flex-shrink: 0;
+    color: #6b7280;
+    font-size: 12px;
+  }
+
+  .thinking-panel-body {
+    padding: 0 12px 12px;
+    color: #4b5563;
+    font-size: 13px;
+    line-height: 1.7;
+    border-top: 1px solid #eef2f7;
+  }
   
   .reports-list {
     margin-top: 12px;