Просмотр исходного кода

添加换行功能、文件上传、文件识别功能(还要完善)

zkn 4 недель назад
Родитель
Сommit
4ce49a4673

+ 11 - 1
shudao-chat-py/models/report.py

@@ -2,7 +2,16 @@
 报告相关数据模型
 报告相关数据模型
 """
 """
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
-from typing import Optional
+from typing import List, Optional
+
+
+class UploadedDocumentContext(BaseModel):
+    """用户上传文档上下文"""
+    file_name: str = Field(..., description="上传文件名")
+    file_type: str = Field(default="", description="文件类型")
+    content: str = Field(..., description="后端提取的纯文本内容")
+    attachment_id: Optional[str] = Field(default=None, description="附件解析ID")
+    char_count: Optional[int] = Field(default=None, description="提取文本字符数")
 
 
 
 
 class ReportCompleteFlowRequest(BaseModel):
 class ReportCompleteFlowRequest(BaseModel):
@@ -14,6 +23,7 @@ class ReportCompleteFlowRequest(BaseModel):
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     is_network_search_enabled: bool = Field(default=False, description="是否启用联网搜索")
     is_network_search_enabled: bool = Field(default=False, description="是否启用联网搜索")
     enable_online_model: bool = Field(default=False, description="是否启用在线模型")
     enable_online_model: bool = Field(default=False, description="是否启用在线模型")
+    uploaded_documents: List[UploadedDocumentContext] = Field(default_factory=list, description="用户上传文档上下文")
 
 
 
 
 class UpdateAIMessageRequest(BaseModel):
 class UpdateAIMessageRequest(BaseModel):

+ 33 - 2
shudao-chat-py/routers/report_compat.py

@@ -2,7 +2,7 @@
 报告兼容路由
 报告兼容路由
 完全对齐 Go 版本的接口实现,保持外部一致性
 完全对齐 Go 版本的接口实现,保持外部一致性
 """
 """
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, File, Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from fastapi.responses import StreamingResponse, JSONResponse
 import httpx
 import httpx
 import json
 import json
