Files
X-Agents/web/src/views/Knowledge.vue
DESKTOP-72TV0V4\caoxiaozhu f22f823a4a fix: 优化文件上传对话框布局
- 调整顶部导航按钮位置
- 隐藏对话框默认关闭按钮
- 优化文件上传流程交互

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 09:33:13 +08:00

1032 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { useModelSettings } from './settings/useModelSettings'
import { fetchKnowledgeBases, createKnowledgeBase as apiCreateKnowledgeBase, deleteKnowledgeBase as apiDeleteKnowledgeBase, fetchKnowledgeDocuments } from './knowledge/useKnowledge'
import './knowledge/knowledge.css'
// 获取已配置的模型列表
const { models, fetchModels } = useModelSettings()
// 页面加载时获取数据
onMounted(async () => {
fetchModels()
await fetchKbList()
})
// 获取知识库列表
const knowledgeBases = ref<any[]>([])
const loadingKb = ref(false)
const fetchKbList = async () => {
loadingKb.value = true
try {
const data = await fetchKnowledgeBases()
knowledgeBases.value = data
} finally {
loadingKb.value = false
}
}
// 筛选 LLM (chat) 模型
const llmModels = computed(() => {
return models.value.filter((m: any) => m.model_type === 'chat')
})
// 筛选 Embedding 模型
const embeddingModels = computed(() => {
return models.value.filter((m: any) => m.model_type === 'embedding')
})
// 步骤验证
const step1Valid = computed(() => !!newKbForm.value.name.trim())
const step2Valid = computed(() => !!modelConfig.value.llmModelId && !!modelConfig.value.embeddingModelId)
const step3Valid = computed(() => {
// Parsing - 如果选择 docling则需要填写 URL
if (parsingConfig.value.engine === 'docling') {
return !!parsingConfig.value.doclingUrl.trim()
}
return true
})
const step4Valid = computed(() => {
// Local 存储不需要额外配置
if (storageConfig.value.type === 'local') {
return true
}
// MinIO 存储需要填写所有字段
if (storageConfig.value.type === 'minio') {
return !!(storageConfig.value.endpoint && storageConfig.value.accessKeyId && storageConfig.value.secretAccessKey && storageConfig.value.bucket)
}
// S3 暂时默认通过
return true
})
// 获取当前步骤是否有效
const isCurrentStepValid = computed(() => {
switch (createStep.value) {
case 1: return step1Valid.value
case 2: return step2Valid.value
case 3: return step3Valid.value
case 4: return step4Valid.value
default: return false
}
})
// 步骤是否可以点击(只能点击当前步骤或已完成的步骤)
const canClickStep = (step: number) => {
if (step === 1) return true
if (step === createStep.value) return true
// 可以点击已完成的前面的步骤
if (step < createStep.value) {
switch (step) {
case 1: return step1Valid.value
case 2: return step2Valid.value
case 3: return step3Valid.value
case 4: return step4Valid.value
default: return false
}
}
return false
}
// 下一步
const nextStep = () => {
if (!isCurrentStepValid.value) {
ElMessage.warning('Please complete the current step first')
return
}
if (createStep.value < 4) {
createStep.value++
}
}
// 上一步
const prevStep = () => {
if (createStep.value > 1) {
createStep.value--
}
}
const loading = ref(false)
const searchQuery = ref('')
// 筛选知识库
const filteredKnowledgeBases = () => {
if (!searchQuery.value) return knowledgeBases.value
const query = searchQuery.value.toLowerCase()
return knowledgeBases.value.filter(kb =>
kb.name.toLowerCase().includes(query) ||
kb.description.toLowerCase().includes(query)
)
}
// 新建知识库 - 分步骤
const showCreateDialog = ref(false)
const createStep = ref(1)
// 文件上传弹窗
const showFileUploadDialog = ref(false)
const selectedKnowledge = ref<any>(null)
const fileFilter = ref('all') // all, parsed, parsing, failed
const selectedFile = ref<any>(null) // 当前选中的文件ID
const selectedDocument = ref<any>(null) // 当前选中的文档详情
const knowledgeDocuments = ref<any[]>([]) // 知识库文档列表
const loadingDocuments = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
const previewUrl = ref('') // 文档预览URL (blob URL)
const previewDownloadUrl = ref('') // 原始下载链接
const loadingPreview = ref(false)
const previewPage = ref(1) // 当前页码
const previewTotalPages = ref(1) // 总页数
// 使用代理接口加载PDF
const loadPdfWithProxy = async (doc: any): Promise<string> => {
if (!selectedKnowledge.value || !doc.file_key) {
return ''
}
try {
const { getFileProxyUrl } = await import('./knowledge/useKnowledge')
const proxyUrl = getFileProxyUrl(selectedKnowledge.value.id, doc.file_key)
console.log('Using proxy URL for PDF:', proxyUrl)
return proxyUrl
} catch (error) {
console.error('Failed to get proxy URL:', error)
return ''
}
}
const newKbForm = ref({
name: '',
description: '',
})
// 模型配置
const modelConfig = ref({
llmModelId: '',
embeddingModelId: '',
})
const parsingConfig = ref({
enablePdf: true,
engine: 'markitdown',
doclingUrl: '',
pandoc: true,
academic: false,
highRes: false,
fileSizeLimit: '5242880',
})
// Storage 配置
const storageConfig = ref({
type: 'local',
endpoint: '',
accessKeyId: '',
secretAccessKey: '',
bucket: '',
})
const openCreateDialog = () => {
createStep.value = 1
newKbForm.value = { name: '', description: '' }
modelConfig.value = { llmModelId: '', embeddingModelId: '' }
parsingConfig.value = {
enablePdf: true,
engine: 'markitdown',
doclingUrl: '',
pandoc: true,
academic: false,
highRes: false,
fileSizeLimit: '5242880',
}
storageConfig.value = { type: 'local' }
showCreateDialog.value = true
}
const cancelCreate = () => {
newKbForm.value = { name: '', description: '' }
modelConfig.value = { llmModelId: '', embeddingModelId: '' }
parsingConfig.value = {
enablePdf: true,
engine: 'markitdown',
doclingUrl: '',
pandoc: true,
academic: false,
highRes: false,
fileSizeLimit: '5242880',
}
storageConfig.value = { type: 'local' }
showCreateDialog.value = false
}
const createKnowledgeBase = async () => {
const result = await apiCreateKnowledgeBase({
name: newKbForm.value.name,
description: newKbForm.value.description,
llm_model_id: modelConfig.value.llmModelId,
embedding_model_id: modelConfig.value.embeddingModelId,
parsing_config: {
engine: parsingConfig.value.engine,
docling_url: parsingConfig.value.engine === 'docling' ? parsingConfig.value.doclingUrl : undefined,
enable_pdf: parsingConfig.value.enablePdf,
pandoc: parsingConfig.value.pandoc,
},
storage_config: {
type: storageConfig.value.type,
endpoint: storageConfig.value.type === 'minio' ? storageConfig.value.endpoint : undefined,
access_key_id: storageConfig.value.type === 'minio' ? storageConfig.value.accessKeyId : undefined,
secret_access_key: storageConfig.value.type === 'minio' ? storageConfig.value.secretAccessKey : undefined,
bucket: storageConfig.value.type === 'minio' ? storageConfig.value.bucket : undefined,
}
})
if (result.success) {
await fetchKbList()
newKbForm.value = { name: '', description: '' }
modelConfig.value = { llmModelId: '', embeddingModelId: '' }
parsingConfig.value = {
enablePdf: true,
engine: 'markitdown',
doclingUrl: '',
pandoc: true,
academic: false,
highRes: false,
fileSizeLimit: '5242880',
}
showCreateDialog.value = false
ElMessage.success('Knowledge base created successfully')
} else {
ElMessage.error(result.message || 'Failed to create knowledge base')
}
}
// 编辑知识库
const showEditDialog = ref(false)
const editForm = ref({
id: '',
name: '',
description: '',
})
const openEdit = (kb: any) => {
editForm.value = {
id: kb.id,
name: kb.name,
description: kb.description
}
showEditDialog.value = true
}
const saveEdit = () => {
const kb = knowledgeBases.value.find(k => k.id === editForm.value.id)
if (kb) {
kb.name = editForm.value.name
kb.description = editForm.value.description
}
showEditDialog.value = false
ElMessage.success('Knowledge base updated successfully')
}
const cancelEdit = () => {
showEditDialog.value = false
}
// 删除知识库
const deleteKb = async (id: string) => {
const result = await apiDeleteKnowledgeBase(id)
if (result.success) {
await fetchKbList()
ElMessage.success('Knowledge base deleted')
} else {
ElMessage.error(result.message || 'Failed to delete knowledge base')
}
}
// 辅助函数:格式化文件大小
const formatFileSize = (bytes: number) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// 辅助函数:格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
}
// 辅助函数:获取状态图标
const getStatusIcon = (status: string) => {
switch (status) {
case 'parsed':
return 'fa-solid fa-check-circle'
case 'parsing':
return 'fa-solid fa-spinner fa-spin'
case 'failed':
return 'fa-solid fa-circle-xmark'
default:
return 'fa-solid fa-clock'
}
}
// 进入知识库(上传文档界面)
const enterKnowledge = async (kb: any) => {
selectedKnowledge.value = kb
selectedFile.value = null
previewUrl.value = ''
previewDownloadUrl.value = ''
// 获取文档列表
loadingDocuments.value = true
try {
const docs = await fetchKnowledgeDocuments(kb.id, fileFilter.value)
knowledgeDocuments.value = docs
// 自动选中第一个文档
if (docs && docs.length > 0) {
await selectDocument(docs[0])
}
} finally {
loadingDocuments.value = false
}
showFileUploadDialog.value = true
}
// 切换文件过滤标签时重新获取文档列表
const changeFileFilter = async (filter: string) => {
fileFilter.value = filter
if (selectedKnowledge.value) {
loadingDocuments.value = true
try {
const docs = await fetchKnowledgeDocuments(selectedKnowledge.value.id, filter)
knowledgeDocuments.value = docs
} finally {
loadingDocuments.value = false
}
}
}
// 选择文档
const selectDocument = async (doc: any) => {
selectedFile.value = doc.id
selectedDocument.value = doc
previewUrl.value = ''
previewDownloadUrl.value = ''
previewPage.value = 1
previewTotalPages.value = 1
// 优先使用代理接口加载PDF
if (doc.file_key && selectedKnowledge.value) {
previewUrl.value = await loadPdfWithProxy(doc)
if (previewUrl.value) {
return
}
}
// 如果代理失败尝试使用file_url
const fileUrl = doc.file_url || doc.fileUrl || doc.url || doc.FileURL
if (fileUrl) {
previewUrl.value = fileUrl
return
}
// 如果没有file_url调用预览API获取
if (selectedKnowledge.value) {
loadingPreview.value = true
try {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, doc.id)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || 1
}
} catch (error) {
console.error('Failed to get preview:', error)
} finally {
loadingPreview.value = false
}
}
}
// 翻页
const changePreviewPage = async (page: number) => {
if (!selectedKnowledge.value || !selectedFile.value || page < 1 || page > previewTotalPages.value) return
loadingPreview.value = true
previewPage.value = page
try {
const { getDocumentPreview } = await import('./knowledge/useKnowledge')
const result = await getDocumentPreview(selectedKnowledge.value.id, selectedFile.value, page)
if (result.success && result.data?.content) {
previewUrl.value = result.data.content
previewTotalPages.value = result.data.total_pages || 1
previewPage.value = result.data.current_page || page
}
} catch (error) {
console.error('Failed to get preview:', error)
} finally {
loadingPreview.value = false
}
}
// 触发文件上传
const triggerFileUpload = () => {
fileInput.value?.click()
}
// 处理文件选择
const handleFileSelect = async (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !selectedKnowledge.value) return
uploading.value = true
try {
const { uploadDocument } = await import('./knowledge/useKnowledge')
const result = await uploadDocument(selectedKnowledge.value.id, file)
if (result.success) {
ElMessage.success('File uploaded successfully')
// 刷新文档列表以获取最新数据(包括 file_key
await changeFileFilter(fileFilter.value)
// 获取刚上传的文档
const uploadedDoc = knowledgeDocuments.value.find(d => d.id === result.id)
if (uploadedDoc) {
// 选中新上传的文档
selectedFile.value = result.id
selectedDocument.value = uploadedDoc
// 使用代理接口加载PDF
if (uploadedDoc.file_key) {
previewUrl.value = await loadPdfWithProxy(uploadedDoc)
} else if (uploadedDoc.file_url) {
previewUrl.value = uploadedDoc.file_url
}
}
} else {
ElMessage.error(result.message || 'Failed to upload file')
}
} catch (error) {
ElMessage.error('Failed to upload file')
} finally {
uploading.value = false
// 清空 input 以便可以再次选择相同文件
target.value = ''
}
}
// 删除文档
const deleteDocument = async (docId: string) => {
if (!selectedKnowledge.value) return
try {
const { deleteDocument: apiDeleteDocument } = await import('./knowledge/useKnowledge')
const result = await apiDeleteDocument(selectedKnowledge.value.id, docId)
if (result.success) {
ElMessage.success('Document deleted')
// 如果删除的是当前选中的文件,清除选中状态
if (selectedFile.value === docId) {
selectedFile.value = null
selectedDocument.value = null
previewUrl.value = ''
previewDownloadUrl.value = ''
}
// 刷新文档列表
await changeFileFilter(fileFilter.value)
} else {
ElMessage.error(result.message || 'Failed to delete document')
}
} catch (error) {
ElMessage.error('Failed to delete document')
}
}
</script>
<template>
<div class="p-6 min-h-screen">
<!-- 顶部导航 -->
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-2">
<i class="fa-solid fa-brain text-gray-400"></i>
<span class="font-medium">Knowledge Base</span>
</div>
<button @click="openCreateDialog" class="btn-primary">
<i class="fa-solid fa-plus"></i>
New Knowledge Base
</button>
</div>
<!-- 搜索 -->
<div class="flex gap-4 mb-6">
<div class="flex-1 relative">
<i class="fa-solid fa-search absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
v-model="searchQuery"
type="text"
placeholder="Search knowledge bases by name or description..."
class="search-input w-full"
>
</div>
</div>
<!-- Knowledge Base 列表 -->
<div class="bg-dark-700 rounded-xl overflow-hidden">
<div v-if="loading" class="py-12 text-center text-gray-400">
<i class="fa-solid fa-circle-notch fa-spin text-2xl mb-2"></i>
<p>Loading...</p>
</div>
<table v-else-if="filteredKnowledgeBases().length > 0" class="w-full">
<thead class="bg-dark-600">
<tr>
<th class="text-left px-5 py-3 text-sm font-medium text-gray-400">Name</th>
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Documents</th>
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Chunks</th>
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Status</th>
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Created</th>
<th class="text-center px-5 py-3 text-sm font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="kb in filteredKnowledgeBases()" :key="kb.id" class="table-row">
<td class="px-5 py-4">
<div class="font-medium">{{ kb.name }}</div>
<div class="text-sm text-gray-500">{{ kb.description }}</div>
</td>
<td class="px-5 py-4 text-center">
<span class="text-primary-cyan">{{ kb.document_count }}</span>
</td>
<td class="px-5 py-4 text-center text-gray-400 text-sm">
{{ kb.chunk_count }}
</td>
<td class="px-5 py-4 text-center">
<span v-if="kb.status === 'ready'" class="bg-primary-success/20 text-primary-success px-2 py-1 rounded text-sm">
Ready
</span>
<span v-else-if="kb.status === 'processing'" class="bg-primary-warning/20 text-primary-warning px-2 py-1 rounded text-sm">
Processing
</span>
</td>
<td class="px-5 py-4 text-center text-gray-400 text-sm">
{{ kb.created_at?.split('T')[0] }}
</td>
<td class="px-5 py-4">
<div class="flex items-center justify-center gap-2">
<button
@click="enterKnowledge(kb)"
class="p-2 rounded-lg transition-colors hover:bg-dark-600"
title="Enter"
>
<i class="fa-solid fa-door-open text-gray-400 hover:text-primary-orange"></i>
</button>
<button
@click="openEdit(kb)"
class="p-2 rounded-lg transition-colors hover:bg-dark-600"
title="Edit"
>
<i class="fa-solid fa-pen text-gray-400 hover:text-primary-danger"></i>
</button>
<button
@click="deleteKb(kb.id)"
class="p-2 rounded-lg transition-colors hover:bg-dark-600"
title="Delete"
>
<i class="fa-solid fa-trash text-gray-400 hover:text-primary-danger"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
<!-- 空状态 -->
<div v-else class="empty-box">
<div class="empty-icon">
<i class="fa-solid fa-brain"></i>
</div>
<p class="empty-text">No knowledge bases found</p>
<p class="empty-tip">Click "New Knowledge Base" to create one</p>
</div>
</div>
<!-- 新建知识库弹窗 -->
<el-dialog
v-model="showCreateDialog"
title="Create Knowledge Base"
width="1100px"
:close-on-click-modal="false"
:show-close="false"
class="kb-dialog kb-create-dialog"
>
<div class="create-layout">
<!-- 左侧选项列表 -->
<div class="sidebar">
<div
class="menu-item"
:class="{ active: createStep === 1 }"
@click="createStep = 1"
>
<i class="fa-solid fa-layer-group menu-icon"></i>
<div class="menu-content">
<span class="menu-title">Basic Info</span>
<span class="menu-desc">Name and description</span>
</div>
<i v-if="createStep === 1" class="fa-solid fa-chevron-right menu-arrow"></i>
<i v-else-if="createStep > 1 && step1Valid" class="fa-solid fa-check-circle menu-check"></i>
</div>
<div
class="menu-item"
:class="{ active: createStep === 2, disabled: !canClickStep(2) }"
@click="canClickStep(2) && (createStep = 2)"
>
<i class="fa-solid fa-robot menu-icon"></i>
<div class="menu-content">
<span class="menu-title">Model Config</span>
<span class="menu-desc">Embedding & rerank models</span>
</div>
<i v-if="createStep === 2" class="fa-solid fa-chevron-right menu-arrow"></i>
<i v-else-if="createStep > 2 && step2Valid" class="fa-solid fa-check-circle menu-check"></i>
</div>
<div
class="menu-item"
:class="{ active: createStep === 3, disabled: !canClickStep(3) }"
@click="canClickStep(3) && (createStep = 3)"
>
<i class="fa-solid fa-file-lines menu-icon"></i>
<div class="menu-content">
<span class="menu-title">Parsing</span>
<span class="menu-desc">Document parsing settings</span>
</div>
<i v-if="createStep === 3" class="fa-solid fa-chevron-right menu-arrow"></i>
<i v-else-if="createStep > 3 && step3Valid" class="fa-solid fa-check-circle menu-check"></i>
</div>
<div
class="menu-item"
:class="{ active: createStep === 4, disabled: !canClickStep(4) }"
@click="canClickStep(4) && (createStep = 4)"
>
<i class="fa-solid fa-hard-drive menu-icon"></i>
<div class="menu-content">
<span class="menu-title">Storage</span>
<span class="menu-desc">Vector & file storage</span>
</div>
<i v-if="createStep === 4" class="fa-solid fa-chevron-right menu-arrow"></i>
<i v-else-if="createStep > 4 && step4Valid" class="fa-solid fa-check-circle menu-check"></i>
</div>
</div>
<!-- 右侧内容区 -->
<div class="content">
<!-- Step 1: Basic Info -->
<div v-if="createStep === 1" class="form-content">
<div class="section-header">
<i class="fa-solid fa-layer-group section-icon"></i>
<span class="section-title">Basic Info</span>
</div>
<el-form label-position="top" class="kb-form">
<el-form-item label="Name" required>
<el-input v-model="newKbForm.name" placeholder="Enter knowledge base name" />
</el-form-item>
<el-form-item label="Description">
<el-input v-model="newKbForm.description" type="textarea" :rows="4" placeholder="Enter knowledge base description" />
</el-form-item>
</el-form>
</div>
<!-- Step 2: Model Config -->
<div v-if="createStep === 2" class="form-content">
<div class="section-header">
<i class="fa-solid fa-robot section-icon"></i>
<span class="section-title">Model Config</span>
</div>
<el-form label-position="top" class="kb-form">
<el-form-item label="LLM Model">
<el-select v-model="modelConfig.llmModelId" placeholder="Select a chat model" class="w-full" popper-class="dark-select-dropdown">
<el-option
v-for="model in llmModels"
:key="model.id"
:label="model.name"
:value="model.id"
>
<div class="model-option">
<span class="model-name">{{ model.name }}</span>
<span class="model-info">{{ model.provider }} - {{ model.model }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="Embedding Model">
<el-select v-model="modelConfig.embeddingModelId" placeholder="Select an embedding model" class="w-full" popper-class="dark-select-dropdown">
<el-option
v-for="model in embeddingModels"
:key="model.id"
:label="model.name"
:value="model.id"
>
<div class="model-option">
<span class="model-name">{{ model.name }}</span>
<span class="model-info">{{ model.provider }} - {{ model.model }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<!-- Step 3: Parsing -->
<div v-if="createStep === 3" class="form-content">
<div class="section-header">
<i class="fa-solid fa-file-lines section-icon"></i>
<span class="section-title">Parsing</span>
</div>
<div class="parsing-section">
<div class="parsing-row">
<span class="parsing-label">Parsing Engine</span>
<el-select v-model="parsingConfig.engine" placeholder="Select engine" class="parsing-select" popper-class="dark-select-dropdown">
<el-option label="MarkItDown" value="markitdown" />
<el-option label="Docling" value="docling" />
</el-select>
</div>
<!-- Docling URL 配置 -->
<div v-if="parsingConfig.engine === 'docling'" class="parsing-row" style="margin-top: 12px; align-items: center;">
<span class="parsing-label" style="flex: 1;">Docling URL</span>
<input
v-model="parsingConfig.doclingUrl"
type="text"
placeholder="http://localhost:8080"
class="input-field"
style="width: 350px;"
>
</div>
</div>
</div>
<!-- Step 4: Storage -->
<div v-if="createStep === 4" class="form-content">
<div class="section-header">
<i class="fa-solid fa-hard-drive section-icon"></i>
<span class="section-title">Storage</span>
</div>
<el-form label-position="top" class="kb-form">
<el-form-item label="Storage Type">
<el-select v-model="storageConfig.type" placeholder="Select storage type" class="w-full" popper-class="dark-select-dropdown">
<el-option label="Local" value="local" />
<el-option label="MinIO" value="minio" />
<el-option label="S3" value="s3" />
</el-select>
</el-form-item>
<!-- MinIO 配置 -->
<template v-if="storageConfig.type === 'minio'">
<el-form-item label="Endpoint">
<el-input v-model="storageConfig.endpoint" placeholder="http://localhost:9000" />
</el-form-item>
<el-form-item label="Access Key ID">
<el-input v-model="storageConfig.accessKeyId" placeholder="Enter Access Key ID" />
</el-form-item>
<el-form-item label="Secret Access Key">
<el-input v-model="storageConfig.secretAccessKey" type="password" placeholder="Enter Secret Access Key" show-password />
</el-form-item>
<el-form-item label="Bucket">
<el-input v-model="storageConfig.bucket" placeholder="Enter Bucket name" />
</el-form-item>
</template>
</el-form>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<div class="footer-left">
<span class="step-hint">Step {{ createStep }} of 4</span>
</div>
<div class="footer-right">
<el-button @click="cancelCreate" class="cancel-btn">Cancel</el-button>
<el-button v-if="createStep > 1" @click="prevStep" class="prev-btn">
<i class="fa-solid fa-arrow-left"></i>
Previous
</el-button>
<el-button v-if="createStep < 4" :disabled="!isCurrentStepValid" @click="nextStep" class="next-btn">
Next
<i class="fa-solid fa-arrow-right"></i>
</el-button>
<el-button v-if="createStep === 4" :disabled="!isCurrentStepValid" @click="createKnowledgeBase" class="confirm-btn">
<i class="fa-solid fa-plus"></i>
Create
</el-button>
</div>
</div>
</template>
</el-dialog>
<!-- 编辑知识库弹窗 -->
<el-dialog
v-model="showEditDialog"
title="Edit Knowledge Base"
width="500px"
:close-on-click-modal="false"
class="kb-dialog"
>
<p class="dialog-desc">Edit knowledge base information</p>
<el-form label-position="top" class="kb-form">
<el-form-item label="Name">
<el-input v-model="editForm.name" placeholder="Enter knowledge base name" />
</el-form-item>
<el-form-item label="Description">
<el-input v-model="editForm.description" type="textarea" :rows="3" placeholder="Enter description" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button text @click="cancelEdit">Cancel</el-button>
<el-button type="primary" @click="saveEdit">Save</el-button>
</div>
</template>
</el-dialog>
<!-- 文件上传弹窗 -->
<el-dialog
v-model="showFileUploadDialog"
title=" "
width="calc(100vw - 40px)"
top="20px"
:close-on-click-modal="false"
:show-close="false"
class="kb-dialog file-upload-dialog"
>
<div class="file-upload-layout">
<!-- 顶部导航 -->
<div class="file-header">
<input
type="file"
ref="fileInput"
style="display: none"
accept=".pdf,.doc,.docx,.txt,.md"
@change="handleFileSelect"
/>
<button class="btn-primary" @click="triggerFileUpload">
<i class="fa-solid fa-upload"></i>
Upload
</button>
<h2 class="file-title">{{ selectedKnowledge?.name || 'Knowledge Base' }}</h2>
<button class="back-btn" @click="showFileUploadDialog = false">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- 标签栏 -->
<div class="file-tabs">
<div
class="file-tab"
:class="{ active: fileFilter === 'all' }"
@click="changeFileFilter('all')"
>
All
</div>
<div
class="file-tab"
:class="{ active: fileFilter === 'parsed' }"
@click="changeFileFilter('parsed')"
>
Parsed
</div>
<div
class="file-tab"
:class="{ active: fileFilter === 'parsing' }"
@click="changeFileFilter('parsing')"
>
Parsing
</div>
<div
class="file-tab"
:class="{ active: fileFilter === 'failed' }"
@click="changeFileFilter('failed')"
>
Failed
</div>
</div>
<!-- 内容区 - 左侧文件列表 + 右侧预览 -->
<div class="file-main">
<!-- 左侧文件列表 -->
<div class="file-list">
<!-- 加载状态 -->
<div v-if="loadingDocuments" class="loading-state">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>Loading...</span>
</div>
<!-- 无数据提示 -->
<div v-else-if="knowledgeDocuments.length === 0" class="empty-state">
<i class="fa-solid fa-folder-open"></i>
<span>No documents yet</span>
</div>
<!-- 文档列表 -->
<div
v-else
class="file-item"
:class="{ selected: selectedFile === doc.id }"
v-for="doc in knowledgeDocuments"
:key="doc.id"
@click="selectDocument(doc)"
>
<div class="file-item-icon">
<i class="fa-solid fa-file-pdf"></i>
</div>
<div class="file-item-info">
<div class="file-item-name">{{ doc.name }}</div>
<div class="file-item-meta">
<span>{{ formatFileSize(doc.file_size) }}</span>
<span>{{ formatDate(doc.uploaded_at) }}</span>
</div>
</div>
<div class="file-item-status" :class="doc.status">
<span v-if="doc.status === 'failed'" class="status-text">Failed</span>
<i v-else :class="getStatusIcon(doc.status)"></i>
</div>
</div>
</div>
<!-- 右侧预览面板 -->
<div class="file-preview">
<!-- 无选中文件时显示提示 -->
<div v-if="!selectedFile" class="preview-empty">
<i class="fa-solid fa-file-lines"></i>
<span>Select a document to preview</span>
</div>
<!-- 有选中文件时显示预览 -->
<template v-else>
<div class="preview-header">
<div class="preview-header-left">
<i class="fa-solid fa-file-pdf"></i>
<span class="preview-filename">{{ selectedDocument?.name || 'Document' }}</span>
</div>
<div class="preview-header-info">
<span class="info-tag">{{ formatFileSize(selectedDocument?.file_size) }}</span>
<span class="info-tag">{{ formatDate(selectedDocument?.uploaded_at) }}</span>
<span class="info-tag" :class="selectedDocument?.status">{{ selectedDocument?.status === 'parsed' ? 'Parsed' : selectedDocument?.status === 'parsing' ? 'Parsing' : selectedDocument?.status === 'failed' ? 'Failed' : 'Pending' }}</span>
<span class="info-tag">{{ selectedDocument?.chunk_count || 0 }} chunks</span>
</div>
</div>
<div class="preview-content">
<!-- PDF文件预览 -->
<div class="pdf-preview">
<!-- 加载中状态 -->
<div v-if="loadingPreview" class="preview-loading">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>Loading preview...</span>
</div>
<!-- 有blob预览URL时显示PDF (使用iframe) -->
<iframe
v-else-if="previewUrl"
:src="previewUrl"
class="pdf-embed"
/>
<!-- 无预览但有下载链接时显示下载按钮 -->
<div v-else-if="previewDownloadUrl" class="preview-no-file">
<i class="fa-solid fa-file-pdf"></i>
<span>Cannot preview PDF directly</span>
<a :href="previewDownloadUrl" target="_blank" class="download-link">
<i class="fa-solid fa-download"></i> Download PDF
</a>
</div>
<!-- 无预览也无下载链接时显示提示 -->
<div v-else class="preview-no-file">
<i class="fa-solid fa-file-pdf"></i>
<span>Document preview not available</span>
</div>
</div>
</div>
</template>
<div class="preview-actions">
<button class="action-btn delete" @click="deleteDocument(selectedFile)">
<i class="fa-solid fa-trash"></i>
Delete
</button>
<button class="action-btn reparse">
<i class="fa-solid fa-rotate"></i>
Reparse
</button>
<button class="action-btn confirm">
<i class="fa-solid fa-check"></i>
Confirm
</button>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>