فهرست منبع

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

zkn 4 هفته پیش
والد
کامیت
4ce49a4673

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

@@ -2,7 +2,16 @@
 报告相关数据模型
 """
 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):
@@ -14,6 +23,7 @@ class ReportCompleteFlowRequest(BaseModel):
     ai_conversation_id: Optional[int] = Field(default=None, description="AI对话ID")
     is_network_search_enabled: 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):

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

@@ -2,7 +2,7 @@
 报告兼容路由
 完全对齐 Go 版本的接口实现,保持外部一致性
 """
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, File, Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 import httpx
 import json
@@ -72,6 +72,24 @@ def _build_aichat_complete_flow_body(
     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(
     request_data: ReportCompleteFlowRequest,
     request: Request
@@ -80,7 +98,10 @@ async def fallback_to_local_stream(
     logger.info("[报告兼容] 降级为本地 SSE 兼容输出")
 
     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,
         business_type=0
     )
@@ -212,6 +233,16 @@ async def complete_flow(request: 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")
 async def update_ai_message(request: Request):
     """

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

@@ -4,7 +4,7 @@ AIChat 代理服务
 """
 import httpx
 from typing import AsyncGenerator, Optional
-from fastapi import Request
+from fastapi import Request, UploadFile
 from fastapi.responses import StreamingResponse, JSONResponse
 from utils.config import settings
 from utils.logger import logger
@@ -144,6 +144,52 @@ class AIChatProxy:
                 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:
         """
         检查 aichat 服务健康状态

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

@@ -41,7 +41,7 @@
             :autosize="{ minRows: 1, maxRows: 4 }"
             placeholder="请在此处发送消息 (Enter键可立即发送)"
             :disabled="loading"
-            @keydown.enter.exact.prevent="handleSubmit"
+            @keydown.enter="handleEnterKey"
             class="message-input"
           />
           
@@ -69,6 +69,7 @@
 <script setup>
 import { reactive, ref } from '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'])
 
@@ -111,6 +112,15 @@ const handleSubmit = () => {
   })
 }
 
+const handleEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSubmit,
+    updateValue: (value) => {
+      form.question = value
+    }
+  })
+}
+
 const handleCancel = () => {
   emit('cancel')
 }

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

@@ -23,6 +23,9 @@ export const apis = {
 
   //上传oss
   uploadOss: (data) => request.post('/oss/upload', data),
+
+  // 解析AI问答上传附件
+  parseAttachment: (data) => request.post('/attachments/parse', data),
   
   // 获取功能卡片
   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进行发送)"
               class="message-input"
               v-model="messageText"
-              @keyup.enter.exact="handleSendMessage"
+              @keydown.enter="handleMessageInputEnterKey"
               @input="handleInput"
               :disabled="isSending || hasTypingMessage"
               maxlength="2000"
@@ -673,7 +673,7 @@
     <input
       ref="fileInput"
       type="file"
-      accept=".doc,.docx,.xls,.xlsx,.ppt,.pptx,.pdf,image/*,text/plain"
+      accept=".docx,.pptx,.pdf,text/plain"
       style="display: none"
       @change="handleFileSelect"
     />
@@ -722,7 +722,6 @@ import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 import Sidebar from '@/components/Sidebar.vue'
 import ExamWorkshop from '@/views/ExamWorkshop.vue'
-import * as mammoth from 'mammoth'
 
 // 导入Element Plus组件
 import { ElMessage } from 'element-plus'
@@ -745,12 +744,15 @@ import {
   buildDocumentGenerationUserMessage,
   shouldAttachDocumentToRequest
 } from '@/utils/aiWritingRequest.js'
+import { buildUploadedDocumentPayload } from '@/utils/attachmentContext.js'
+import { getAttachmentCardIcon } from '@/utils/attachmentFile.js'
 import { prepareAIWritingEditorHtml } from '@/utils/aiWritingContent.js'
 import { getGeneratedDocumentCardTime } from '@/utils/generatedDocumentCard.js'
 import {
   AI_WRITING_SIDEBAR_SIZE,
   calculateResizableSidebarWidth
 } from '@/utils/resizableSidebar.js'
+import { handleChatInputEnterKey } from '@/utils/chatInputKeydown.js'
 import { getToken } from '@/utils/auth.js'
 import { renderMarkdown } from '@/utils/markdown'
 import 'katex/dist/katex.min.css'
@@ -1380,7 +1382,7 @@ const currentWebSearchData = ref({
 // 文件处理配置
 const fileConfig = reactive({
   maxSize: 20 * 1024 * 1024, // 20MB
-  allowedTypes: ['.docx'] // 只允许.docx格式的Word文档
+  allowedTypes: ['.docx', '.pdf', '.pptx', '.txt']
 })
 
 // 文件预览相关
@@ -2272,6 +2274,15 @@ const handleSendMessage = async () => {
   scrollToBottom()
 }
 
+const handleMessageInputEnterKey = (event) => {
+  handleChatInputEnterKey(event, {
+    submit: handleSendMessage,
+    updateValue: (value) => {
+      messageText.value = value
+    }
+  })
+}
+
 // 处理非流式请求 (AI写作 和 安全培训)
 
 const handleAIWritingStream = async (data) => {
@@ -3654,12 +3665,15 @@ const handleStopGeneration = async () => {
 const handleReportGeneratorSubmit = async (data) => {
   isSending.value = true
   currentQuestion.value = data.question
+  const attachedFile = selectedFile.value
+  const uploadedDocument = buildUploadedDocumentPayload(attachedFile)
   
   // 添加用户消息
   chatMessages.value.push({
     id: Date.now(),
     type: 'user',
     content: data.question,
+    file: attachedFile || null,
     timestamp: new Date().toISOString()
   })
   
@@ -3710,7 +3724,8 @@ const handleReportGeneratorSubmit = async (data) => {
       n_results: 10,
       ai_conversation_id: ai_conversation_id.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 请求:', {
@@ -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 {
     const fileExtension = validateFile(file)
-    if (!mammoth) {
-      throw new Error('Word文档解析库未正确加载,请刷新页面重试')
-    }
     
     isUploadingFile.value = true
-    const extractedContent = await readWordFile(file)
+    const parsed = await parseAttachmentFile(file)
+    const extractedContent = parsed.text || ''
     
     selectedFile.value = {
       file,
-      name: file.name,
+      name: parsed.file_name || file.name,
       size: file.size,
-      type: fileExtension,
+      type: parsed.file_type ? `.${parsed.file_type}` : 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} 个字符`)
   } catch (error) {
     console.error('文件读取失败:', error)
-    ElMessage.error(error.message || '文件读取失败,请重试')
+    ElMessage.error(error.message || error.msg || '文件读取失败,请重试')
   } finally {
     isUploadingFile.value = false
     event.target.value = ''
@@ -4761,10 +4758,10 @@ const validateFile = (file) => {
   const fileExtension = '.' + file.name.split('.').pop().toLowerCase()
   
   // 支持的扩展名列表(对应于 input 的 accept 属性)
-  const supportedExtensions = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.txt']
+  const supportedExtensions = fileConfig.allowedTypes
   
   if (!supportedExtensions.includes(fileExtension)) {
-    throw new Error(`不支持的文件格式: ${fileExtension}。支持的格式: 图片、文档、表格、PPT、PDF等。`)
+    throw new Error(`不支持的文件格式: ${fileExtension}。支持的格式: Word(.docx)、PPT(.pptx)、PDF、TXT。`)
   }
   
   return fileExtension
@@ -4772,21 +4769,7 @@ const validateFile = (file) => {
 
 // 获取文件图标
 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 {
       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;
             line-height: 1.4;
             word-wrap: break-word;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
           }
         }