@@ -72,6 +72,24 @@ def _build_aichat_complete_flow_body(
     return json.dumps(payload, ensure_ascii=False).encode("utf-8")
     return json.dumps(payload, ensure_ascii=False).encode("utf-8")
 
 
 
 
+def _augment_message_with_uploaded_documents(message: str, uploaded_documents) -> str:
+    parts = [message]
+    for item in uploaded_documents or []:
+        content = (item.content or "").strip()
+        if not item.file_name or not content:
+            continue
+        parts.append(
+            "\n".join([
+                "【用户上传文档】",
+                f"文件名:{item.file_name}",
+                f"文件类型:{item.file_type or '未知'}",
+                "文档内容:",
+                content,
+            ])
+        )
+    return "\n\n".join(parts)
+
+
 async def fallback_to_local_stream(
 async def fallback_to_local_stream(
     request_data: ReportCompleteFlowRequest,
     request_data: ReportCompleteFlowRequest,
     request: Request
     request: Request
@@ -80,7 +98,10 @@ async def fallback_to_local_stream(
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
 
 
     stream_request = StreamChatRequest(
     stream_request = StreamChatRequest(
-        message=request_data.user_question,
+        message=_augment_message_with_uploaded_documents(
+            request_data.user_question,
+            request_data.uploaded_documents,
+        ),
         ai_conversation_id=request_data.ai_conversation_id,
         ai_conversation_id=request_data.ai_conversation_id,
         business_type=0
         business_type=0
     )
     )
@@ -212,6 +233,16 @@ async def complete_flow(request: Request):
     return await fallback_to_local_stream(request_data, request)
     return await fallback_to_local_stream(request_data, request)
 
 
 
 
+@router.post("/attachments/parse")
+async def parse_attachment(request: Request, file: UploadFile = File(...)):
+    """Parse an uploaded attachment through aichat."""
+    user = getattr(request.state, "user", None)
+    if not user:
+        return JSONResponse(content={"statusCode": 401, "msg": "未授权"}, status_code=401)
+
+    return await aichat_proxy.proxy_upload("/attachments/parse", request, file)
+
+
 @router.post("/report/update-ai-message")
 @router.post("/report/update-ai-message")
 async def update_ai_message(request: Request):
 async def update_ai_message(request: Request):
     """
     """

+ 47 - 1
shudao-chat-py/services/aichat_proxy.py

@@ -4,7 +4,7 @@ AIChat 代理服务
 """
 """
 import httpx
 import httpx
 from typing import AsyncGenerator, Optional
 from typing import AsyncGenerator, Optional
-from fastapi import Request
+from fastapi import Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from fastapi.responses import StreamingResponse, JSONResponse
 from utils.config import settings
 from utils.config import settings
 from utils.logger import logger
 from utils.logger import logger
@@ -144,6 +144,52 @@ class AIChatProxy:
                 status_code=500
                 status_code=500
             )
             )
 
 
+    async def proxy_upload(
+        self,
+        path: str,
+        request: Request,
+        upload_file: UploadFile,
+        field_name: str = "file",
+    ) -> JSONResponse:
+        """Proxy a multipart upload to aichat."""
+        url = f"{self.base_url}{path}"
+        headers = self._get_auth_headers(request)
+
+        logger.info(f"[AIChat代理] 文件上传请求: {url}, filename={upload_file.filename}")
+
+        try:
+            content = await upload_file.read()
+            files = {
+                field_name: (
+                    upload_file.filename or "upload",
+                    content,
+                    upload_file.content_type or "application/octet-stream",
+                )
+            }
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.post(url, files=files, headers=headers)
+
+            try:
+                response_content = response.json()
+            except Exception:
+                response_content = {"success": False, "message": response.text}
+            return JSONResponse(
+                content=response_content,
+                status_code=response.status_code
+            )
+        except httpx.TimeoutException:
+            logger.error("[AIChat代理] 文件上传请求超时")
+            return JSONResponse(
+                content={"statusCode": 504, "msg": "AIChat服务请求超时"},
+                status_code=504
+            )
+        except Exception as e:
+            logger.error(f"[AIChat代理] 文件上传请求异常: {e}")
+            return JSONResponse(
+                content={"statusCode": 500, "msg": f"AIChat服务异常: {str(e)}"},
+                status_code=500
+            )
+
     async def health_check(self) -> bool:
     async def health_check(self) -> bool:
         """
         """
         检查 aichat 服务健康状态
         检查 aichat 服务健康状态

+ 11 - 1
shudao-vue-frontend/src/components/QuestionInput.vue

@@ -41,7 +41,7 @@
             :autosize="{ minRows: 1, maxRows: 4 }"
             :autosize="{ minRows: 1, maxRows: 4 }"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             :disabled="loading"
             :disabled="loading"
-            @keydown.enter.exact.prevent="handleSubmit"
+            @keydown.enter="handleEnterKey"
             class="message-input"
             class="message-input"
           />
           />
           
           
@@ -69,6 +69,7 @@
 <script setup>
 <script setup>
 import { reactive, ref } from 'vue'
 import { reactive, ref } from 'vue'
 import { Position, Link, Paperclip, Microphone, Setting, Document, Files } from '@element-plus/icons-vue'
 import { Position, Link, Paperclip, Microphone, Setting, Document, Files } from '@element-plus/icons-vue'
+import { handleChatInputEnterKey } from '@/utils/chatInputKeydown.js'
 
 
 const emit = defineEmits(['submit', 'cancel'])
 const emit = defineEmits(['submit', 'cancel'])
 
 
@@ -111,6 +112,15 @@ const handleSubmit = () => {
   })
   })
 }
 }
 
 
+const handleEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSubmit,
+    updateValue: (value) => {
+      form.question = value
+    }
+  })
+}
+
 const handleCancel = () => {
 const handleCancel = () => {
   emit('cancel')
   emit('cancel')
 }
 }

+ 3 - 0
shudao-vue-frontend/src/request/apis.js

