| | |
| | | </div> |
| | | </div> |
| | | </FormDialog> |
| | | |
| | | <!-- 文件管理弹窗 --> |
| | | <FormDialog |
| | | v-model="filesDialogVisible" |
| | | title="文件管理" |
| | | :width="'900px'" |
| | | @close="closeFilesDialog" |
| | | @confirm="closeFilesDialog" |
| | | @cancel="closeFilesDialog" |
| | | > |
| | | <div class="file-manager"> |
| | | <!-- 文件上传 --> |
| | | <div class="upload-section"> |
| | | <el-upload |
| | | :action="uploadUrl" |
| | | :headers="uploadHeaders" |
| | | :on-success="handleUploadSuccess" |
| | | :on-error="handleUploadError" |
| | | :before-upload="beforeUpload" |
| | | multiple |
| | | :show-file-list="false" |
| | | accept=".txt,.md,.docx,.xlsx,.xls,.pdf" |
| | | > |
| | | <el-button type="primary">上传文件</el-button> |
| | | </el-upload> |
| | | <el-button |
| | | type="success" |
| | | @click="saveFiles" |
| | | :disabled="uploadedBlobIds.length === 0" |
| | | :loading="savingFiles" |
| | | style="margin-left: 10px" |
| | | > |
| | | 保存文件关联 |
| | | </el-button> |
| | | </div> |
| | | |
| | | <!-- 文件列表与向量化状态 --> |
| | | <el-table :data="fileList" style="margin-top: 20px" border> |
| | | <el-table-column prop="fileName" label="文件名" show-overflow-tooltip /> |
| | | <el-table-column prop="fileType" label="文件类型" width="100" /> |
| | | <el-table-column label="向量化状态" width="120"> |
| | | <template #default="{ row }"> |
| | | <el-tag :type="getStatusType(row.vectorStatus)"> |
| | | {{ getStatusText(row.vectorStatus) }} |
| | | </el-tag> |
| | | </template> |
| | | </el-table-column> |
| | | <el-table-column prop="chunkCount" label="切片数" width="100" align="center" /> |
| | | <el-table-column prop="createTime" label="上传时间" width="180" /> |
| | | <el-table-column label="操作" width="150" align="center"> |
| | | <template #default="{ row }"> |
| | | <el-button |
| | | v-if="row.vectorStatus === 3" |
| | | type="text" |
| | | @click="reprocessFile(row)" |
| | | > |
| | | 重新处理 |
| | | </el-button> |
| | | <el-button type="text" @click="deleteFile(row)" style="color: #f56c6c"> |
| | | 删除 |
| | | </el-button> |
| | | </template> |
| | | </el-table-column> |
| | | </el-table> |
| | | </div> |
| | | </FormDialog> |
| | | |
| | | <!-- 知识库问答弹窗 --> |
| | | <FormDialog |
| | | v-model="chatDialogVisible" |
| | | title="知识库问答" |
| | | :width="'800px'" |
| | | @close="closeChatDialog" |
| | | @confirm="closeChatDialog" |
| | | @cancel="closeChatDialog" |
| | | > |
| | | <div class="knowledge-chat"> |
| | | <div class="chat-header"> |
| | | <el-tag type="success">当前知识库: {{ currentKnowledgeBase?.title }}</el-tag> |
| | | </div> |
| | | |
| | | <!-- 对话区域 --> |
| | | <div class="chat-messages" ref="chatMessagesRef"> |
| | | <div |
| | | v-for="(msg, index) in messages" |
| | | :key="index" |
| | | :class="['message', msg.role]" |
| | | > |
| | | <div class="message-role">{{ msg.role === 'user' ? '我' : 'AI助手' }}</div> |
| | | <div class="message-content">{{ msg.content }}</div> |
| | | </div> |
| | | <div v-if="chatLoading" class="message assistant"> |
| | | <div class="message-role">AI助手</div> |
| | | <div class="message-content typing">正在思考中...</div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 输入框 --> |
| | | <div class="chat-input"> |
| | | <el-input |
| | | v-model="inputQuestion" |
| | | placeholder="请输入问题,按回车发送" |
| | | @keyup.enter="sendMessage" |
| | | :disabled="chatLoading" |
| | | > |
| | | <template #append> |
| | | <el-button @click="sendMessage" :loading="chatLoading">发送</el-button> |
| | | </template> |
| | | </el-input> |
| | | </div> |
| | | </div> |
| | | </FormDialog> |
| | | </div> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { Search } from "@element-plus/icons-vue"; |
| | | import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch } from "vue"; |
| | | import { onMounted, ref, reactive, toRefs, getCurrentInstance, computed, watch, nextTick } from "vue"; |
| | | import { ElMessage, ElMessageBox } from "element-plus"; |
| | | import PIMTable from "@/components/PIMTable/PIMTable.vue"; |
| | | import FormDialog from '@/components/Dialog/FormDialog.vue'; |
| | | import { listKnowledgeBase, delKnowledgeBase,addKnowledgeBase,updateKnowledgeBase } from "@/api/collaborativeApproval/knowledgeBase.js"; |
| | | import { |
| | | listKnowledgeBase, |
| | | delKnowledgeBase, |
| | | addKnowledgeBase, |
| | | updateKnowledgeBase, |
| | | getVectorStatus, |
| | | reprocessVector, |
| | | saveKnowledgeBaseFiles, |
| | | deleteKnowledgeBaseFiles, |
| | | knowledgeChat |
| | | } from "@/api/collaborativeApproval/knowledgeBase.js"; |
| | | import useUserStore from '@/store/modules/user'; |
| | | import { userListNoPageByTenantId } from '@/api/system/user.js'; |
| | | import { getToken } from "@/utils/auth"; |
| | | |
| | | // 表单验证规则 |
| | | const rules = { |
| | |
| | | dialogTitle: "", |
| | | dialogType: "add", |
| | | viewDialogVisible: false, |
| | | currentKnowledge: {} |
| | | currentKnowledge: {}, |
| | | filesDialogVisible: false, |
| | | currentKnowledgeBase: null, |
| | | fileList: [], |
| | | uploadedBlobIds: [], |
| | | savingFiles: false, |
| | | chatDialogVisible: false, |
| | | messages: [], |
| | | inputQuestion: "", |
| | | chatLoading: false, |
| | | memoryId: "" |
| | | }); |
| | | |
| | | const { |
| | |
| | | dialogTitle, |
| | | dialogType, |
| | | viewDialogVisible, |
| | | currentKnowledge |
| | | currentKnowledge, |
| | | filesDialogVisible, |
| | | currentKnowledgeBase, |
| | | fileList, |
| | | uploadedBlobIds, |
| | | savingFiles, |
| | | chatDialogVisible, |
| | | messages, |
| | | inputQuestion, |
| | | chatLoading, |
| | | memoryId |
| | | } = toRefs(data); |
| | | |
| | | // 表单引用 |
| | |
| | | // 用户相关 |
| | | const userStore = useUserStore(); |
| | | const userList = ref([]); |
| | | // 聊天消息容器引用 |
| | | const chatMessagesRef = ref(); |
| | | |
| | | // 文件上传相关 |
| | | const uploadUrl = import.meta.env.VITE_APP_BASE_API + "/common/upload"; |
| | | const uploadHeaders = { Authorization: "Bearer " + getToken() }; |
| | | |
| | | // 表格列配置 |
| | | const tableColumn = ref([ |
| | |
| | | } |
| | | }, |
| | | { |
| | | label: "文件数量", |
| | | prop: "fileCount", |
| | | width: 100, |
| | | align: "center" |
| | | }, |
| | | { |
| | | label: "切片数量", |
| | | prop: "totalChunkCount", |
| | | width: 100, |
| | | align: "center" |
| | | }, |
| | | { |
| | | label: "使用次数", |
| | | prop: "usageCount", |
| | | width: 100, |
| | |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openForm("edit", row); |
| | | } |
| | | }, |
| | | { |
| | | name: "文件", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openFilesDialog(row); |
| | | } |
| | | }, |
| | | { |
| | | name: "问答", |
| | | type: "text", |
| | | clickFun: (row) => { |
| | | openChatDialog(row); |
| | | } |
| | | }, |
| | | { |
| | |
| | | const handleExport = () => { |
| | | proxy.download('/knowledgeBase/export', { ...searchForm.value }, '知识库.xlsx') |
| | | } |
| | | |
| | | // ============ 文件管理相关 ============ |
| | | |
| | | // 打开文件管理弹窗 |
| | | const openFilesDialog = (row) => { |
| | | currentKnowledgeBase.value = row; |
| | | filesDialogVisible.value = true; |
| | | loadFileList(); |
| | | }; |
| | | |
| | | // 加载文件列表 |
| | | const loadFileList = async () => { |
| | | if (!currentKnowledgeBase.value?.id) return; |
| | | |
| | | try { |
| | | const res = await getVectorStatus(currentKnowledgeBase.value.id); |
| | | fileList.value = res.data || []; |
| | | } catch (error) { |
| | | console.error("加载文件列表失败:", error); |
| | | ElMessage.error("加载文件列表失败"); |
| | | } |
| | | }; |
| | | |
| | | // 上传前校验 |
| | | const beforeUpload = (file) => { |
| | | const allowedTypes = ['.txt', '.md', '.docx', '.xlsx', '.xls', '.pdf']; |
| | | const fileName = file.name.toLowerCase(); |
| | | const isAllowed = allowedTypes.some(type => fileName.endsWith(type)); |
| | | |
| | | if (!isAllowed) { |
| | | ElMessage.error('只支持 txt、md、docx、xlsx、xls、pdf 格式的文件'); |
| | | return false; |
| | | } |
| | | |
| | | const isLt50M = file.size / 1024 / 1024 < 50; |
| | | if (!isLt50M) { |
| | | ElMessage.error('文件大小不能超过 50MB'); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | }; |
| | | |
| | | // 上传成功 |
| | | const handleUploadSuccess = (response, file) => { |
| | | if (response.code === 200) { |
| | | uploadedBlobIds.value.push(response.data.id); |
| | | ElMessage.success(`文件 ${file.name} 上传成功`); |
| | | } else { |
| | | ElMessage.error(response.msg || "上传失败"); |
| | | } |
| | | }; |
| | | |
| | | // 上传失败 |
| | | const handleUploadError = (error, file) => { |
| | | ElMessage.error(`文件 ${file.name} 上传失败`); |
| | | }; |
| | | |
| | | // 保存文件关联 |
| | | const saveFiles = async () => { |
| | | if (uploadedBlobIds.value.length === 0) { |
| | | ElMessage.warning("请先上传文件"); |
| | | return; |
| | | } |
| | | |
| | | savingFiles.value = true; |
| | | try { |
| | | await saveKnowledgeBaseFiles({ |
| | | knowledgeBaseId: currentKnowledgeBase.value.id, |
| | | storageBlobIds: uploadedBlobIds.value |
| | | }); |
| | | |
| | | ElMessage.success("文件关联保存成功,正在后台处理向量化"); |
| | | uploadedBlobIds.value = []; |
| | | |
| | | // 延迟刷新文件列表,给后台处理时间 |
| | | setTimeout(() => { |
| | | loadFileList(); |
| | | }, 1000); |
| | | } catch (error) { |
| | | console.error("保存文件关联失败:", error); |
| | | ElMessage.error("保存文件关联失败"); |
| | | } finally { |
| | | savingFiles.value = false; |
| | | } |
| | | }; |
| | | |
| | | // 重新处理向量化的文件 |
| | | const reprocessFile = async (row) => { |
| | | try { |
| | | await reprocessVector(row.id); |
| | | ElMessage.success("已重新提交向量化任务"); |
| | | // 延迟刷新 |
| | | setTimeout(() => { |
| | | loadFileList(); |
| | | }, 1000); |
| | | } catch (error) { |
| | | console.error("重新处理失败:", error); |
| | | ElMessage.error("重新处理失败"); |
| | | } |
| | | }; |
| | | |
| | | // 删除文件 |
| | | const deleteFile = async (row) => { |
| | | try { |
| | | await ElMessageBox.confirm( |
| | | "确定要删除该文件吗?删除后将无法恢复向量数据", |
| | | "删除确认", |
| | | { |
| | | confirmButtonText: "确定", |
| | | cancelButtonText: "取消", |
| | | type: "warning" |
| | | } |
| | | ); |
| | | |
| | | await deleteKnowledgeBaseFiles([row.id]); |
| | | ElMessage.success("删除成功"); |
| | | loadFileList(); |
| | | } catch (error) { |
| | | if (error !== 'cancel') { |
| | | console.error("删除文件失败:", error); |
| | | ElMessage.error("删除文件失败"); |
| | | } |
| | | } |
| | | }; |
| | | |
| | | // 状态文本映射 |
| | | const getStatusText = (status) => { |
| | | const map = { |
| | | 0: '待处理', |
| | | 1: '处理中', |
| | | 2: '已完成', |
| | | 3: '失败' |
| | | }; |
| | | return map[status] || '未知'; |
| | | }; |
| | | |
| | | // 状态标签类型映射 |
| | | const getStatusType = (status) => { |
| | | const map = { |
| | | 0: 'info', |
| | | 1: 'warning', |
| | | 2: 'success', |
| | | 3: 'danger' |
| | | }; |
| | | return map[status] || 'info'; |
| | | }; |
| | | |
| | | // 关闭文件管理弹窗 |
| | | const closeFilesDialog = () => { |
| | | filesDialogVisible.value = false; |
| | | currentKnowledgeBase.value = null; |
| | | fileList.value = []; |
| | | uploadedBlobIds.value = []; |
| | | getList(); // 刷新主列表,更新文件数量 |
| | | }; |
| | | |
| | | // ============ 知识库问答相关 ============ |
| | | |
| | | // 打开问答弹窗 |
| | | const openChatDialog = (row) => { |
| | | currentKnowledgeBase.value = row; |
| | | chatDialogVisible.value = true; |
| | | memoryId.value = crypto.randomUUID(); |
| | | messages.value = []; |
| | | inputQuestion.value = ""; |
| | | }; |
| | | |
| | | // 发送消息 |
| | | const sendMessage = async () => { |
| | | if (!inputQuestion.value.trim()) { |
| | | ElMessage.warning("请输入问题"); |
| | | return; |
| | | } |
| | | |
| | | const question = inputQuestion.value.trim(); |
| | | |
| | | // 添加用户消息 |
| | | messages.value.push({ |
| | | role: 'user', |
| | | content: question |
| | | }); |
| | | |
| | | inputQuestion.value = ""; |
| | | chatLoading.value = true; |
| | | |
| | | // 滚动到底部 |
| | | await nextTick(); |
| | | scrollToBottom(); |
| | | |
| | | try { |
| | | // 流式请求 |
| | | const response = await fetch('/api/ai/knowledge/chat', { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | | 'Authorization': 'Bearer ' + getToken() |
| | | }, |
| | | body: JSON.stringify({ |
| | | knowledgeBaseId: currentKnowledgeBase.value.id, |
| | | memoryId: memoryId.value, |
| | | question: question |
| | | }) |
| | | }); |
| | | |
| | | if (!response.ok) { |
| | | throw new Error('请求失败'); |
| | | } |
| | | |
| | | // 处理SSE流式响应 |
| | | const reader = response.body.getReader(); |
| | | const decoder = new TextDecoder(); |
| | | let aiContent = ''; |
| | | |
| | | messages.value.push({ role: 'assistant', content: '' }); |
| | | |
| | | while (true) { |
| | | const { done, value } = await reader.read(); |
| | | if (done) break; |
| | | |
| | | const text = decoder.decode(value); |
| | | aiContent += text; |
| | | messages.value[messages.value.length - 1].content = aiContent; |
| | | |
| | | // 滚动到底部 |
| | | await nextTick(); |
| | | scrollToBottom(); |
| | | } |
| | | } catch (error) { |
| | | console.error("问答请求失败:", error); |
| | | ElMessage.error("问答请求失败,请稍后重试"); |
| | | messages.value.push({ |
| | | role: 'assistant', |
| | | content: '抱歉,发生了错误,请稍后重试' |
| | | }); |
| | | } finally { |
| | | chatLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | // 滚动到底部 |
| | | const scrollToBottom = () => { |
| | | if (chatMessagesRef.value) { |
| | | chatMessagesRef.value.scrollTop = chatMessagesRef.value.scrollHeight; |
| | | } |
| | | }; |
| | | |
| | | // 关闭问答弹窗 |
| | | const closeChatDialog = () => { |
| | | chatDialogVisible.value = false; |
| | | currentKnowledgeBase.value = null; |
| | | messages.value = []; |
| | | inputQuestion.value = ""; |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped> |
| | |
| | | font-size: 14px; |
| | | color: #909399; |
| | | } |
| | | |
| | | /* 文件管理样式 */ |
| | | .file-manager { |
| | | padding: 20px 0; |
| | | } |
| | | |
| | | .upload-section { |
| | | display: flex; |
| | | align-items: center; |
| | | } |
| | | |
| | | /* 知识库问答样式 */ |
| | | .knowledge-chat { |
| | | display: flex; |
| | | flex-direction: column; |
| | | height: 500px; |
| | | } |
| | | |
| | | .chat-header { |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .chat-messages { |
| | | flex: 1; |
| | | overflow-y: auto; |
| | | padding: 16px; |
| | | background: #f5f7fa; |
| | | border-radius: 8px; |
| | | margin-bottom: 16px; |
| | | } |
| | | |
| | | .message { |
| | | margin-bottom: 16px; |
| | | max-width: 80%; |
| | | } |
| | | |
| | | .message.user { |
| | | margin-left: auto; |
| | | text-align: right; |
| | | } |
| | | |
| | | .message.assistant { |
| | | margin-right: auto; |
| | | } |
| | | |
| | | .message-role { |
| | | font-size: 12px; |
| | | color: #909399; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .message-content { |
| | | display: inline-block; |
| | | padding: 10px 14px; |
| | | border-radius: 8px; |
| | | line-height: 1.6; |
| | | word-wrap: break-word; |
| | | white-space: pre-wrap; |
| | | } |
| | | |
| | | .message.user .message-content { |
| | | background: #409eff; |
| | | color: white; |
| | | } |
| | | |
| | | .message.assistant .message-content { |
| | | background: white; |
| | | color: #303133; |
| | | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
| | | } |
| | | |
| | | .typing { |
| | | animation: typing 1.5s infinite; |
| | | } |
| | | |
| | | @keyframes typing { |
| | | 0%, 50%, 100% { |
| | | opacity: 1; |
| | | } |
| | | 25%, 75% { |
| | | opacity: 0.5; |
| | | } |
| | | } |
| | | |
| | | .chat-input { |
| | | margin-top: auto; |
| | | } |
| | | |
| | | </style> |