Przeglądaj źródła

polish chat streaming and file preview UI

asuka-4908 6 godzin temu
rodzic
commit
364985bacd

+ 1 - 2
shudao-vue-frontend/src/components/FilePreviewDrawer.vue

@@ -71,7 +71,6 @@
 <script setup>
 import { ref, watch, onBeforeUnmount } from 'vue'
 import { Document, Loading, CircleClose } from '@element-plus/icons-vue'
-import { buildPreviewUrl } from '@/utils/filePreviewUrl'
 
 const props = defineProps({
   modelValue: {
@@ -151,7 +150,7 @@ const loadFile = async () => {
   
   try {
     const originalUrl = props.filePath
-    const convertedUrl = await buildPreviewUrl(originalUrl)
+    const convertedUrl = originalUrl
     if (!convertedUrl) {
       throw new Error('Empty preview URL')
     }

+ 7 - 11
shudao-vue-frontend/src/components/FileReportCard.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="file-reference-item" :class="statusClass">
     <div class="file-header">
-      <div class="file-title-wrapper" @click="openFile" :class="{ 'clickable': report.file_path }">
+      <div class="file-title-wrapper" @click="openFile" :class="{ 'clickable': previewSource }">
         <el-icon class="file-icon"><Document /></el-icon>
         <span class="file-name">{{ report.report?.display_name || report.source_file }}</span>
       </div>
@@ -88,6 +88,7 @@ import { computed, ref } from 'vue'
 import { ElMessage, ElDialog } from 'element-plus'
 import { Document, CopyDocument, Link, WarningFilled } from '@element-plus/icons-vue'
 import StreamMarkdown from './StreamMarkdown.vue'
+import { getReportPreviewSource } from '@/utils/filePreviewUrl'
 
 const props = defineProps({
   report: {
@@ -175,6 +176,8 @@ const briefSummary = computed(() => {
   return props.report?.report?.summary || props.report?._fullContent?.summary || '暂无摘要'
 })
 
+const previewSource = computed(() => getReportPreviewSource(props.report))
+
 // 获取文件来源URL
 const sourceUrl = computed(() => {
   // 调试日志:查看report对象结构
@@ -187,14 +190,7 @@ const sourceUrl = computed(() => {
   })
   
   // 尝试从多个可能的字段获取URL
-  const url = props.report.metadata?.source_url || 
-         props.report.metadata?.url || 
-         props.report.metadata?.link ||
-         props.report.metadata?.file_url ||
-         props.report.source_url ||
-         props.report.url ||
-         props.report.link ||
-         null
+  const url = previewSource.value || null
   
   console.log('🔗 [DEBUG] 找到的URL:', url)
   return url
@@ -225,10 +221,10 @@ const openSourceUrl = () => {
 }
 
 const openFile = () => {
-  if (props.report.file_path) {
+  if (previewSource.value) {
     const fileName = props.report.report?.display_name || props.report.source_file || '未命名文件'
     emit('preview-file', {
-      filePath: props.report.file_path,
+      filePath: previewSource.value,
       fileName: fileName
     })
   }

+ 5 - 2
shudao-vue-frontend/src/components/MobileFileReportCard.vue

@@ -34,6 +34,7 @@
 
 <script setup>
 import { computed } from 'vue'
+import { getReportPreviewSource } from '@/utils/filePreviewUrl'
 
 const props = defineProps({
   report: {
@@ -81,6 +82,8 @@ const briefSummary = computed(() => {
   return props.report?.report?.summary || props.report?._fullContent?.summary || ''
 })
 
+const previewSource = computed(() => getReportPreviewSource(props.report))
+
 const hasMetaRow = computed(() => {
   return props.report.metadata?.primary_category || 
          props.report.metadata?.secondary_category || 
@@ -90,10 +93,10 @@ const hasMetaRow = computed(() => {
 })
 
 const openFile = () => {
-  if (props.report.file_path) {
+  if (previewSource.value) {
     const fileName = props.report.report?.display_name || props.report.source_file || '未命名文件'
     emit('preview-file', {
-      filePath: props.report.file_path,
+      filePath: previewSource.value,
       fileName: fileName
     })
   }

+ 38 - 0
shudao-vue-frontend/src/utils/filePreviewUrl.js

@@ -34,6 +34,35 @@ const buildFileProxyViewUrl = (encryptedUrl) => (
   `${REPORT_API_PREFIX}${FILE_PROXY_VIEW_PATH}?url=${encodeURIComponent(encryptedUrl)}`
 )
 
+export const getReportPreviewSource = (report = {}) => {
+  const metadata = report?.metadata || {}
+  const candidates = [
+    report.file_path,
+    report.filePath,
+    report.file_url,
+    report.fileUrl,
+    report.preview_url,
+    report.previewUrl,
+    metadata.file_path,
+    metadata.filePath,
+    metadata.file_url,
+    metadata.fileUrl,
+    metadata.preview_url,
+    metadata.previewUrl,
+    metadata.oss_url,
+    metadata.ossUrl
+  ]
+
+  for (const candidate of candidates) {
+    const value = String(candidate || '').trim()
+    if (value) {
+      return value
+    }
+  }
+
+  return ''
+}
+
 const convertKnownProxyUrl = (rawUrl) => {
   const pathAndSearch = getPathAndSearch(rawUrl)
   if (!pathAndSearch) {
@@ -98,3 +127,12 @@ export async function buildPreviewUrl(rawUrl) {
 
   return normalized
 }
+
+export function buildPreviewUrlSync(rawUrl) {
+  const normalized = String(rawUrl || '').trim()
+  if (!normalized) {
+    return ''
+  }
+
+  return convertKnownProxyUrl(normalized) || normalized
+}

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

@@ -833,6 +833,7 @@ import WebSearchCapsule from '@/components/WebSearchCapsule.vue'
 import WebSearchSidebar from '@/components/WebSearchSidebar.vue'
 import WebSearchSummary from '@/components/WebSearchSummary.vue'
 import StatusAvatar from '@/components/StatusAvatar.vue'
+import { buildPreviewUrl, buildPreviewUrlSync } from '@/utils/filePreviewUrl'
 import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
 import { getApiPrefix } from '@/utils/apiConfig'
 import { synthesizeSpeechToObjectUrl } from '@/services/speechService'
@@ -1657,8 +1658,8 @@ const typewriterIntervals = new Map() // 存储每个消息的打字机定时器
 const reportTypewriters = new Map() // 存储每个报告字段的打字机定时器
 const streamingAnswerTypewriters = new Map() // 存储流式回答的平滑渲染定时器
 const databaseFileRevealTimers = new Map() // 存储召回文件逐条显现定时器
-const STREAMING_ANSWER_CHARS_PER_FRAME = 2
-const STREAMING_ANSWER_FRAME_MS = 1000 / 36
+const STREAMING_ANSWER_CHARS_PER_SECOND = 78
+const STREAMING_ANSWER_FRAME_MS = 1000 / 30
 const FINAL_ANSWER_TYPEWRITER_SPEED = 80
 const DATABASE_FILE_REVEAL_INTERVAL_MS = 320
 
@@ -1808,14 +1809,22 @@ const ensureStreamingAnswerTypewriter = (message) => {
   }
 
   message._visibleAnswerLength = Number(message._visibleAnswerLength || 0)
+  message._answerTypewriterCarry = Number(message._answerTypewriterCarry || 0)
+  message._answerTypewriterLastTick = Date.now()
   const interval = setInterval(() => {
     const rawContent = String(message.content || '')
     const targetLength = rawContent.length
 
     if (message._visibleAnswerLength < targetLength) {
+      const now = Date.now()
+      const elapsed = Math.max(0, now - (message._answerTypewriterLastTick || now))
+      message._answerTypewriterLastTick = now
+      const exactChars = (elapsed / 1000) * STREAMING_ANSWER_CHARS_PER_SECOND + Number(message._answerTypewriterCarry || 0)
+      const charsToReveal = Math.max(1, Math.floor(exactChars))
+      message._answerTypewriterCarry = Math.max(0, exactChars - charsToReveal)
       message._visibleAnswerLength = Math.min(
         targetLength,
-        message._visibleAnswerLength + STREAMING_ANSWER_CHARS_PER_FRAME
+        message._visibleAnswerLength + charsToReveal
       )
       renderAnswerPrefix(message, message._visibleAnswerLength)
       return
@@ -3412,6 +3421,11 @@ const ensureThinkingFallback = (aiMessage) => {
   aiMessage.showThinking = true
 }
 
+const buildInitialThinkingContent = (question) => buildDisplayThinkingSummary({
+  userQuestion: question || currentQuestion.value || '',
+  rawThinking: question || currentQuestion.value || ''
+})
+
 const appendThinkingContent = (aiMessage, sectionTitle, content) => {
   const normalized = normalizeDisplayThinkingContent(content)
   if (!normalized) return
@@ -3580,6 +3594,15 @@ const handleSSEMessage = (data, aiMessageIndex) => {
 
       if (data.thinking_content) {
         aiMessage.rawIntentThinkingContent = data.thinking_content
+        if (!aiMessage.rawThinkingContent) {
+          aiMessage.thinkingContent = buildDisplayThinkingSummary({
+            rawThinking: data.thinking_content,
+            existingThinking: '',
+            userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+            summary: data.summary || aiMessage._fullSummary || aiMessage.summary || ''
+          })
+          aiMessage.showThinking = true
+        }
       }
 
       // 如果启用联网搜索,稍后会更新为web_searching状态
@@ -4406,15 +4429,16 @@ const handleReportGeneratorSubmit = async (data) => {
   
   // 添加AI消息占位符
   const aiMessageIndex = chatMessages.value.length
+  const initialThinkingContent = buildInitialThinkingContent(data.question)
   chatMessages.value.push({
     id: Date.now() + 1,
     type: 'ai',
     userQuestion: data.question, // 用户问题
     summary: '',
     isProfessionalQuestion: null,
-    thinkingContent: '',
+    thinkingContent: initialThinkingContent,
     showThinking: true,
-    thinkingStreaming: false,
+    thinkingStreaming: true,
     answerStreaming: false,
     totalFiles: 0,
     webSearchTotal: 0,
@@ -5989,15 +6013,22 @@ const showLinkInIframe = (url) => {
 }
 
 // 文件预览处理函数
-const handleFilePreview = (data) => {
-  if (typeof data === 'string') {
-    previewFilePath.value = data
-    previewFileName.value = ''
-  } else {
-    previewFilePath.value = data.filePath
-    previewFileName.value = data.fileName || ''
-  }
+const handleFilePreview = async (data) => {
+  const rawPath = typeof data === 'string' ? data : data?.filePath
+  const fileName = typeof data === 'string' ? '' : (data?.fileName || '')
+
+  previewFilePath.value = buildPreviewUrlSync(rawPath)
+  previewFileName.value = fileName
   showFilePreview.value = true
+
+  try {
+    const convertedPath = await buildPreviewUrl(rawPath)
+    if (convertedPath && convertedPath !== previewFilePath.value) {
+      previewFilePath.value = convertedPath
+    }
+  } catch (error) {
+    console.warn('文件预览链接转换失败,使用原始链接:', error)
+  }
 }
 
 // 处理网络搜索胶囊点击

+ 42 - 8
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -489,7 +489,7 @@ import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
 import { getApiPrefix, BACKEND_API_PREFIX } from '@/utils/apiConfig'
 import { renderMarkdown } from '@/utils/markdown'
 import { stopSSEStream, updateAIMessageContent } from '@/utils/api.js'
-import { buildPreviewUrl } from '@/utils/filePreviewUrl'
+import { buildPreviewUrl, buildPreviewUrlSync } from '@/utils/filePreviewUrl'
 import {
   applyReportChunkToMessage,
   buildDisplayThinkingSummary,
@@ -710,8 +710,8 @@ const reportTypewriters = new Map() // 存储每个报告字段的打字机定
 const typewriterIntervals = new Map() // 存储打字机定时器
 const streamingAnswerTypewriters = new Map() // 存储流式回答的平滑渲染定时器
 const databaseFileRevealTimers = new Map() // 存储召回文件逐条显现定时器
-const STREAMING_ANSWER_CHARS_PER_FRAME = 2
-const STREAMING_ANSWER_FRAME_MS = 1000 / 36
+const STREAMING_ANSWER_CHARS_PER_SECOND = 78
+const STREAMING_ANSWER_FRAME_MS = 1000 / 30
 const FINAL_ANSWER_TYPEWRITER_SPEED = 80
 const DATABASE_FILE_REVEAL_INTERVAL_MS = 320
 
@@ -2295,12 +2295,14 @@ const handleFilePreview = async (data) => {
   fileError.value = ''
   fileLoading.value = false
 
+  const rawPath = typeof data === 'string' ? data : data?.filePath
+
   // 处理不同类型的输入参数
   if (typeof data === 'string') {
-    previewFilePath.value = await buildPreviewUrl(data)
+    previewFilePath.value = buildPreviewUrlSync(data)
     previewFileName.value = data
   } else if (data && data.filePath) {
-    previewFilePath.value = await buildPreviewUrl(data.filePath)
+    previewFilePath.value = buildPreviewUrlSync(data.filePath)
     previewFileName.value = data.fileName || data.filePath
   } else {
     fileError.value = '文件路径为空'
@@ -2317,6 +2319,15 @@ const handleFilePreview = async (data) => {
   }
   
   showFilePreview.value = true
+
+  try {
+    const convertedPath = await buildPreviewUrl(rawPath)
+    if (convertedPath && convertedPath !== previewFilePath.value) {
+      previewFilePath.value = convertedPath
+    }
+  } catch (error) {
+    console.warn('文件预览链接转换失败,使用原始链接:', error)
+  }
 }
 
 const toggleNetworkSearch = () => {
@@ -2701,14 +2712,22 @@ const ensureStreamingAnswerTypewriter = (message) => {
   }
 
   message._visibleAnswerLength = Number(message._visibleAnswerLength || 0)
+  message._answerTypewriterCarry = Number(message._answerTypewriterCarry || 0)
+  message._answerTypewriterLastTick = Date.now()
   const interval = setInterval(() => {
     const rawContent = String(message.content || '')
     const targetLength = rawContent.length
 
     if (message._visibleAnswerLength < targetLength) {
+      const now = Date.now()
+      const elapsed = Math.max(0, now - (message._answerTypewriterLastTick || now))
+      message._answerTypewriterLastTick = now
+      const exactChars = (elapsed / 1000) * STREAMING_ANSWER_CHARS_PER_SECOND + Number(message._answerTypewriterCarry || 0)
+      const charsToReveal = Math.max(1, Math.floor(exactChars))
+      message._answerTypewriterCarry = Math.max(0, exactChars - charsToReveal)
       message._visibleAnswerLength = Math.min(
         targetLength,
-        message._visibleAnswerLength + STREAMING_ANSWER_CHARS_PER_FRAME
+        message._visibleAnswerLength + charsToReveal
       )
       renderAnswerPrefix(message, message._visibleAnswerLength)
       return
@@ -2926,6 +2945,11 @@ const finalizeThinkingContent = (aiMessage) => {
   aiMessage.showThinking = Boolean(aiMessage.thinkingContent)
 }
 
+const buildInitialThinkingContent = (question) => buildDisplayThinkingSummary({
+  userQuestion: question || currentQuestion.value || '',
+  rawThinking: question || currentQuestion.value || ''
+})
+
 const toggleThinkingPanel = (message) => {
   message.showThinking = message.showThinking === false
 }
@@ -3029,6 +3053,15 @@ const handleSSEMessage = (data, aiMessageIndex) => {
 
       if (data.thinking_content) {
         aiMessage.rawIntentThinkingContent = data.thinking_content
+        if (!aiMessage.rawThinkingContent) {
+          aiMessage.thinkingContent = buildDisplayThinkingSummary({
+            rawThinking: data.thinking_content,
+            existingThinking: '',
+            userQuestion: aiMessage.userQuestion || currentQuestion.value || '',
+            summary: data.summary || aiMessage._fullSummary || aiMessage.summary || ''
+          })
+          aiMessage.showThinking = true
+        }
       }
 
       // 如果启用联网搜索,稍后会更新为web_searching状态
@@ -3831,6 +3864,7 @@ const handleReportGeneratorSubmit = async (data) => {
   
   // 添加AI消息占位符
   const aiMessageIndex = chatMessages.value.length
+  const initialThinkingContent = buildInitialThinkingContent(data.question)
   chatMessages.value.push({
     id: Date.now() + 1,
     type: 'ai',
@@ -3845,9 +3879,9 @@ const handleReportGeneratorSubmit = async (data) => {
     isTyping: true,
     content: '',
     displayContent: '',
-    thinkingContent: '',
+    thinkingContent: initialThinkingContent,
     showThinking: true,
-    thinkingStreaming: false,
+    thinkingStreaming: true,
     answerStreaming: false,
     timestamp: new Date().toISOString(),
     // 新增:状态管理