@@ -23,6 +23,9 @@ export const apis = {
 
 
   //上传oss
   //上传oss
   uploadOss: (data) => request.post('/oss/upload', data),
   uploadOss: (data) => request.post('/oss/upload', data),
+
+  // 解析AI问答上传附件
+  parseAttachment: (data) => request.post('/attachments/parse', data),
   
   
   // 获取功能卡片
   // 获取功能卡片
   getFunctionCard: (params) => request.get('/get_function_card', { params }),
   getFunctionCard: (params) => request.get('/get_function_card', { params }),

+ 19 - 0
shudao-vue-frontend/src/utils/attachmentContext.js

@@ -0,0 +1,19 @@
+export function buildUploadedDocumentPayload(file) {
+  if (!file || typeof file.content !== 'string' || !file.content.trim()) {
+    return null
+  }
+
+  const normalizedType = String(file.type || file.fileType || '')
+    .replace(/^\./, '')
+    .toLowerCase()
+
+  return {
+    file_name: file.name || file.fileName || '',
+    file_type: normalizedType,
+    content: file.content,
+    attachment_id: file.attachmentId || file.attachment_id || '',
+    char_count: Number.isFinite(Number(file.charCount ?? file.char_count))
+      ? Number(file.charCount ?? file.char_count)
+      : file.content.length
+  }
+}

+ 28 - 0
shudao-vue-frontend/src/utils/attachmentContext.test.js

@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+
+import { buildUploadedDocumentPayload } from './attachmentContext'
+
+describe('attachmentContext', () => {
+  it('builds report uploaded document payload from parsed attachment', () => {
+    const file = {
+      name: '专项施工方案.pdf',
+      type: '.pdf',
+      content: '第一章 总则\n第二章 安全措施',
+      attachmentId: 'sha256:abc',
+      charCount: 18
+    }
+
+    expect(buildUploadedDocumentPayload(file)).toEqual({
+      file_name: '专项施工方案.pdf',
+      file_type: 'pdf',
+      content: '第一章 总则\n第二章 安全措施',
+      attachment_id: 'sha256:abc',
+      char_count: 18
+    })
+  })
+
+  it('returns null when no parsed text is available', () => {
+    expect(buildUploadedDocumentPayload(null)).toBeNull()
+    expect(buildUploadedDocumentPayload({ name: 'empty.pdf', content: '   ' })).toBeNull()
+  })
+})

+ 10 - 0
shudao-vue-frontend/src/utils/attachmentFile.js

@@ -0,0 +1,10 @@
+const UNIFIED_DOCUMENT_TYPES = new Set(['.doc', '.docx', '.pdf', '.ppt', '.pptx', '.txt'])
+
+export function getAttachmentCardIcon(fileType, documentIcon, fallbackIcon = '') {
+  const normalizedType = String(fileType || '')
+    .trim()
+    .toLowerCase()
+  const extension = normalizedType.startsWith('.') ? normalizedType : `.${normalizedType}`
+
+  return UNIFIED_DOCUMENT_TYPES.has(extension) ? documentIcon : fallbackIcon
+}

+ 18 - 0
shudao-vue-frontend/src/utils/attachmentFile.test.js

@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest'
+
+import { getAttachmentCardIcon } from './attachmentFile'
+
+describe('attachmentFile', () => {
+  it('uses the document card icon for supported upload formats', () => {
+    const documentIcon = '/assets/doc-card.png'
+
+    expect(getAttachmentCardIcon('.docx', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('pdf', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('.pptx', documentIcon)).toBe(documentIcon)
+    expect(getAttachmentCardIcon('.txt', documentIcon)).toBe(documentIcon)
+  })
+
+  it('uses fallback icon for unknown formats', () => {
+    expect(getAttachmentCardIcon('.zip', '/assets/doc-card.png', 'fallback')).toBe('fallback')
+  })
+})

+ 32 - 0
shudao-vue-frontend/src/utils/chatInputKeydown.js

@@ -0,0 +1,32 @@
+export const insertNewlineAtCursor = (target) => {
+  const value = target?.value || ''
+  const start = Number.isInteger(target?.selectionStart) ? target.selectionStart : value.length
+  const end = Number.isInteger(target?.selectionEnd) ? target.selectionEnd : start
+  const nextValue = `${value.slice(0, start)}\n${value.slice(end)}`
+  const nextCursor = start + 1
+
+  target.value = nextValue
+  target?.setSelectionRange?.(nextCursor, nextCursor)
+
+  return nextValue
+}
+
+export const handleChatInputEnterKey = (event, { submit, updateValue } = {}) => {
+  if (event?.isComposing) return
+
+  const shouldInsertNewline = event?.ctrlKey || event?.metaKey
+  const shouldSubmit = !event?.shiftKey && !event?.altKey && !shouldInsertNewline
+
+  if (!shouldInsertNewline && !shouldSubmit) return
+
+  event?.preventDefault?.()
+
+  if (shouldInsertNewline) {
+    const nextValue = insertNewlineAtCursor(event.target)
+    updateValue?.(nextValue)
+    event.target?.dispatchEvent?.(new Event('input', { bubbles: true }))
+    return
+  }
+
+  submit?.()
+}

+ 75 - 0
shudao-vue-frontend/src/utils/chatInputKeydown.test.js

@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from 'vitest'
+import { handleChatInputEnterKey } from './chatInputKeydown'
+
+const createEvent = ({ ctrlKey = false, value = 'hello', start = value.length, end = start } = {}) => {
+  const target = {
+    value,
+    selectionStart: start,
+    selectionEnd: end,
+    setSelectionRange: vi.fn(function setSelectionRange(nextStart, nextEnd) {
+      this.selectionStart = nextStart
+      this.selectionEnd = nextEnd
+    }),
+    dispatchEvent: vi.fn()
+  }
+
+  return {
+    key: 'Enter',
+    ctrlKey,
+    metaKey: false,
+    shiftKey: false,
+    altKey: false,
+    target,
+    preventDefault: vi.fn(),
+    stopPropagation: vi.fn()
+  }
+}
+
+describe('handleChatInputEnterKey', () => {
+  it('submits on plain Enter without inserting a newline', () => {
+    const event = createEvent()
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(event.preventDefault).toHaveBeenCalledOnce()
+    expect(submit).toHaveBeenCalledOnce()
+    expect(updateValue).not.toHaveBeenCalled()
+    expect(event.target.value).toBe('hello')
+  })
+
+  it('inserts a newline at the cursor on Ctrl Enter without submitting', () => {
+    const event = createEvent({ ctrlKey: true, value: 'helloworld', start: 5, end: 5 })
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(event.preventDefault).toHaveBeenCalledOnce()
+    expect(submit).not.toHaveBeenCalled()
+    expect(updateValue).toHaveBeenCalledWith('hello\nworld')
+    expect(event.target.value).toBe('hello\nworld')
+    expect(event.target.setSelectionRange).toHaveBeenCalledWith(6, 6)
+  })
+
+  it('replaces the selected text with a newline on Ctrl Enter', () => {
+    const event = createEvent({ ctrlKey: true, value: 'hello world', start: 5, end: 6 })
+    const submit = vi.fn()
+    const updateValue = vi.fn()
+
+    handleChatInputEnterKey(event, {
+      submit,
+      updateValue
+    })
+
+    expect(updateValue).toHaveBeenCalledWith('hello\nworld')
+    expect(event.target.setSelectionRange).toHaveBeenCalledWith(6, 6)
+  })
+})

+ 14 - 0
shudao-vue-frontend/src/views/Chat.userMessageWhitespace.test.js

@@ -0,0 +1,14 @@
+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 chatSource = readFileSync(resolve(__dirname, 'Chat.vue'), 'utf8')
+
+describe('Chat user message whitespace', () => {
+  it('preserves line breaks in sent user messages', () => {
+    expect(chatSource).toMatch(/\.user-message[\s\S]*?\.message-text\s*\{[\s\S]*?white-space:\s*pre-wrap/)
+  })
+})

+ 46 - 61
shudao-vue-frontend/src/views/Chat.vue

@@ -500,7 +500,7 @@
               placeholder="给AI智能助手发消息(按Enter进行发送)"
               placeholder="给AI智能助手发消息(按Enter进行发送)"
               class="message-input"
               class="message-input"
               v-model="messageText"
               v-model="messageText"
-              @keyup.enter.exact="handleSendMessage"
+              @keydown.enter="handleMessageInputEnterKey"
               @input="handleInput"
               @input="handleInput"
               :disabled="isSending || hasTypingMessage"
               :disabled="isSending || hasTypingMessage"
               maxlength="2000"
               maxlength="2000"
@@ -673,7 +673,7 @@
     <input
     <input
       ref="fileInput"
       ref="fileInput"
       type="file"
       type="file"
-      accept=".doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,image/*,text/plain"
+      accept=".docx,.pptx,.pdf,text/plain"
       style="display: none"
       style="display: none"
       @change="handleFileSelect"
       @change="handleFileSelect"
     />
     />
@@ -722,7 +722,6 @@ import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 import '@wangeditor/editor/dist/css/style.css'
 import Sidebar from '@/components/Sidebar.vue'
 import Sidebar from '@/components/Sidebar.vue'
 import ExamWorkshop from '@/views/ExamWorkshop.vue'
 import ExamWorkshop from '@/views/ExamWorkshop.vue'
-import * as mammoth from 'mammoth'
 
 
 // 导入Element Plus组件
 // 导入Element Plus组件
 import { ElMessage } from 'element-plus'
 import { ElMessage } from 'element-plus'
@@ -745,12 +744,15 @@ import {
   buildDocumentGenerationUserMessage,
   buildDocumentGenerationUserMessage,
   shouldAttachDocumentToRequest
   shouldAttachDocumentToRequest
 } from '@/utils/aiWritingRequest.js'
 } from '@/utils/aiWritingRequest.js'
+import { buildUploadedDocumentPayload } from '@/utils/attachmentContext.js'
+import { getAttachmentCardIcon } from '@/utils/attachmentFile.js'
 import { prepareAIWritingEditorHtml } from '@/utils/aiWritingContent.js'
 import { prepareAIWritingEditorHtml } from '@/utils/aiWritingContent.js'
 import { getGeneratedDocumentCardTime } from '@/utils/generatedDocumentCard.js'
 import { getGeneratedDocumentCardTime } from '@/utils/generatedDocumentCard.js'
 import {
 import {
   AI_WRITING_SIDEBAR_SIZE,
   AI_WRITING_SIDEBAR_SIZE,
   calculateResizableSidebarWidth
   calculateResizableSidebarWidth
 } from '@/utils/resizableSidebar.js'
 } from '@/utils/resizableSidebar.js'
+import { handleChatInputEnterKey } from '@/utils/chatInputKeydown.js'
 import { getToken } from '@/utils/auth.js'
 import { getToken } from '@/utils/auth.js'
 import { renderMarkdown } from '@/utils/markdown'
 import { renderMarkdown } from '@/utils/markdown'
 import 'katex/dist/katex.min.css'
 import 'katex/dist/katex.min.css'
@@ -1380,7 +1382,7 @@ const currentWebSearchData = ref({
 // 文件处理配置
 // 文件处理配置
 const fileConfig = reactive({
 const fileConfig = reactive({
   maxSize: 20 * 1024 * 1024, // 20MB
   maxSize: 20 * 1024 * 1024, // 20MB
-  allowedTypes: ['.docx'] // 只允许.docx格式的Word文档
+  allowedTypes: ['.docx', '.pdf', '.pptx', '.txt']
 })
 })
 
 
 // 文件预览相关
 // 文件预览相关
@@ -2272,6 +2274,15 @@ const handleSendMessage = async () => {
   scrollToBottom()
   scrollToBottom()
 }
 }
 
 
+const handleMessageInputEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSendMessage,
+    updateValue: (value) => {
+      messageText.value = value
+    }
+  })
+}
+
 // 处理非流式请求 (AI写作 和 安全培训)
 // 处理非流式请求 (AI写作 和 安全培训)
 
 
 const handleAIWritingStream = async (data) => {
 const handleAIWritingStream = async (data) => {
@@ -3654,12 +3665,15 @@ const handleStopGeneration = async () => {
 const handleReportGeneratorSubmit = async (data) => {
 const handleReportGeneratorSubmit = async (data) => {
   isSending.value = true
   isSending.value = true
   currentQuestion.value = data.question
   currentQuestion.value = data.question
+  const attachedFile = selectedFile.value
+  const uploadedDocument = buildUploadedDocumentPayload(attachedFile)
   
   
   // 添加用户消息
   // 添加用户消息
   chatMessages.value.push({
   chatMessages.value.push({
     id: Date.now(),
     id: Date.now(),
     type: 'user',
     type: 'user',
     content: data.question,
     content: data.question,
+    file: attachedFile || null,
     timestamp: new Date().toISOString()
     timestamp: new Date().toISOString()
   })
   })
   
   
@@ -3710,7 +3724,8 @@ const handleReportGeneratorSubmit = async (data) => {
       n_results: 10,
       n_results: 10,
       ai_conversation_id: ai_conversation_id.value,
       ai_conversation_id: ai_conversation_id.value,
       is_network_search_enabled: isNetworkSearchEnabled.value,
       is_network_search_enabled: isNetworkSearchEnabled.value,
-      enable_online_model: isOnlineModel.value
+      enable_online_model: isOnlineModel.value,
+      uploaded_documents: uploadedDocument ? [uploadedDocument] : []
     }
     }
 
 
     console.log('📤 发起 SSE POST 请求:', {
     console.log('📤 发起 SSE POST 请求:', {
@@ -4336,38 +4351,19 @@ const triggerFileUpload = () => {
 
 
 
 
 
 
-// 读取Word文件内容
-const readWordFile = async (file) => {
-  try {
-    if (file.size === 0) throw new Error('Word文件为空')
-    if (!mammoth) throw new Error('Word文档解析库未正确加载')
-    
-    const fileExtension = file.name.toLowerCase().split('.').pop()
-    const arrayBuffer = await file.arrayBuffer()
-    
-    // 检查文件格式
-    if (fileExtension === 'docx') {
-      const uint8Array = new Uint8Array(arrayBuffer.slice(0, 2))
-      if (uint8Array[0] !== 0x50 || uint8Array[1] !== 0x4B) {
-        throw new Error('文件不是有效的.docx格式,可能已损坏')
-      }
-    } else if (fileExtension === 'doc') {
-      throw new Error('检测到.doc格式文件。请将文件另存为.docx格式后重新上传。')
-    }
-    
-    const result = await mammoth.extractRawText({ arrayBuffer })
-    return result.value
-  } catch (error) {
-    console.error('Word文件读取失败:', error)
-    
-    if (error.message.includes('Can\'t find end of central directory')) {
-      throw new Error('文件格式错误:这不是一个有效的Word文档,或者文件已损坏。')
-    } else if (error.message.includes('Invalid file format')) {
-      throw new Error('Word文件格式无效或已损坏')
-    } else {
-      throw error
-    }
+const parseAttachmentFile = async (file) => {
+  const formData = new FormData()
+  formData.append('file', file)
+  const response = await apis.parseAttachment(formData)
+  const parsed = response?.data || {}
+  const text = parsed.text || ''
+  if (!text.trim()) {
+    const warning = Array.isArray(parsed.warnings) && parsed.warnings.length > 0
+      ? parsed.warnings[0]
+      : '未提取到可用文本'
+    throw new Error(warning)
   }
   }
+  return parsed
 }
 }
 
 
 // 处理文件选择
 // 处理文件选择
@@ -4377,25 +4373,26 @@ const handleFileSelect = async (event) => {
   
   
   try {
   try {
     const fileExtension = validateFile(file)
     const fileExtension = validateFile(file)
-    if (!mammoth) {
-      throw new Error('Word文档解析库未正确加载,请刷新页面重试')
-    }
     
     
     isUploadingFile.value = true
     isUploadingFile.value = true
-    const extractedContent = await readWordFile(file)
+    const parsed = await parseAttachmentFile(file)
+    const extractedContent = parsed.text || ''
     
     
     selectedFile.value = {
     selectedFile.value = {
       file,
       file,
-      name: file.name,
+      name: parsed.file_name || file.name,
       size: file.size,
       size: file.size,
-      type: fileExtension,
+      type: parsed.file_type ? `.${parsed.file_type}` : fileExtension,
       icon: getFileIcon(fileExtension),
       icon: getFileIcon(fileExtension),
-      content: extractedContent
+      content: extractedContent,
+      attachmentId: parsed.attachment_id || '',
+      charCount: parsed.char_count || extractedContent.length,
+      warnings: parsed.warnings || []
     }
     }
     ElMessage.success(`文件读取成功,共提取 ${extractedContent.length} 个字符`)
     ElMessage.success(`文件读取成功,共提取 ${extractedContent.length} 个字符`)
   } catch (error) {
   } catch (error) {
     console.error('文件读取失败:', error)
     console.error('文件读取失败:', error)
-    ElMessage.error(error.message || '文件读取失败,请重试')
+    ElMessage.error(error.message || error.msg || '文件读取失败,请重试')
   } finally {
   } finally {
     isUploadingFile.value = false
     isUploadingFile.value = false
     event.target.value = ''
     event.target.value = ''
@@ -4761,10 +4758,10 @@ const validateFile = (file) => {
   const fileExtension = '.' + file.name.split('.').pop().toLowerCase()
   const fileExtension = '.' + file.name.split('.').pop().toLowerCase()
   
   
   // 支持的扩展名列表(对应于 input 的 accept 属性)
   // 支持的扩展名列表(对应于 input 的 accept 属性)
-  const supportedExtensions = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt']
+  const supportedExtensions = fileConfig.allowedTypes
   
   
   if (!supportedExtensions.includes(fileExtension)) {
   if (!supportedExtensions.includes(fileExtension)) {
-    throw new Error(`不支持的文件格式: ${fileExtension}。支持的格式: 图片、文档、表格、PPT、PDF等。`)
+    throw new Error(`不支持的文件格式: ${fileExtension}。支持的格式: Word(.docx)、PPT(.pptx)、PDF、TXT。`)
   }
   }
   
   
   return fileExtension
   return fileExtension
@@ -4772,21 +4769,7 @@ const validateFile = (file) => {
 
 
 // 获取文件图标
 // 获取文件图标
 const getFileIcon = (fileType) => {
 const getFileIcon = (fileType) => {
-  const type = fileType.toLowerCase()
-  if (['.doc', '.docx'].includes(type)) {
-    return wordDocIcon
-  } else if (['.xls', '.xlsx'].includes(type)) {
-    return new URL('../assets/Chat/excel.png', import.meta.url).href
-  } else if (['.ppt', '.pptx'].includes(type)) {
-    return new URL('../assets/Chat/ppt.png', import.meta.url).href
-  } else if (type === '.pdf') {
-    return new URL('../assets/Chat/pdf.png', import.meta.url).href
-  } else if (['.png', '.jpg', '.jpeg', '.gif'].includes(type)) {
-    return new URL('../assets/Chat/image.png', import.meta.url).href
-  } else if (type === '.txt') {
-    return new URL('../assets/Chat/txt.png', import.meta.url).href
-  }
-  return '📎'
+  return getAttachmentCardIcon(fileType, wordDocIcon)
 }
 }
 
 
 // 滚动到底部
 // 滚动到底部
@@ -6139,6 +6122,8 @@ onActivated(async () => {
     
     
     .message-text {
     .message-text {
       margin-top: 0;
       margin-top: 0;
+      white-space: pre-wrap;
+      overflow-wrap: anywhere;
     }
     }
   }
   }
   
   

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

@@ -4578,6 +4578,8 @@ onActivated(async () => {
             font-size: 20px;
             font-size: 20px;
             line-height: 1.4;
             line-height: 1.4;
             word-wrap: break-word;
             word-wrap: break-word;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
           }
           }
         }
         }