浏览代码

feat:AI助手增加文件上传分析功能

Jerry Liang 2 月之前
父节点
当前提交
b0f640a5b5

+ 61 - 2
src/layout/header/Actions/AiChat/ChatPanel.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin } from 'naive-ui'
+import { NInput, NButton, NEmpty, NScrollbar, NPopconfirm, NSpin, NTag } from 'naive-ui'
 import { ref, nextTick, watch, onMounted } from 'vue'
 import { useAiChat } from './useAiChat'
 import MessageItem from './MessageItem.vue'
@@ -11,6 +11,8 @@ const {
   sessionList,
   currentSessionId,
   isLoadingSession,
+  pendingFiles,
+  isUploading,
   sendMessage,
   cancelChat,
   clearMessages,
@@ -19,11 +21,14 @@ const {
   createNewSession,
   loadSession,
   deleteSession,
+  handleUploadFile,
+  removePendingFile,
 } = useAiChat()
 
 const inputText = ref('')
 const scrollRef = ref<InstanceType<typeof NScrollbar>>()
 const showSidebar = ref(false)
+const fileInputRef = ref<HTMLInputElement>()
 
 onMounted(() => {
   fetchSession()
@@ -53,6 +58,20 @@ function handleKeydown(e: KeyboardEvent) {
   }
 }
 
+function triggerFileInput() {
+  fileInputRef.value?.click()
+}
+
+function onFileSelected(e: Event) {
+  const input = e.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (file) {
+    handleUploadFile(file)
+  }
+  // 重置 input,允许再次选择同名文件
+  input.value = ''
+}
+
 function handleNewSession() {
   createNewSession()
   showSidebar.value = false
@@ -180,7 +199,47 @@ function formatTime(ts: number): string {
 
     <!-- 输入区域 -->
     <div class="border-t p-3 dark:border-neutral-700">
+      <!-- 已上传文件标签 -->
+      <div v-if="pendingFiles.length > 0" class="mb-2 flex flex-wrap gap-1">
+        <NTag
+          v-for="(file, idx) in pendingFiles"
+          :key="file.key"
+          size="small"
+          closable
+          @close="removePendingFile(idx)"
+        >
+          <template #icon>
+            <span class="i-ph:file-text text-xs" />
+          </template>
+          {{ file.fileName }}
+        </NTag>
+      </div>
+
+      <!-- 上传中提示 -->
+      <div v-if="isUploading" class="mb-2 flex items-center gap-1 text-xs text-neutral-400">
+        <NSpin :size="12" />
+        <span>上传中...</span>
+      </div>
+
       <div class="flex items-center gap-2">
+        <!-- 上传按钮 -->
+        <NButton
+          text
+          size="small"
+          :disabled="isStreaming || isUploading || pendingFiles.length >= 5"
+          title="上传文件"
+          @click="triggerFileInput"
+        >
+          <span class="i-ph:paperclip text-lg" />
+        </NButton>
+        <input
+          ref="fileInputRef"
+          type="file"
+          class="hidden"
+          accept=".txt,.csv,.json,.md,.xml,.yaml,.yml,.js,.ts,.vue,.go,.py,.sh,.sql,.log,.html,.css"
+          @change="onFileSelected"
+        >
+
         <NInput
           v-model:value="inputText"
           type="textarea"
@@ -193,7 +252,7 @@ function formatTime(ts: number): string {
           v-if="!isStreaming"
           type="primary"
           size="small"
-          :disabled="!inputText.trim()"
+          :disabled="!inputText.trim() && pendingFiles.length === 0"
           @click="handleSend"
         >
           <span class="i-ph:paper-plane-tilt" />

+ 8 - 0
src/layout/header/Actions/AiChat/interface.ts

@@ -58,3 +58,11 @@ export interface LocalChatSession {
   createdAt: number
   updatedAt: number
 }
+
+// 已上传待发送的文件
+export interface UploadedFile {
+  key: string
+  fileName: string
+  path: string
+  size: number
+}

+ 57 - 3
src/layout/header/Actions/AiChat/useAiChat.ts

@@ -1,5 +1,5 @@
 import { ref } from 'vue'
-import type { ChatMessage, ToolCallInfo, SessionListItem } from './interface'
+import type { ChatMessage, ToolCallInfo, SessionListItem, UploadedFile } from './interface'
 import { useUserStore } from '@/stores/user'
 import { chatDB } from './chatDB'
 import {
@@ -10,6 +10,7 @@ import {
   deleteAiSession,
   cancelAiChat,
   resolveAiPermission,
+  uploadAiFile,
 } from '@/service/aiChat'
 
 export function useAiChat() {
@@ -18,6 +19,8 @@ export function useAiChat() {
   const currentSessionId = ref('')
   const welcomeMessage = ref('')
   const sessionList = ref<SessionListItem[]>([])
+  const pendingFiles = ref<UploadedFile[]>([])
+  const isUploading = ref(false)
   const isLoadingSession = ref(false)
 
   const userStore = useUserStore()
@@ -209,6 +212,44 @@ export function useAiChat() {
 
   // ============ 消息发送 ============
 
+  // ============ 文件上传 ============
+
+  async function handleUploadFile(file: File) {
+    // 限制数量
+    if (pendingFiles.value.length >= 5) {
+      return
+    }
+    // 限制大小
+    if (file.size > 2 * 1024 * 1024) {
+      return
+    }
+
+    isUploading.value = true
+    try {
+      const result = await uploadAiFile(file)
+      if (result) {
+        pendingFiles.value.push({
+          key: result.key,
+          fileName: result.fileName,
+          path: result.path,
+          size: result.size,
+        })
+      }
+    }
+    catch {
+      // 忽略
+    }
+    finally {
+      isUploading.value = false
+    }
+  }
+
+  function removePendingFile(index: number) {
+    pendingFiles.value.splice(index, 1)
+  }
+
+  // ============ 消息发送 ============
+
   function generateId(): string {
     return `${Date.now()}-${Math.random().toString(36).slice(2)}`
   }
@@ -216,7 +257,16 @@ export function useAiChat() {
   async function sendMessage(content: string) {
     if (!content.trim() || isStreaming.value) return
 
-    // 添加用户消息
+    // 构建实际发送的消息(包含文件上下文)
+    let actualMessage = content.trim()
+    if (pendingFiles.value.length > 0) {
+      const filePaths = pendingFiles.value.map(f => f.path).join(', ')
+      actualMessage = `[用户上传了文件: ${filePaths},请使用 readTextFile 工具读取文件内容后进行分析]\n\n${actualMessage}`
+      // 清空待发送文件
+      pendingFiles.value = []
+    }
+
+    // 添加用户消息(显示原始内容,不含注入的文件提示)
     messages.value.push({
       id: generateId(),
       role: 'user',
@@ -244,7 +294,7 @@ export function useAiChat() {
           Authorization: `Bearer ${userStore.token}`,
         },
         body: JSON.stringify({
-          message: content.trim(),
+          message: actualMessage,
           sessionId: currentSessionId.value,
         }),
       })
@@ -433,6 +483,8 @@ export function useAiChat() {
     welcomeMessage,
     sessionList,
     isLoadingSession,
+    pendingFiles,
+    isUploading,
     sendMessage,
     cancelChat,
     clearMessages,
@@ -442,5 +494,7 @@ export function useAiChat() {
     createNewSession,
     loadSession,
     deleteSession,
+    handleUploadFile,
+    removePendingFile,
   }
 }

+ 7 - 0
src/service/aiChat/index.ts

@@ -75,5 +75,12 @@ export const downloadAiFile = async (key: string, fileName: string) => {
   })
 }
 
+// POST 上传文件给 AI 分析
+export const uploadAiFile = async (file: File): Promise<{ key: string, fileName: string, path: string, size: number }> => {
+  const formData = new FormData()
+  formData.append('file', file)
+  return await http.post(API.fileUpload, formData)
+}
+
 // 导出 chat SSE 请求路径(流式请求需在 useAiChat 中直接 fetch)
 export { API as AI_CHAT_API }