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

feat(aiChat): 添加文件上传功能并优化聊天界面

- 在aiChat组件中实现文件上传功能,支持多文件上传和取消上传
- 添加FileInput2组件用于文件选择
- 显示已上传文件列表和上传状态
- 添加ConfirmDialog组件用于确认操作
- 优化聊天界面样式,显示消息关联的文件
- 添加文件大小限制和上传错误处理
lsc 1 неделя назад
Родитель
Сommit
4f971d3cd5

+ 135 - 7
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -6,6 +6,12 @@
             <div v-for="(message, index) in messages" :key="index" class="chat-message">
                 <div class="message-content user-message chat" v-if="message.content">
                     <div v-html="message.content"></div>
+                    <!-- 显示上传的文件 -->
+                    <div class="message-files" v-if="message.sourceFiles && message.sourceFiles.length > 0">
+                        <div v-for="(file, index) in message.sourceFiles" :key="index" class="message-file-item">
+                            <span>{{ file.title }}</span>
+                        </div>
+                    </div>
                 </div>
                 <div class="message-content ai-message chat" v-if="message.aiContent || message.loading">
                     <div v-if="message.aiContent" v-html="message.aiContent"></div>
@@ -49,7 +55,7 @@
                     :placeholder="messages.length === 0 ? lang.ssAiChatExample : lang.ssAiChatShortcut"
                     v-model="inputText" @keyup.enter.exact="sendMessage" rows="5" />
                 <div class="input-actions">
-                    <FileInput accept="*" @change="handleFileUpload" v-show="false">
+                    <FileInput accept="*" @change="handleFileUpload" >
                         <button class="attach-btn">
                             <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                                 <path
@@ -98,7 +104,9 @@ import { useSlidesStore } from '@/store'
 import { lang } from '@/main'
 import MarkdownIt from 'markdown-it'
 import { getWorkPageId } from '@/services/course'
-import FileInput from '@/components/FileInput.vue'
+import FileInput from '@/components/FileInput2.vue'
+import axios from '@/services/config'
+import message from '@/utils/message'
 
 interface ChatMessage {
     uid?: string
@@ -155,7 +163,7 @@ const chatLoading = ref(false)
 const showQuickActions = ref(false)
 const streamController = ref<{ abort: () => void } | null>(null)
 const noStreamController = ref<{ promise: Promise<string>; abort: () => void } | null>(null)
-const files = ref<Array<{ title: string; id?: string; url?: string }>>([])
+const files = ref<Array<{ title: string; id?: string | null; url?: string; isProcessing?: boolean; cancel?: () => void }>>([])
 
 // 快捷操作短语数组
 const quickActions = [
@@ -177,6 +185,12 @@ const sendMessage = () => {
   if (chatLoading.value) {
     return
   }
+  // 检查是否有文件正在处理中
+  const hasProcessingFile = files.value.some(file => file.isProcessing)
+  if (hasProcessingFile) {
+    message.error('请等待文件上传完成后再发送消息')
+    return
+  }
   if (inputText.value.trim() || files.value.length > 0) {
     // 添加用户消息
     messages.value.push({
@@ -196,6 +210,10 @@ const sendMessage = () => {
     prevChatResult()
     messages.value.at(-1).loading = true
     messages.value.at(-1).chatloading = true
+    messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+      title: file.title,
+      id: file.id
+    }))
     chatLoading.value = true
     sendAction(inputText.value)
     inputText.value = ''
@@ -220,22 +238,62 @@ const stopMessage = () => {
 }
 
 // 处理文件上传
-const handleFileUpload = (files2) => {
+const handleFileUpload = async (files2: File[]) => {
   const maxSize = 10 * 1024 * 1024 // 10MB
+  const uploadPromises = []
+  
   for (let i = 0; i < files2.length; i++) {
     const file = files2[i]
     if (file.size > maxSize) {
-      alert('文件大小不能超过10MB')
+      message.error('文件大小不能超过10MB')
       continue
     }
+    // 先添加文件到列表,显示解析中状态
+    const fileIndex = files.value.length
     files.value.push({
-      title: file.name
+      title: file.name + ' (解析中...)',
+      id: null,
+      isProcessing: true,
+      cancel: null
+    })
+    // 创建取消控制器
+    const controller = new AbortController()
+    files.value[fileIndex].cancel = () => {
+      controller.abort()
+      files.value.splice(fileIndex, 1)
+    }
+    // 创建上传Promise并添加到数组
+    const uploadPromise = uploadFile2(file, controller.signal).then(res => {
+      if (!res) {
+        files.value.splice(fileIndex, 1)
+        return
+      }
+      // 上传成功,更新文件状态
+      files.value[fileIndex] = {
+        title: file.name,
+        id: res.results.document_id,
+        isProcessing: false
+      }
+    }).catch(error => {
+      if (error.name !== 'AbortError') {
+        console.error('文件上传失败:', error)
+        files.value.splice(fileIndex, 1)
+      }
     })
+    
+    uploadPromises.push(uploadPromise)
   }
+  
+  // 等待所有文件上传完成
+  await Promise.allSettled(uploadPromises)
 }
 
 // 移除文件
 const removeFile = (index: number) => {
+  const file = files.value[index]
+  if (file && file.isProcessing && file.cancel) {
+    file.cancel()
+  }
   files.value.splice(index, 1)
 }
 
@@ -309,7 +367,7 @@ const sendAction = async (action: string) => {
       chatLoading.value = false
       prevChatResult()
     }
-  }, session_name.value).then(controller => {
+  }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
     streamController.value = controller
   }).catch(err => {
     chatLoading.value = false
@@ -376,6 +434,49 @@ const setPageId = async (tool: any, json: any) => {
   return res[0][0].id
 }
 
+// 上传文件
+const uploadFile2 = async (file: File, signal?: AbortSignal): Promise<any> => {
+  try {
+    const uuid = uuidv4()
+    const formData = new FormData()
+    const timestamp = Date.now()
+    const finalExtension = file.name.split('.').pop()?.toLowerCase() || ''
+    const baseName = file.name.slice(0, -(finalExtension.length + 1))
+    
+    formData.append(
+      'file',
+      new File([file], `${baseName}${timestamp}.${finalExtension}`)
+    )
+    formData.append('collection_ids', JSON.stringify([]))
+    formData.append('id', uuid)
+    formData.append('metadata', JSON.stringify({ title: file.name }))
+    formData.append('ingestion_mode', 'fast')
+    formData.append('run_with_orchestration', 'true')
+
+    // 同步知识库
+    const res = await axios.post(
+      'https://r2rserver.cocorobo.cn/v3/documents',
+      formData,
+      {
+        headers: {
+          'Content-Type': 'multipart/form-data',
+        },
+        signal: signal
+      }
+    )
+    
+    console.log(res)
+    return res
+  }
+  catch (error) {
+    console.log('err', error)
+    if (error.name === 'AbortError') {
+      throw error
+    }
+    return ''
+  }
+}
+
 
 
 const agentid1 = ref('cbb29b41-2a4a-4453-bf8d-357929ced4bd')// 判断意图
@@ -733,4 +834,31 @@ ul {
     border-right: 1px solid #000;
     padding: 10px;
 }
+
+.message-files {
+    margin-top: 8px;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+}
+
+.message-file-item {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    color: #6b7280;
+    background: #f3f4f6;
+    padding: 4px 8px;
+    border-radius: 4px;
+    max-width: 200px;
+    overflow: hidden;
+}
+
+.message-file-item span {
+    flex: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
 </style>

+ 91 - 0
src/components/ConfirmDialog.vue

@@ -0,0 +1,91 @@
+<template>
+  <Modal
+    :visible="visible"
+    :width="width"
+    :closeButton="false"
+    :closeOnClickMask="closeOnClickMask"
+    :closeOnEsc="closeOnEsc"
+    @update:visible="handleVisibleChange"
+  >
+    <div class="confirm-dialog">
+      <div class="confirm-dialog__title">{{ title }}</div>
+      <div class="confirm-dialog__content">
+        <slot>{{ content }}</slot>
+      </div>
+      <div class="confirm-dialog__footer">
+        <Button type="default" @click="handleCancel">{{ cancelText }}</Button>
+        <Button type="primary" @click="handleConfirm">{{ confirmText }}</Button>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import Modal from '@/components/Modal.vue'
+import Button from '@/components/Button.vue'
+
+const props = withDefaults(defineProps<{
+  visible: boolean
+  title?: string
+  content?: string
+  confirmText?: string
+  cancelText?: string
+  width?: number
+  closeOnClickMask?: boolean
+  closeOnEsc?: boolean
+}>(), {
+  title: '提示',
+  content: '',
+  confirmText: '确认',
+  cancelText: '取消',
+  width: 420,
+  closeOnClickMask: false,
+  closeOnEsc: false,
+})
+
+const emit = defineEmits<{
+  (event: 'update:visible', payload: boolean): void
+  (event: 'confirm'): void
+  (event: 'cancel'): void
+}>()
+
+const handleVisibleChange = (val: boolean) => {
+  emit('update:visible', val)
+}
+
+const handleConfirm = () => {
+  emit('confirm')
+  emit('update:visible', false)
+}
+
+const handleCancel = () => {
+  emit('cancel')
+  emit('update:visible', false)
+}
+</script>
+
+<style lang="scss" scoped>
+.confirm-dialog {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
+.confirm-dialog__title {
+  font-size: 16px;
+  font-weight: 500;
+  color: #333;
+}
+
+.confirm-dialog__content {
+  font-size: 14px;
+  color: #666;
+  line-height: 1.5;
+}
+
+.confirm-dialog__footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+</style>

+ 47 - 0
src/components/FileInput2.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="file-input" @click="handleClick()">
+    <slot></slot>
+    <input 
+      class="input"
+      type="file" 
+      name="upload" 
+      ref="inputRef" 
+      :accept="accept" 
+      multiple
+      @change="$event => handleChange($event)"
+      @click.stop
+    >
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { useTemplateRef } from 'vue'
+
+withDefaults(defineProps<{
+  accept?: string
+}>(), {
+  accept: 'image/*',
+})
+
+const emit = defineEmits<{
+  (event: 'change', payload: FileList): void
+}>()
+
+const inputRef = useTemplateRef<HTMLInputElement>('inputRef')
+
+const handleClick = () => {
+  if (!inputRef.value) return
+  inputRef.value.value = ''
+  inputRef.value.click()
+}
+const handleChange = (e: Event) => {
+  const files = (e.target as HTMLInputElement).files
+  if (files) emit('change', files)
+}
+</script>
+
+<style lang="scss" scoped>
+.input {
+  display: none;
+}
+</style>

+ 2 - 0
src/tools/aiChat.ts

@@ -94,11 +94,13 @@ export const chat_stream = async (
   language: string,
   onMessage: (event: { type: 'message' | 'close' | 'error' | 'messageEnd'; data: string }) => void,
   session_name?: string,
+  file_ids?: Array<string>
 ): Promise<{ abort: () => void }> => {
   const agentData = await getAgentModel(agentId)
   const params: ChatParams = {
     ...DEFAULT_PARAMS,
     id: agentId,
+    file_ids: file_ids || [],
     message: msg,
     uid: uuidv4(),
     stream: true,