refactor(database): 重构知识库信息页面组件化实现
- 将DataBaseInfoView.vue拆分为多个独立组件: * DatabaseHeader.vue: 数据库头部信息展示和编辑功能 * FileTable.vue: 文件列表展示和操作功能 * FileDetailModal.vue: 文件详情弹窗展示 * FileUploadModal.vue: 文件上传和URL添加功能 * KnowledgeGraphSection.vue: 知识图谱展示区域 * QuerySection.vue: 查询功能区域 - 优化database.js store,增加完整的状态管理和业务逻辑 - 更新AppLayout.vue中数据库信息获取方法 - 添加工具函数文件file_utils.js和kb_utils.js - 更新KnowledgeGraphViewer.vue组件配置 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
80acc7ad8f
commit
40c2922132
39
docs/changelog/update.md
Normal file
39
docs/changelog/update.md
Normal file
@ -0,0 +1,39 @@
|
||||
目前已有的开发计划包括:
|
||||
|
||||
|
||||
### v2.0 Prerelease
|
||||
|
||||
💭 **Features Todo**
|
||||
- [x] 知识库使用 LightRAG(GraphRAG 轻量版)重构(🌟🌟🌟🌟🌟)
|
||||
- [x] 集成 MinerU 处理 PDF 文件。(🌟🌟🌟)
|
||||
- [x] 优化现有向量模型的逻辑,全局配置的向量模型作为新建知识库的默认模型,但是查询的时候,使用对应数据库的向量模型查询(单例模式)(🌟🌟🌟)
|
||||
- [x] 多类型知识库支持(🌟🌟🌟🌟🌟)
|
||||
- [x] 设计问答知识库,支持针对问答对的调优
|
||||
- [x] 添加了批量上传文件的脚本,并优化文件信息
|
||||
|
||||
🐛**BUGs**
|
||||
- [x] 智能体的工具切换有问题
|
||||
- [ ] 知识图谱可视化页面的效果有点问题
|
||||
- [x] 处理失败的文件,在特定情况下会一直处于 processing 状态,且无法删除
|
||||
- [ ] 与最新的 LightRAG 版本兼容性存在问题 #233
|
||||
|
||||
# 💯 More:
|
||||
|
||||
下面的功能会放在后续版本实现,暂时未定
|
||||
|
||||
- [ ] 封装现有工具为 mcp(stdio)调用
|
||||
- [ ] 支持额外 mcp 配置(代码端)
|
||||
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息(🌟🌟)
|
||||
- [ ] 对话页面支持文档/图片临时上传(🌟🌟🌟🌟)
|
||||
- [ ] 在 @web/src/components/ToolCallingResult/KnowledgeBaseResult.vue 文件中,添加点击某个片段的时候可以弹出信息预览框(最好可以提供源文件下载的功能),这里需要将DatabaseInfoView 里面的那个文件详情的弹窗给组件化。
|
||||
- 先实现 result 中可以按照 file_id 聚合信息;信息的展示以结果卡片的形式展示
|
||||
但是同时也要能够配置,选择显示源文件还是chunk的结果,如果可以的话,最好是在一个文件里面显示所有的chunk,而不是每个chunk都单独显示。不过似乎有点难度,毕竟是有 overlap 的存在,还是分开吧。对于URL链接的这种,可以选择直接跳转就可以。(sub1: sub2:根据 file_id 可以从后端获取文件的基本信息,比如可下载的路径,chunks 等信息;sub3:在前端获取到所有的 chunks 信息之后,能够根据传入的 chunks_idx 高亮被选中的 chunks 区域;sub4:如果可能的话,使用 markdown 展示(但是存在 overlap 拼接的问题。不知道现在知识库里面是否存储的有 fulltext,这时候 start_idx 和 end_idx 就有用了。)
|
||||
- [ ] 先实现在 DatabaseInfo.vue 中,点击文件详情按钮,能够弹出文件详情的弹窗,显示的方法有两种,一种是以现在的 chunk 的形式展示,一种是以 markdown渲染全文的形式,但是存在 overlap 拼接的问题。不知道现在知识库里面是否存储的有 fulltext,这时候 start_idx 和 end_idx 就有用了。
|
||||
- [ ] 各种结果的可视化:知识库检索页面,工具调用检索页面,知识图谱检索页面
|
||||
|
||||
|
||||
根据这个模糊的分析,判断一下这个需求应该如何实现,务必阅读所有相关的文献,包括但不限于当前代码中,保存文本片段的时候所保存的信息是否支撑功能的实现,当某些需求无法实现的时候,能否采用退而求其次的方法实现略差一些的功能(不要过度增加代码的复杂度)。
|
||||
|
||||
模糊的需求如下:先实现在 DatabaseInfo.vue 中,点击文件详情按钮,能够弹出文件详情的弹窗,显示的方法有两种,一种是以现在的 chunk 的形式展示,一种是以 markdown渲染全文的形式,但是存在 overlap 拼接的问题。不知道现在知识库里面是否存储的有 fulltext,这时候 start_idx 和 end_idx 就有用了。并不对,直接读取源文件的方法是错的,因为源文件并不全是 markdown 格式,可能是 html、pdf 等结构文件,因此,只能想办法拼接,而不是尝试读取文件。项目中已经安装了 md-editor-v3 ,可以参考 AgentMessageComponent.vue 中对于 markdown 的渲染。
|
||||
|
||||
先告诉我你要如何实现这些功能,重点实现的是 milvus db 和 chroma db ,lightrag db 尽量实现,实现不了也没事。相关的上下文如下 knowledge_router.py knowledge_base.py DataBaseInfoView.vue。
|
||||
@ -1,28 +0,0 @@
|
||||
目前已有的开发计划包括:
|
||||
|
||||
|
||||
### v2.0 Prerelease
|
||||
|
||||
💭 **Features Todo**
|
||||
- [x] 知识库使用 LightRAG(GraphRAG 轻量版)重构(🌟🌟🌟🌟🌟)
|
||||
- [x] 集成 MinerU 处理 PDF 文件。(🌟🌟🌟)
|
||||
- [x] 优化现有向量模型的逻辑,全局配置的向量模型作为新建知识库的默认模型,但是查询的时候,使用对应数据库的向量模型查询(单例模式)(🌟🌟🌟)
|
||||
- [x] 多类型知识库支持(🌟🌟🌟🌟🌟)
|
||||
- [x] 设计问答知识库,支持针对问答对的调优
|
||||
- [x] 添加了批量上传文件的脚本,并优化文件信息
|
||||
|
||||
🐛**BUGs**
|
||||
- [x] 智能体的工具切换有问题
|
||||
- [ ] 知识图谱可视化页面的效果有点问题
|
||||
- [ ] 处理失败的文件,在特定情况下会一直处于 processing 状态,且无法删除
|
||||
|
||||
# 💯 More:
|
||||
|
||||
下面的功能会放在后续版本实现,暂时未定
|
||||
|
||||
- [ ] 封装现有工具为 mcp(stdio)调用
|
||||
- [ ] 支持额外 mcp 配置(代码端)
|
||||
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息(🌟🌟)
|
||||
- [ ] 对话页面支持文档/图片临时上传(🌟🌟🌟🌟)
|
||||
- [ ] 在Prompt中添加对于参考文献的要求,同时要求使用链接的形式,在聊天页面点击该链接后,可以选择跳转或者弹出信息预览框(最好可以提供源文件下载的功能),这里需要将DatabaseInfoView 里面的那个文件详情的弹窗给组件化。但是同时也要能够配置,选择显示源文件还是chunk的结果,如果可以的话,最好是在一个文件里面显示所有的chunk,而不是每个chunk都单独显示。不过似乎有点难度,毕竟是有 overlap 的存在,还是分开吧。对于URL链接的这种,可以选择直接跳转就可以。
|
||||
- [ ] 各种结果的可视化:知识库检索页面,工具调用检索页面,知识图谱检索页面
|
||||
144
web/src/components/DatabaseHeader.vue
Normal file
144
web/src/components/DatabaseHeader.vue
Normal file
@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<HeaderComponent
|
||||
:title="database.name || '数据库信息加载中'"
|
||||
:loading="loading"
|
||||
class="database-info-header"
|
||||
>
|
||||
<template #left>
|
||||
<a-button @click="backToDatabase">
|
||||
<LeftOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
<template #behind-title>
|
||||
<a-button type="link" @click="showEditModal" :style="{ padding: '0px', color: 'inherit' }">
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="header-info">
|
||||
<span class="db-id">ID:
|
||||
<span style="user-select: all;">{{ database.db_id || 'N/A' }}</span>
|
||||
</span>
|
||||
<span class="file-count">{{ database.files ? Object.keys(database.files).length : 0 }} 文件</span>
|
||||
<a-tag color="blue">{{ database.embed_info?.name }}</a-tag>
|
||||
<a-tag
|
||||
:color="getKbTypeColor(database.kb_type || 'lightrag')"
|
||||
class="kb-type-tag"
|
||||
size="small"
|
||||
>
|
||||
<component :is="getKbTypeIcon(database.kb_type || 'lightrag')" class="type-icon" />
|
||||
{{ getKbTypeLabel(database.kb_type || 'lightrag') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
</HeaderComponent>
|
||||
|
||||
<!-- 添加编辑对话框 -->
|
||||
<a-modal v-model:open="editModalVisible" title="编辑知识库信息">
|
||||
<template #footer>
|
||||
<a-button danger @click="deleteDatabase" style="margin-right: auto; margin-left: 0;">
|
||||
<DeleteOutlined /> 删除数据库
|
||||
</a-button>
|
||||
<a-button key="back" @click="editModalVisible = false">取消</a-button>
|
||||
<a-button key="submit" type="primary" :loading="loading" @click="handleEditSubmit">确定</a-button>
|
||||
</template>
|
||||
<a-form :model="editForm" :rules="rules" ref="editFormRef" layout="vertical">
|
||||
<a-form-item label="知识库名称" name="name" required>
|
||||
<a-input v-model:value="editForm.name" placeholder="请输入知识库名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="知识库描述" name="description">
|
||||
<a-textarea v-model:value="editForm.description" placeholder="请输入知识库描述" :rows="4" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { getKbTypeLabel, getKbTypeIcon, getKbTypeColor } from '@/utils/kb_utils';
|
||||
import {
|
||||
LeftOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import HeaderComponent from '@/components/HeaderComponent.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const database = computed(() => store.database);
|
||||
const loading = computed(() => store.state.databaseLoading);
|
||||
|
||||
const editModalVisible = ref(false);
|
||||
const editFormRef = ref(null);
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入知识库名称' }]
|
||||
};
|
||||
|
||||
const backToDatabase = () => {
|
||||
router.push('/database');
|
||||
};
|
||||
|
||||
const showEditModal = () => {
|
||||
editForm.name = database.value.name || '';
|
||||
editForm.description = database.value.description || '';
|
||||
editModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleEditSubmit = () => {
|
||||
editFormRef.value.validate().then(async () => {
|
||||
await store.updateDatabaseInfo({
|
||||
name: editForm.name,
|
||||
description: editForm.description
|
||||
});
|
||||
editModalVisible.value = false;
|
||||
}).catch(err => {
|
||||
console.error('表单验证失败:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteDatabase = () => {
|
||||
store.deleteDatabase();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.database-info-header {
|
||||
padding: 8px 16px 6px 16px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.db-id {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.kb-type-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
</style>
|
||||
186
web/src/components/FileDetailModal.vue
Normal file
186
web/src/components/FileDetailModal.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="file?.filename || '文件详情'"
|
||||
width="1200px"
|
||||
:footer="null"
|
||||
@after-open-change="afterOpenChange"
|
||||
>
|
||||
<div class="file-detail-content" v-if="file">
|
||||
<div class="file-info-grid">
|
||||
<div class="info-item">
|
||||
<label>文件ID:</label>
|
||||
<span>{{ file.file_id }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>上传时间:</label>
|
||||
<span>{{ formatStandardTime(Math.round(file.created_at*1000)) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<label>处理状态:</label>
|
||||
<span class="status-badge" :class="file.status">
|
||||
{{ getStatusText(file.status) }} - {{ file.lines?.length || 0 }} 行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-content-section" v-if="file.lines && file.lines.length > 0">
|
||||
<h4>文件内容预览</h4>
|
||||
<div class="content-lines">
|
||||
<div
|
||||
v-for="(line, index) in file.lines"
|
||||
:key="index"
|
||||
class="content-line"
|
||||
>
|
||||
<span class="line-number">{{ index + 1 }}</span>
|
||||
<span class="line-text">{{ line.text || line }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<a-spin />
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-content">
|
||||
<p>暂无文件内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const visible = computed({
|
||||
get: () => store.state.fileDetailModalVisible,
|
||||
set: (value) => store.state.fileDetailModalVisible = value
|
||||
});
|
||||
|
||||
const file = computed(() => store.selectedFile);
|
||||
const loading = computed(() => store.state.fileDetailLoading);
|
||||
|
||||
const afterOpenChange = (open) => {
|
||||
if (!open) {
|
||||
store.selectedFile = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 导入工具函数
|
||||
import { getStatusText, formatStandardTime } from '@/utils/file_utils';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-detail-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.file-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.done {
|
||||
background-color: #f6ffed;
|
||||
color: #52c41a;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background-color: #fff2f0;
|
||||
color: #ff4d4f;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.status-badge.processing {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border: 1px solid #91d5ff;
|
||||
}
|
||||
|
||||
.status-badge.waiting {
|
||||
background-color: #fffbe6;
|
||||
color: #faad14;
|
||||
border: 1px solid #ffe58f;
|
||||
}
|
||||
|
||||
.file-content-section h4 {
|
||||
margin-bottom: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-lines {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.content-line {
|
||||
display: flex;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
width: 40px;
|
||||
color: #ccc;
|
||||
text-align: right;
|
||||
padding-right: 8px;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.line-text {
|
||||
flex: 1;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
459
web/src/components/FileTable.vue
Normal file
459
web/src/components/FileTable.vue
Normal file
@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<div class="file-table-container">
|
||||
<div class="panel-header">
|
||||
<div class="search-container">
|
||||
<a-button
|
||||
type="secondary"
|
||||
@click="showAddFilesModal"
|
||||
:loading="refreshing"
|
||||
:icon="h(PlusOutlined)"
|
||||
>添加文件</a-button>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="handleRefresh"
|
||||
:loading="refreshing"
|
||||
:icon="h(ReloadOutlined)"
|
||||
title="刷新"
|
||||
/>
|
||||
<a-button
|
||||
@click="toggleAutoRefresh"
|
||||
size="small"
|
||||
:type="autoRefresh ? 'primary' : 'default'"
|
||||
title="自动刷新文件状态"
|
||||
class="auto-refresh-btn panel-action-btn"
|
||||
>
|
||||
Auto
|
||||
</a-button>
|
||||
<a-input
|
||||
v-model:value="filenameFilter"
|
||||
placeholder="搜索文件名"
|
||||
size="small"
|
||||
style="width: 120px; margin-right: 8px; border-radius: 6px; padding: 4px 8px;"
|
||||
allow-clear
|
||||
@change="onFilterChange"
|
||||
/>
|
||||
<a-button
|
||||
type="text"
|
||||
@click="toggleRightPanel"
|
||||
:icon="h(ChevronLast)"
|
||||
title="切换右侧面板"
|
||||
class="panel-action-btn expand"
|
||||
:class="{ 'expanded': props.rightPanelVisible }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-actions-compact" v-if="selectedRowKeys.length > 0">
|
||||
<div class="batch-info">
|
||||
<span>{{ selectedRowKeys.length }} 项</span>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="selectAllFailedFiles"
|
||||
:disabled="lock"
|
||||
title="选择所有失败的文件"
|
||||
>
|
||||
选择失败
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
@click="handleBatchDelete"
|
||||
:loading="batchDeleting"
|
||||
:disabled="!canBatchDelete"
|
||||
:icon="h(DeleteOutlined)"
|
||||
title="批量删除"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columnsCompact"
|
||||
:data-source="filteredFiles"
|
||||
row-key="file_id"
|
||||
class="my-table-compact"
|
||||
size="small"
|
||||
:pagination="paginationCompact"
|
||||
:row-selection="{
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: onSelectChange,
|
||||
getCheckboxProps: getCheckboxProps
|
||||
}"
|
||||
:locale="{
|
||||
emptyText: emptyText
|
||||
}">
|
||||
<template #bodyCell="{ column, text, record }">
|
||||
<a-button v-if="column.key === 'filename'" class="main-btn" type="link" @click="openFileDetail(record)">
|
||||
<component :is="getFileIcon(text)" :style="{ marginRight: '6px', color: getFileIconColor(text) }" />
|
||||
{{ text }}
|
||||
</a-button>
|
||||
<span v-else-if="column.key === 'type'" :class="['span-type', text]">{{ text?.toUpperCase() }}</span>
|
||||
<div v-else-if="column.key === 'status'" style="display: flex; align-items: center; justify-content: flex-end;">
|
||||
<CheckCircleFilled v-if="text === 'done'" style="color: #41A317;"/>
|
||||
<CloseCircleFilled v-else-if="text === 'failed'" style="color: #FF4D4F;"/>
|
||||
<HourglassFilled v-else-if="text === 'processing'" style="color: #1677FF;"/>
|
||||
<ClockCircleFilled v-else-if="text === 'waiting'" style="color: #FFCD43;"/>
|
||||
</div>
|
||||
|
||||
<a-tooltip v-else-if="column.key === 'created_at'" :title="formatRelativeTime(Math.round(text*1000))" placement="left">
|
||||
<span>{{ formatRelativeTime(Math.round(text*1000)) }}</span>
|
||||
</a-tooltip>
|
||||
|
||||
<div v-else-if="column.key === 'action'" style="display: flex; gap: 4px;">
|
||||
<a-button class="del-btn" type="text"
|
||||
@click="handleDeleteFile(record.file_id)"
|
||||
:disabled="lock || record.status === 'processing' || record.status === 'waiting'"
|
||||
:icon="h(DeleteOutlined)"
|
||||
title="删除"
|
||||
/>
|
||||
</div>
|
||||
<span v-else>{{ text }}</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, h } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
HourglassFilled,
|
||||
CloseCircleFilled,
|
||||
ClockCircleFilled,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { ChevronLast } from 'lucide-vue-next';
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const props = defineProps({
|
||||
rightPanelVisible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'showAddFilesModal',
|
||||
'toggleRightPanel',
|
||||
]);
|
||||
|
||||
const files = computed(() => Object.values(store.database.files || {}));
|
||||
const refreshing = computed(() => store.state.refrashing);
|
||||
const lock = computed(() => store.state.lock);
|
||||
const batchDeleting = computed(() => store.state.batchDeleting);
|
||||
const autoRefresh = computed(() => store.state.autoRefresh);
|
||||
const selectedRowKeys = computed({
|
||||
get: () => store.selectedRowKeys,
|
||||
set: (keys) => store.selectedRowKeys = keys,
|
||||
});
|
||||
|
||||
// 文件名过滤
|
||||
const filenameFilter = ref('');
|
||||
|
||||
// 紧凑表格列定义
|
||||
const columnsCompact = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
ellipsis: true,
|
||||
width: undefined, // 不设置宽度,让它占据剩余空间
|
||||
sorter: (a, b) => (a.filename || '').localeCompare(b.filename || ''),
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
sorter: (a, b) => (a.created_at || 0) - (b.created_at || 0),
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 60,
|
||||
align: 'right',
|
||||
sorter: (a, b) => {
|
||||
const statusOrder = { 'done': 1, 'processing': 2, 'waiting': 3, 'failed': 4 };
|
||||
return (statusOrder[a.status] || 5) - (statusOrder[b.status] || 5);
|
||||
},
|
||||
sortDirections: ['ascend', 'descend']
|
||||
},
|
||||
{ title: '', key: 'action', dataIndex: 'file_id', width: 40, align: 'center' }
|
||||
];
|
||||
|
||||
// 过滤后的文件列表
|
||||
const filteredFiles = computed(() => {
|
||||
let filtered = files.value;
|
||||
|
||||
// 应用文件名过滤
|
||||
if (filenameFilter.value.trim()) {
|
||||
const filterText = filenameFilter.value.toLowerCase().trim();
|
||||
filtered = files.value.filter(file =>
|
||||
file.filename && file.filename.toLowerCase().includes(filterText)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// 空状态文本
|
||||
const emptyText = computed(() => {
|
||||
return filenameFilter.value ? `没有找到包含"${filenameFilter.value}"的文件` : '暂无文件';
|
||||
});
|
||||
|
||||
// 紧凑分页配置
|
||||
const paginationCompact = ref({
|
||||
pageSize: 20,
|
||||
current: 1,
|
||||
total: 0,
|
||||
showSizeChanger: false,
|
||||
showTotal: (total) => `${total}`,
|
||||
size: 'small',
|
||||
showQuickJumper: false,
|
||||
onChange: (page, pageSize) => {
|
||||
paginationCompact.value.current = page;
|
||||
paginationCompact.value.pageSize = pageSize;
|
||||
selectedRowKeys.value = [];
|
||||
},
|
||||
});
|
||||
|
||||
// 监听过滤后的文件列表变化,更新分页总数
|
||||
watch(filteredFiles, (newFiles) => {
|
||||
paginationCompact.value.total = newFiles.length;
|
||||
}, { immediate: true });
|
||||
|
||||
// 计算是否可以批量删除
|
||||
const canBatchDelete = computed(() => {
|
||||
return selectedRowKeys.value.some(key => {
|
||||
const file = filteredFiles.value.find(f => f.file_id === key);
|
||||
return file && !(lock.value || file.status === 'processing' || file.status === 'waiting');
|
||||
});
|
||||
});
|
||||
|
||||
const showAddFilesModal = () => {
|
||||
emit('showAddFilesModal');
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
store.getDatabaseInfo();
|
||||
};
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
store.toggleAutoRefresh();
|
||||
};
|
||||
|
||||
const toggleRightPanel = () => {
|
||||
console.log(props.rightPanelVisible);
|
||||
emit('toggleRightPanel');
|
||||
};
|
||||
|
||||
const onSelectChange = (keys) => {
|
||||
selectedRowKeys.value = keys;
|
||||
};
|
||||
|
||||
const getCheckboxProps = (record) => ({
|
||||
disabled: lock.value || record.status === 'processing' || record.status === 'waiting',
|
||||
});
|
||||
|
||||
const onFilterChange = (e) => {
|
||||
filenameFilter.value = e.target.value;
|
||||
};
|
||||
|
||||
const handleDeleteFile = (fileId) => {
|
||||
store.handleDeleteFile(fileId);
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
store.handleBatchDelete();
|
||||
}
|
||||
|
||||
// 选择所有失败的文件
|
||||
const selectAllFailedFiles = () => {
|
||||
store.selectAllFailedFiles();
|
||||
};
|
||||
|
||||
const openFileDetail = (record) => {
|
||||
store.openFileDetail(record);
|
||||
};
|
||||
|
||||
// 导入工具函数
|
||||
import { getFileIcon, getFileIconColor, formatRelativeTime } from '@/utils/file_utils';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-table-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.batch-actions-compact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
background-color: var(--main-5);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.batch-info span {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.batch-actions-compact .ant-btn {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 22px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.batch-actions-compact .ant-btn:hover {
|
||||
background-color: var(--main-20);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.my-table-compact {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: transparent;
|
||||
min-height: 0; /* 让 flex 子项可以正确缩小 */
|
||||
}
|
||||
|
||||
.my-table-compact .main-btn {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.my-table-compact .main-btn:hover {
|
||||
cursor: pointer;
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.my-table-compact .del-btn {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.my-table-compact .del-btn:hover {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.my-table-compact .del-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.my-table-compact .span-type {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.auto-refresh-btn {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.panel-action-btn {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--gray-300);
|
||||
background-color: var(--gray-50);
|
||||
color: var(--gray-700);
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&.expand {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-action-btn.expanded {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scaleX(1);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.panel-action-btn.auto-refresh-btn.ant-btn-primary {
|
||||
background-color: var(--main-color);
|
||||
border-color: var(--main-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel-action-btn.auto-refresh-btn.ant-btn-primary:hover {
|
||||
background-color: var(--main-color) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.panel-action-btn:hover {
|
||||
background-color: var(--main-5);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.panel-action-btn.expanded:hover {
|
||||
background-color: var(--main-5);
|
||||
border-color: var(--main-color);
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
/* Table row selection styling */
|
||||
:deep(.ant-table-tbody > tr.ant-table-row-selected > td) {
|
||||
background-color: var(--main-5);
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: var(--main-5);
|
||||
}
|
||||
</style>
|
||||
541
web/src/components/FileUploadModal.vue
Normal file
541
web/src/components/FileUploadModal.vue
Normal file
@ -0,0 +1,541 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
title="添加文件"
|
||||
width="800px"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<template #footer>
|
||||
<a-button key="back" @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
key="submit"
|
||||
type="primary"
|
||||
@click="chunkData"
|
||||
:loading="chunkLoading"
|
||||
:disabled="(uploadMode === 'file' && fileList.length === 0) || (uploadMode === 'url' && !urlList.trim())"
|
||||
>
|
||||
添加到知识库
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="add-files-content">
|
||||
<div class="upload-header">
|
||||
<div class="source-selector">
|
||||
<div class="upload-mode-selector" @click="uploadMode = 'file'" :class="{ active: uploadMode === 'file' }">
|
||||
<FileOutlined /> 上传文件
|
||||
</div>
|
||||
<div class="upload-mode-selector" @click="uploadMode = 'url'" :class="{ active: uploadMode === 'url' }">
|
||||
<LinkOutlined /> 输入网址
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-controls">
|
||||
<a-button type="dashed" @click="showChunkConfigModal">
|
||||
<SettingOutlined /> 分块参数 ({{ chunkParams.chunk_size }}/{{ chunkParams.chunk_overlap }})
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ocr-config">
|
||||
<a-form layout="horizontal">
|
||||
<a-form-item label="使用OCR" name="enable_ocr">
|
||||
<div class="ocr-controls">
|
||||
<a-select
|
||||
v-model:value="chunkParams.enable_ocr"
|
||||
:options="enableOcrOptions"
|
||||
style="width: 220px; margin-right: 12px;"
|
||||
:disabled="ocrHealthChecking"
|
||||
/>
|
||||
<a-button
|
||||
size="small"
|
||||
type="dashed"
|
||||
@click="checkOcrHealth"
|
||||
:loading="ocrHealthChecking"
|
||||
:icon="h(CheckCircleOutlined)"
|
||||
>
|
||||
检查OCR服务
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="param-description">
|
||||
<div v-if="chunkParams.enable_ocr !== 'disable' && selectedOcrStatus && selectedOcrStatus !== 'healthy'" class="ocr-warning">
|
||||
⚠️ {{ selectedOcrMessage }}
|
||||
</div>
|
||||
<div v-else-if="chunkParams.enable_ocr !== 'disable' && selectedOcrStatus === 'healthy'" class="ocr-healthy">
|
||||
✅ {{ selectedOcrMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<div class="qa-split-config" v-if="isQaSplitSupported">
|
||||
<a-form layout="horizontal">
|
||||
<a-form-item label="QA分割模式" name="use_qa_split">
|
||||
<div class="toggle-controls">
|
||||
<a-switch
|
||||
v-model:checked="chunkParams.use_qa_split"
|
||||
style="margin-right: 12px;"
|
||||
/>
|
||||
<span class="param-description">
|
||||
{{ chunkParams.use_qa_split ? '启用QA分割(忽略chunk大小设置)' : '使用普通分割模式' }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="chunkParams.use_qa_split"
|
||||
label="QA分隔符"
|
||||
name="qa_separator"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="chunkParams.qa_separator"
|
||||
placeholder="输入QA分隔符"
|
||||
style="width: 200px; margin-right: 12px;"
|
||||
/>
|
||||
<span class="param-description">
|
||||
用于分割不同QA对的分隔符,默认为3个换行符
|
||||
</span>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传区域 -->
|
||||
<div class="upload" v-if="uploadMode === 'file'">
|
||||
<a-upload-dragger
|
||||
class="upload-dragger"
|
||||
v-model:fileList="fileList"
|
||||
name="file"
|
||||
:multiple="true"
|
||||
:disabled="chunkLoading"
|
||||
:action="'/api/knowledge/files/upload?db_id=' + databaseId"
|
||||
:headers="getAuthHeaders()"
|
||||
@change="handleFileUpload"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<p class="ant-upload-text">点击或者把文件拖拽到这里上传</p>
|
||||
<p class="ant-upload-hint">
|
||||
目前仅支持上传文本文件,如 .pdf, .txt, .md。且同名文件无法重复添加
|
||||
</p>
|
||||
</a-upload-dragger>
|
||||
</div>
|
||||
|
||||
<!-- URL 输入区域 -->
|
||||
<div class="url-input" v-else>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="网页链接 (每行一个URL)">
|
||||
<a-textarea
|
||||
v-model:value="urlList"
|
||||
placeholder="请输入网页链接,每行一个"
|
||||
:rows="6"
|
||||
:disabled="chunkLoading"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<p class="url-hint">
|
||||
支持添加网页内容,系统会自动抓取网页文本并进行分块。请确保URL格式正确且可以公开访问。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 分块参数配置弹窗 -->
|
||||
<a-modal v-model:open="chunkConfigModalVisible" title="分块参数配置" width="500px">
|
||||
<template #footer>
|
||||
<a-button key="back" @click="chunkConfigModalVisible = false">取消</a-button>
|
||||
<a-button key="submit" type="primary" @click="handleChunkConfigSubmit">确定</a-button>
|
||||
</template>
|
||||
<div class="chunk-config-content">
|
||||
<div class="params-info">
|
||||
<p>调整分块参数可以控制文本的切分方式,影响检索质量和文档加载效率。</p>
|
||||
</div>
|
||||
<a-form
|
||||
:model="tempChunkParams"
|
||||
name="chunkConfig"
|
||||
autocomplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="Chunk Size" name="chunk_size">
|
||||
<a-input-number v-model:value="tempChunkParams.chunk_size" :min="100" :max="10000" style="width: 100%;" />
|
||||
<p class="param-description">每个文本片段的最大字符数</p>
|
||||
</a-form-item>
|
||||
<a-form-item label="Chunk Overlap" name="chunk_overlap">
|
||||
<a-input-number v-model:value="tempChunkParams.chunk_overlap" :min="0" :max="1000" style="width: 100%;" />
|
||||
<p class="param-description">相邻文本片段间的重叠字符数</p>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isQaSplitSupported" label="QA分割模式" name="use_qa_split">
|
||||
<a-switch v-model:checked="tempChunkParams.use_qa_split" />
|
||||
<p class="param-description">启用后将按QA对分割,忽略上述chunk大小设置</p>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="tempChunkParams.use_qa_split && isQaSplitSupported" label="QA分隔符" name="qa_separator">
|
||||
<a-input v-model:value="tempChunkParams.qa_separator" placeholder="输入QA分隔符" style="width: 100%;" />
|
||||
<p class="param-description">用于分割不同QA对的分隔符</p>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { ocrApi } from '@/apis/system_api';
|
||||
import {
|
||||
FileOutlined,
|
||||
LinkOutlined,
|
||||
SettingOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { h } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.visible,
|
||||
set: (value) => emit('update:visible', value)
|
||||
});
|
||||
|
||||
const databaseId = computed(() => store.databaseId);
|
||||
const kbType = computed(() => store.database.kb_type);
|
||||
const chunkLoading = computed(() => store.state.chunkLoading);
|
||||
|
||||
// 上传模式
|
||||
const uploadMode = ref('file');
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref([]);
|
||||
|
||||
// URL列表
|
||||
const urlList = ref('');
|
||||
|
||||
// OCR服务健康状态
|
||||
const ocrHealthStatus = ref({
|
||||
rapid_ocr: { status: 'unknown', message: '' },
|
||||
mineru_ocr: { status: 'unknown', message: '' },
|
||||
paddlex_ocr: { status: 'unknown', message: '' }
|
||||
});
|
||||
|
||||
// OCR健康检查状态
|
||||
const ocrHealthChecking = ref(false);
|
||||
|
||||
// 分块参数
|
||||
const chunkParams = ref({
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
enable_ocr: 'disable',
|
||||
use_qa_split: false,
|
||||
qa_separator: '\n\n\n',
|
||||
});
|
||||
|
||||
// 分块参数配置弹窗
|
||||
const chunkConfigModalVisible = ref(false);
|
||||
|
||||
// 临时分块参数(用于配置弹窗)
|
||||
const tempChunkParams = ref({
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 200,
|
||||
use_qa_split: false,
|
||||
qa_separator: '\n\n\n',
|
||||
});
|
||||
|
||||
// 计算属性:是否支持QA分割
|
||||
const isQaSplitSupported = computed(() => {
|
||||
const type = kbType.value?.toLowerCase();
|
||||
return type === 'chroma' || type === 'milvus';
|
||||
});
|
||||
|
||||
// 计算属性:OCR选项
|
||||
const enableOcrOptions = computed(() => [
|
||||
{
|
||||
value: 'disable',
|
||||
label: '不启用',
|
||||
title: '不启用'
|
||||
},
|
||||
{
|
||||
value: 'onnx_rapid_ocr',
|
||||
label: getRapidOcrLabel(),
|
||||
title: 'ONNX with RapidOCR',
|
||||
disabled: ocrHealthStatus.value.rapid_ocr.status === 'unavailable' || ocrHealthStatus.value.rapid_ocr.status === 'error'
|
||||
},
|
||||
{
|
||||
value: 'mineru_ocr',
|
||||
label: getMinerULabel(),
|
||||
title: 'MinerU OCR',
|
||||
disabled: ocrHealthStatus.value.mineru_ocr.status === 'unavailable' || ocrHealthStatus.value.mineru_ocr.status === 'error'
|
||||
},
|
||||
{
|
||||
value: 'paddlex_ocr',
|
||||
label: getPaddleXLabel(),
|
||||
title: 'PaddleX OCR',
|
||||
disabled: ocrHealthStatus.value.paddlex_ocr.status === 'unavailable' || ocrHealthStatus.value.paddlex_ocr.status === 'error'
|
||||
},
|
||||
]);
|
||||
|
||||
// 获取当前选中OCR服务的状态
|
||||
const selectedOcrStatus = computed(() => {
|
||||
switch (chunkParams.value.enable_ocr) {
|
||||
case 'onnx_rapid_ocr':
|
||||
return ocrHealthStatus.value.rapid_ocr.status;
|
||||
case 'mineru_ocr':
|
||||
return ocrHealthStatus.value.mineru_ocr.status;
|
||||
case 'paddlex_ocr':
|
||||
return ocrHealthStatus.value.paddlex_ocr.status;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取当前选中OCR服务的状态消息
|
||||
const selectedOcrMessage = computed(() => {
|
||||
switch (chunkParams.value.enable_ocr) {
|
||||
case 'onnx_rapid_ocr':
|
||||
return ocrHealthStatus.value.rapid_ocr.message;
|
||||
case 'mineru_ocr':
|
||||
return ocrHealthStatus.value.mineru_ocr.message;
|
||||
case 'paddlex_ocr':
|
||||
return ocrHealthStatus.value.paddlex_ocr.message;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
// OCR选项标签生成函数
|
||||
const getRapidOcrLabel = () => {
|
||||
const status = ocrHealthStatus.value.rapid_ocr.status;
|
||||
const statusIcons = {
|
||||
'healthy': '✅',
|
||||
'unavailable': '❌',
|
||||
'error': '⚠️',
|
||||
'unknown': '❓'
|
||||
};
|
||||
return `${statusIcons[status] || '❓'} RapidOCR (ONNX)`;
|
||||
};
|
||||
|
||||
const getMinerULabel = () => {
|
||||
const status = ocrHealthStatus.value.mineru_ocr.status;
|
||||
const statusIcons = {
|
||||
'healthy': '✅',
|
||||
'unavailable': '❌',
|
||||
'unhealthy': '⚠️',
|
||||
'timeout': '⏰',
|
||||
'error': '⚠️',
|
||||
'unknown': '❓'
|
||||
};
|
||||
return `${statusIcons[status] || '❓'} MinerU OCR`;
|
||||
};
|
||||
|
||||
const getPaddleXLabel = () => {
|
||||
const status = ocrHealthStatus.value.paddlex_ocr.status;
|
||||
const statusIcons = {
|
||||
'healthy': '✅',
|
||||
'unavailable': '❌',
|
||||
'unhealthy': '⚠️',
|
||||
'timeout': '⏰',
|
||||
'error': '⚠️',
|
||||
'unknown': '❓'
|
||||
};
|
||||
return `${statusIcons[status] || '❓'} PaddleX OCR`;
|
||||
};
|
||||
|
||||
// 验证OCR服务可用性
|
||||
const validateOcrService = () => {
|
||||
if (chunkParams.value.enable_ocr === 'disable') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const status = selectedOcrStatus.value;
|
||||
if (status === 'unavailable' || status === 'error') {
|
||||
const ocrMessage = selectedOcrMessage.value;
|
||||
message.error(`OCR服务不可用: ${ocrMessage}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const handleFileUpload = (event) => {
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
const showChunkConfigModal = () => {
|
||||
tempChunkParams.value = {
|
||||
chunk_size: chunkParams.value.chunk_size,
|
||||
chunk_overlap: chunkParams.value.chunk_overlap,
|
||||
use_qa_split: isQaSplitSupported.value ? chunkParams.value.use_qa_split : false,
|
||||
qa_separator: chunkParams.value.qa_separator,
|
||||
};
|
||||
chunkConfigModalVisible.value = true;
|
||||
};
|
||||
|
||||
const handleChunkConfigSubmit = () => {
|
||||
chunkParams.value.chunk_size = tempChunkParams.value.chunk_size;
|
||||
chunkParams.value.chunk_overlap = tempChunkParams.value.chunk_overlap;
|
||||
// 只有支持QA分割的知识库类型才保存QA分割配置
|
||||
if (isQaSplitSupported.value) {
|
||||
chunkParams.value.use_qa_split = tempChunkParams.value.use_qa_split;
|
||||
chunkParams.value.qa_separator = tempChunkParams.value.qa_separator;
|
||||
} else {
|
||||
chunkParams.value.use_qa_split = false;
|
||||
}
|
||||
chunkConfigModalVisible.value = false;
|
||||
message.success('分块参数配置已更新');
|
||||
};
|
||||
|
||||
const checkOcrHealth = async () => {
|
||||
if (ocrHealthChecking.value) return;
|
||||
|
||||
ocrHealthChecking.value = true;
|
||||
try {
|
||||
const healthData = await ocrApi.getHealth();
|
||||
ocrHealthStatus.value = healthData.services;
|
||||
} catch (error) {
|
||||
console.error('OCR健康检查失败:', error);
|
||||
message.error('OCR服务健康检查失败');
|
||||
} finally {
|
||||
ocrHealthChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const userStore = useUserStore();
|
||||
return userStore.getAuthHeaders();
|
||||
};
|
||||
|
||||
const chunkData = async () => {
|
||||
// 验证OCR服务可用性
|
||||
if (!validateOcrService()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
if (uploadMode.value === 'file') {
|
||||
const files = fileList.value.filter(file => file.status === 'done').map(file => file.response?.file_path);
|
||||
// 过滤掉 undefined 或 null 的文件路径
|
||||
const validFiles = files.filter(file => file);
|
||||
if (validFiles.length === 0) {
|
||||
message.error('请先上传文件');
|
||||
return;
|
||||
}
|
||||
success = await store.addFiles({ items: validFiles, contentType: 'file', params: chunkParams.value });
|
||||
} else if (uploadMode.value === 'url') {
|
||||
const urls = urlList.value.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0 && (url.startsWith('http://') || url.startsWith('https://')));
|
||||
|
||||
if (urls.length === 0) {
|
||||
message.error('请输入有效的网页链接(必须以http://或https://开头)');
|
||||
return;
|
||||
}
|
||||
|
||||
success = await store.addFiles({ items: urls, contentType: 'url', params: chunkParams.value });
|
||||
}
|
||||
|
||||
if (success) {
|
||||
emit('update:visible', false);
|
||||
fileList.value = [];
|
||||
urlList.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.add-files-content {
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.upload-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.source-selector {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-mode-selector {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upload-mode-selector:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-mode-selector.active {
|
||||
border-color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.config-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ocr-config,
|
||||
.qa-split-config {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toggle-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.param-description {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ocr-warning {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.ocr-healthy {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.upload-dragger {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.url-hint {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chunk-config-content .params-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
183
web/src/components/KnowledgeGraphSection.vue
Normal file
183
web/src/components/KnowledgeGraphSection.vue
Normal file
@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="graph-section" :class="{ collapsed: !visible }" :style="style" v-if="isGraphSupported">
|
||||
<div class="section-header">
|
||||
<div class="header-left">
|
||||
<h3 class="section-title">知识图谱</h3>
|
||||
<div v-if="graphStats.displayed_nodes > 0 || graphStats.displayed_edges > 0" class="graph-stats">
|
||||
<a-tag color="blue" size="small">节点: {{ graphStats.displayed_nodes }}</a-tag>
|
||||
<a-tag color="green" size="small">边: {{ graphStats.displayed_edges }}</a-tag>
|
||||
<!-- <a-tag v-if="graphStats.is_truncated" color="red" size="small">已截断</a-tag> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="h(ReloadOutlined)"
|
||||
title="刷新"
|
||||
@click="refreshGraph"
|
||||
:disabled="!isGraphSupported"
|
||||
>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="h(DeleteOutlined)"
|
||||
title="清空"
|
||||
@click="clearGraph"
|
||||
:disabled="!isGraphSupported"
|
||||
>
|
||||
清空
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="h(ExpandOutlined)"
|
||||
title="最大化"
|
||||
@click="toggleGraphMaximize"
|
||||
:disabled="!isGraphSupported"
|
||||
>
|
||||
最大化
|
||||
</a-button>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="toggleVisible"
|
||||
title="折叠/展开"
|
||||
>
|
||||
<component :is="visible ? UpOutlined : DownOutlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-container-compact content" v-show="visible">
|
||||
<div v-if="!isGraphSupported" class="graph-disabled">
|
||||
<div class="disabled-content">
|
||||
<h4>知识图谱不可用</h4>
|
||||
<p>当前知识库类型 "{{ kbTypeLabel }}" 不支持知识图谱功能。</p>
|
||||
<p>只有 LightRAG 类型的知识库支持知识图谱。</p>
|
||||
</div>
|
||||
</div>
|
||||
<KnowledgeGraphViewer
|
||||
v-else
|
||||
:initial-database-id="databaseId"
|
||||
:hide-db-selector="true"
|
||||
:hide-stats="true"
|
||||
@update:stats="handleStatsUpdate"
|
||||
ref="graphViewerRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { getKbTypeLabel } from '@/utils/kb_utils';
|
||||
import { ReloadOutlined, DeleteOutlined, ExpandOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue';
|
||||
import KnowledgeGraphViewer from '@/components/KnowledgeGraphViewer.vue';
|
||||
import { h } from 'vue';
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
style: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
});
|
||||
|
||||
// 添加调试日志
|
||||
console.log('KnowledgeGraphSection props:', props);
|
||||
console.log('KnowledgeGraphSection style prop:', props.style);
|
||||
|
||||
const emit = defineEmits(['toggleVisible']);
|
||||
|
||||
const databaseId = computed(() => store.databaseId);
|
||||
const kbType = computed(() => store.database.kb_type);
|
||||
const graphStats = computed({
|
||||
get: () => store.graphStats,
|
||||
set: (stats) => store.graphStats = stats
|
||||
});
|
||||
|
||||
const graphViewerRef = ref(null);
|
||||
|
||||
// 计算属性:是否支持知识图谱
|
||||
const isGraphSupported = computed(() => {
|
||||
const type = kbType.value?.toLowerCase();
|
||||
return type === 'lightrag';
|
||||
});
|
||||
|
||||
const toggleVisible = () => {
|
||||
emit('toggleVisible');
|
||||
};
|
||||
|
||||
const refreshGraph = () => {
|
||||
if (graphViewerRef.value && typeof graphViewerRef.value.loadFullGraph === 'function') {
|
||||
graphViewerRef.value.loadFullGraph();
|
||||
}
|
||||
};
|
||||
|
||||
const clearGraph = () => {
|
||||
if (graphViewerRef.value && typeof graphViewerRef.value.clearGraph === 'function') {
|
||||
graphViewerRef.value.clearGraph();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGraphMaximize = () => {
|
||||
store.state.isGraphMaximized = !store.state.isGraphMaximized;
|
||||
};
|
||||
|
||||
const handleStatsUpdate = (stats) => {
|
||||
graphStats.value = stats;
|
||||
};
|
||||
|
||||
watch(isGraphSupported, (supported) => {
|
||||
if (supported) {
|
||||
setTimeout(() => {
|
||||
refreshGraph();
|
||||
}, 800);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.graph-section {
|
||||
|
||||
.graph-container-compact {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.graph-disabled {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.disabled-content {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -76,7 +76,7 @@
|
||||
<div v-if="!props.hideStats" class="stats-section">
|
||||
<a-tag color="blue" size="small">节点: {{ stats.displayed_nodes || 0 }}</a-tag>
|
||||
<a-tag color="green" size="small">边: {{ stats.displayed_edges || 0 }}</a-tag>
|
||||
<a-tag v-if="stats.is_truncated" color="red" size="small">已截断</a-tag>
|
||||
<!-- <a-tag v-if="stats.is_truncated" color="red" size="small">已截断</a-tag> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -419,6 +419,10 @@ const sigmaSettings = {
|
||||
edgeClickTolerance: 10, // 边点击容差,增加点击检测区域
|
||||
edgeHoverTolerance: 8, // 边悬停容差
|
||||
zoomToSizeRatioFunction: (x) => x, // 确保缩放时边的厚度保持合理
|
||||
eventListenerOptions: {
|
||||
wheel: { passive: true },
|
||||
touchstart: { passive: true },
|
||||
},
|
||||
}
|
||||
|
||||
// 初始化Sigma.js
|
||||
|
||||
323
web/src/components/QuerySection.vue
Normal file
323
web/src/components/QuerySection.vue
Normal file
@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="query-section" :class="{ collapsed: !visible }" :style="style">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">检索测试</h3>
|
||||
<div class="panel-actions">
|
||||
<a-popover trigger="click" placement="bottomRight" class="query-params-popover">
|
||||
<template #content>
|
||||
<div class="query-params-compact">
|
||||
<div v-if="loading" class="params-loading">
|
||||
<a-spin size="small" />
|
||||
</div>
|
||||
<div v-else class="params-grid">
|
||||
<div v-for="param in queryParams" :key="param.key" class="param-item">
|
||||
<label>{{ param.label }}:</label>
|
||||
<a-select
|
||||
v-if="param.type === 'select'"
|
||||
v-model:value="meta[param.key]"
|
||||
size="small"
|
||||
style="width: 80px;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="option in param.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-switch
|
||||
v-else-if="param.type === 'boolean'"
|
||||
v-model:checked="meta[param.key]"
|
||||
size="small"
|
||||
/>
|
||||
<a-input-number
|
||||
v-else-if="param.type === 'number'"
|
||||
v-model:value="meta[param.key]"
|
||||
size="small"
|
||||
style="width: 60px;"
|
||||
:min="param.min || 0"
|
||||
:max="param.max || 100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:icon="h(SettingOutlined)"
|
||||
title="查询参数"
|
||||
>查询参数</a-button>
|
||||
</a-popover>
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
@click="toggleVisible"
|
||||
title="折叠/展开"
|
||||
>
|
||||
<component :is="visible ? UpOutlined : DownOutlined" />
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="query-content content" v-show="visible">
|
||||
<div class="query-input-container">
|
||||
<a-textarea
|
||||
v-model:value="queryText"
|
||||
placeholder="输入查询内容"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
class="compact-query-textarea"
|
||||
/>
|
||||
<div class="query-actions-row">
|
||||
<a-button
|
||||
@click="onQuery"
|
||||
:loading="searchLoading"
|
||||
class="search-button"
|
||||
>
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<div class="query-examples-compact">
|
||||
<span class="examples-label">示例:</span>
|
||||
<div class="examples-container">
|
||||
<transition name="fade" mode="out-in">
|
||||
<a-button
|
||||
type="text"
|
||||
:key="currentExampleIndex"
|
||||
@click="useQueryExample(queryExamples[currentExampleIndex])"
|
||||
size="small"
|
||||
class="example-btn"
|
||||
>
|
||||
{{ queryExamples[currentExampleIndex] }}
|
||||
</a-button>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="query-results" v-if="queryResult">
|
||||
{{ queryResult }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { useDatabaseStore } from '@/stores/database';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { h } from 'vue';
|
||||
|
||||
const store = useDatabaseStore();
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
style: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
});
|
||||
|
||||
// 添加调试日志
|
||||
console.log('QuerySection props:', props);
|
||||
console.log('QuerySection style prop:', props.style);
|
||||
|
||||
const emit = defineEmits(['toggleVisible']);
|
||||
|
||||
const loading = computed(() => store.state.queryParamsLoading);
|
||||
const searchLoading = computed(() => store.state.searchLoading);
|
||||
const queryParams = computed(() => store.queryParams);
|
||||
const meta = computed({
|
||||
get: () => store.meta,
|
||||
set: (value) => Object.assign(store.meta, value)
|
||||
});
|
||||
const queryResult = computed(() => store.queryResult);
|
||||
|
||||
// 查询测试
|
||||
const queryText = ref('');
|
||||
|
||||
// 添加更多示例查询
|
||||
const queryExamples = ref([
|
||||
'孕妇应该避免吃哪些水果?',
|
||||
'荔枝应该怎么清洗?',
|
||||
'如何判断西瓜是否成熟?',
|
||||
'苹果有哪些营养价值?',
|
||||
'什么季节最适合吃梨?',
|
||||
'如何保存草莓以延长保质期?',
|
||||
'香蕉变黑后还能吃吗?',
|
||||
'橙子皮可以用来做什么?'
|
||||
]);
|
||||
|
||||
// 当前示例索引
|
||||
const currentExampleIndex = ref(0);
|
||||
|
||||
// 示例轮播相关
|
||||
let exampleCarouselInterval = null;
|
||||
|
||||
const toggleVisible = () => {
|
||||
emit('toggleVisible');
|
||||
};
|
||||
|
||||
const onQuery = () => {
|
||||
if (!queryText.value.trim()) {
|
||||
message.error('请输入查询内容');
|
||||
return;
|
||||
}
|
||||
store.onQuery(queryText.value.trim());
|
||||
};
|
||||
|
||||
const useQueryExample = (example) => {
|
||||
queryText.value = example;
|
||||
onQuery();
|
||||
};
|
||||
|
||||
const startExampleCarousel = () => {
|
||||
if (exampleCarouselInterval) return;
|
||||
|
||||
exampleCarouselInterval = setInterval(() => {
|
||||
currentExampleIndex.value = (currentExampleIndex.value + 1) % queryExamples.value.length;
|
||||
}, 6000); // 每6秒切换一次
|
||||
};
|
||||
|
||||
const stopExampleCarousel = () => {
|
||||
if (exampleCarouselInterval) {
|
||||
clearInterval(exampleCarouselInterval);
|
||||
exampleCarouselInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时启动示例轮播
|
||||
onMounted(() => {
|
||||
// 启动示例轮播
|
||||
startExampleCarousel();
|
||||
|
||||
// 加载查询参数
|
||||
store.loadQueryParams();
|
||||
});
|
||||
|
||||
// 组件卸载时停止示例轮播
|
||||
onUnmounted(() => {
|
||||
// 停止示例轮播
|
||||
stopExampleCarousel();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.query-section {
|
||||
.query-content {
|
||||
padding: 8px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.query-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.compact-query-textarea {
|
||||
flex: 1;
|
||||
// background: white;
|
||||
// box-shadow: 0 0px 4px rgba(0, 0, 0, 0.1);
|
||||
// border: none;
|
||||
// outline: none;
|
||||
|
||||
&:focus {
|
||||
// border: none;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.query-actions-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.query-examples-compact {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.examples-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.examples-container {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.example-btn {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
height: auto;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.query-results {
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.query-params-compact {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.params-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.param-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.param-item label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -56,7 +56,7 @@ const getRemoteConfig = () => {
|
||||
}
|
||||
|
||||
const getRemoteDatabase = () => {
|
||||
databaseStore.refreshDatabase()
|
||||
databaseStore.getDatabaseInfo()
|
||||
}
|
||||
|
||||
// Fetch GitHub stars count
|
||||
|
||||
@ -1,18 +1,372 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { databaseApi } from '@/apis/knowledge_api'
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import { databaseApi, documentApi, queryApi } from '@/apis/knowledge_api';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
export const useDatabaseStore = defineStore('database', () => {
|
||||
const db = ref({})
|
||||
function setDatabase(newDatabase) {
|
||||
db.value = newDatabase
|
||||
const router = useRouter();
|
||||
|
||||
// State
|
||||
const database = ref({});
|
||||
const databaseId = ref(null);
|
||||
const selectedFile = ref(null);
|
||||
const queryResult = ref('');
|
||||
const queryParams = ref([]);
|
||||
const meta = reactive({});
|
||||
const graphStats = ref({
|
||||
displayed_nodes: 0,
|
||||
displayed_edges: 0,
|
||||
is_truncated: false,
|
||||
});
|
||||
const selectedRowKeys = ref([]);
|
||||
|
||||
const state = reactive({
|
||||
databaseLoading: false,
|
||||
refrashing: false,
|
||||
searchLoading: false,
|
||||
lock: false,
|
||||
fileDetailModalVisible: false,
|
||||
fileDetailLoading: false,
|
||||
batchDeleting: false,
|
||||
chunkLoading: false,
|
||||
autoRefresh: false,
|
||||
queryParamsLoading: false,
|
||||
isGraphMaximized: false,
|
||||
rightPanelVisible: true,
|
||||
});
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
// Actions
|
||||
async function getDatabaseInfo(id) {
|
||||
const db_id = id || databaseId.value;
|
||||
if (!db_id) return;
|
||||
|
||||
state.lock = true;
|
||||
state.databaseLoading = true;
|
||||
try {
|
||||
const data = await databaseApi.getDatabaseInfo(db_id);
|
||||
database.value = data;
|
||||
await loadQueryParams(db_id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '获取数据库信息失败');
|
||||
} finally {
|
||||
state.lock = false;
|
||||
state.databaseLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDatabase() {
|
||||
const res = await databaseApi.getDatabases()
|
||||
console.log("database", res)
|
||||
setDatabase(res.databases)
|
||||
async function updateDatabaseInfo(formData) {
|
||||
try {
|
||||
state.lock = true;
|
||||
await databaseApi.updateDatabase(databaseId.value, formData);
|
||||
message.success('知识库信息更新成功');
|
||||
await getDatabaseInfo();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '更新失败');
|
||||
} finally {
|
||||
state.lock = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { db, setDatabase, refreshDatabase }
|
||||
})
|
||||
function deleteDatabase() {
|
||||
Modal.confirm({
|
||||
title: '删除数据库',
|
||||
content: '确定要删除该数据库吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
state.lock = true;
|
||||
try {
|
||||
const data = await databaseApi.deleteDatabase(databaseId.value);
|
||||
message.success(data.message || '删除成功');
|
||||
router.push('/database');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '删除失败');
|
||||
} finally {
|
||||
state.lock = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFile(fileId) {
|
||||
state.lock = true;
|
||||
try {
|
||||
await documentApi.deleteDocument(databaseId.value, fileId);
|
||||
await getDatabaseInfo();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '删除失败');
|
||||
throw error;
|
||||
} finally {
|
||||
state.lock = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteFile(fileId) {
|
||||
Modal.confirm({
|
||||
title: '删除文件',
|
||||
content: '确定要删除该文件吗?',
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => deleteFile(fileId),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBatchDelete() {
|
||||
const files = database.value.files || {};
|
||||
const validFileIds = selectedRowKeys.value.filter(fileId => {
|
||||
const file = files[fileId];
|
||||
return file && !(file.status === 'processing' || file.status === 'waiting');
|
||||
});
|
||||
|
||||
if (validFileIds.length === 0) {
|
||||
message.info('没有可删除的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: '批量删除文件',
|
||||
content: `确定要删除选中的 ${validFileIds.length} 个文件吗?`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
state.batchDeleting = true;
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
let progressMessage = message.loading(`正在删除文件 0/${validFileIds.length}`, 0);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < validFileIds.length; i++) {
|
||||
const fileId = validFileIds[i];
|
||||
try {
|
||||
await deleteFile(fileId);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`删除文件 ${fileId} 失败:`, error);
|
||||
failureCount++;
|
||||
}
|
||||
progressMessage?.();
|
||||
if (i + 1 < validFileIds.length) {
|
||||
progressMessage = message.loading(`正在删除文件 ${i + 1}/${validFileIds.length}`, 0);
|
||||
}
|
||||
}
|
||||
progressMessage?.();
|
||||
if (successCount > 0 && failureCount === 0) {
|
||||
message.success(`成功删除 ${successCount} 个文件`);
|
||||
} else if (successCount > 0 && failureCount > 0) {
|
||||
message.warning(`成功删除 ${successCount} 个文件,${failureCount} 个文件删除失败`);
|
||||
} else if (failureCount > 0) {
|
||||
message.error(`${failureCount} 个文件删除失败`);
|
||||
}
|
||||
selectedRowKeys.value = [];
|
||||
await getDatabaseInfo();
|
||||
} catch (error) {
|
||||
progressMessage?.();
|
||||
console.error('批量删除出错:', error);
|
||||
message.error('批量删除过程中发生错误');
|
||||
} finally {
|
||||
state.batchDeleting = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function addFiles({ items, contentType, params }) {
|
||||
if (items.length === 0) {
|
||||
message.error(contentType === 'file' ? '请先上传文件' : '请输入有效的网页链接');
|
||||
return;
|
||||
}
|
||||
|
||||
state.chunkLoading = true;
|
||||
try {
|
||||
const data = await documentApi.addDocuments(databaseId.value, items, { ...params, content_type: contentType });
|
||||
if (data.status === 'success') {
|
||||
const itemType = contentType === 'file' ? '文件' : 'URL';
|
||||
message.success(data.message || `${itemType}已提交处理,请稍后在列表刷新查看状态`);
|
||||
await getDatabaseInfo();
|
||||
return true; // Indicate success
|
||||
} else {
|
||||
message.error(data.message || '处理失败');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message || '处理请求失败');
|
||||
return false;
|
||||
} finally {
|
||||
state.chunkLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openFileDetail(record) {
|
||||
if (record.status !== 'done') {
|
||||
message.error('文件未处理完成,请稍后再试');
|
||||
return;
|
||||
}
|
||||
state.fileDetailModalVisible = true;
|
||||
selectedFile.value = { ...record, lines: [] };
|
||||
state.fileDetailLoading = true;
|
||||
state.lock = true;
|
||||
|
||||
try {
|
||||
const data = await documentApi.getDocumentInfo(databaseId.value, record.file_id);
|
||||
if (data.status == "failed") {
|
||||
message.error(data.message);
|
||||
state.fileDetailModalVisible = false;
|
||||
return;
|
||||
}
|
||||
selectedFile.value = { ...record, lines: data.lines || [] };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message);
|
||||
state.fileDetailModalVisible = false;
|
||||
} finally {
|
||||
state.fileDetailLoading = false;
|
||||
state.lock = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueryParams(id) {
|
||||
const db_id = id || databaseId.value;
|
||||
if (!db_id) return;
|
||||
|
||||
state.queryParamsLoading = true;
|
||||
try {
|
||||
const response = await queryApi.getKnowledgeBaseQueryParams(db_id);
|
||||
queryParams.value = response.params?.options || [];
|
||||
|
||||
// Create a set of currently supported parameter keys
|
||||
const supportedParamKeys = new Set(queryParams.value.map(param => param.key));
|
||||
|
||||
// Remove unsupported parameters from meta
|
||||
for (const key in meta) {
|
||||
if (key !== 'db_id' && !supportedParamKeys.has(key)) {
|
||||
delete meta[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Add default values for supported parameters that are not in meta
|
||||
queryParams.value.forEach(param => {
|
||||
if (!(param.key in meta)) {
|
||||
meta[param.key] = param.default;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load query params:', error);
|
||||
message.error('加载查询参数失败');
|
||||
} finally {
|
||||
state.queryParamsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onQuery(queryText) {
|
||||
state.searchLoading = true;
|
||||
queryResult.value = '';
|
||||
|
||||
// 确保只传递当前知识库类型支持的参数
|
||||
const supportedParamKeys = new Set(queryParams.value.map(param => param.key));
|
||||
const queryMeta = {};
|
||||
|
||||
console.log('Supported param keys:', Array.from(supportedParamKeys));
|
||||
console.log('All meta params:', meta);
|
||||
console.log('Database info:', database.value);
|
||||
|
||||
// 遍历 meta 中的参数,只保留当前知识库类型支持的参数
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
// 跳过 db_id 参数
|
||||
if (key === 'db_id') continue;
|
||||
|
||||
// 只保留当前知识库类型支持的参数
|
||||
if (supportedParamKeys.has(key)) {
|
||||
queryMeta[key] = value;
|
||||
} else {
|
||||
console.log(`Skipping unsupported parameter: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Filtered query meta:', queryMeta);
|
||||
|
||||
try {
|
||||
const data = await queryApi.queryTest(database.value.db_id, queryText, queryMeta);
|
||||
queryResult.value = data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(error.message);
|
||||
} finally {
|
||||
state.searchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (state.autoRefresh && !refreshInterval) {
|
||||
refreshInterval = setInterval(() => {
|
||||
getDatabaseInfo();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
state.autoRefresh = !state.autoRefresh;
|
||||
if (state.autoRefresh) {
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
stopAutoRefresh();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllFailedFiles() {
|
||||
const files = Object.values(database.value.files || {});
|
||||
const failedFiles = files
|
||||
.filter(file => file.status === 'failed')
|
||||
.map(file => file.file_id);
|
||||
|
||||
const newSelectedKeys = [...new Set([...selectedRowKeys.value, ...failedFiles])];
|
||||
selectedRowKeys.value = newSelectedKeys;
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
message.success(`已选择 ${failedFiles.length} 个失败的文件`);
|
||||
} else {
|
||||
message.info('当前没有失败的文件');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
database,
|
||||
databaseId,
|
||||
selectedFile,
|
||||
queryResult,
|
||||
queryParams,
|
||||
meta,
|
||||
graphStats,
|
||||
selectedRowKeys,
|
||||
state,
|
||||
getDatabaseInfo,
|
||||
updateDatabaseInfo,
|
||||
deleteDatabase,
|
||||
deleteFile,
|
||||
handleDeleteFile,
|
||||
handleBatchDelete,
|
||||
addFiles,
|
||||
openFileDetail,
|
||||
loadQueryParams,
|
||||
onQuery,
|
||||
startAutoRefresh,
|
||||
stopAutoRefresh,
|
||||
toggleAutoRefresh,
|
||||
selectAllFailedFiles,
|
||||
};
|
||||
});
|
||||
|
||||
141
web/src/utils/file_utils.js
Normal file
141
web/src/utils/file_utils.js
Normal file
@ -0,0 +1,141 @@
|
||||
// 文件相关工具函数
|
||||
import { FileTextFilled, FileMarkdownFilled, FilePdfFilled, FileWordFilled, FileExcelFilled, FileImageFilled, FileUnknownFilled } from '@ant-design/icons-vue';
|
||||
|
||||
// 根据文件扩展名获取文件图标
|
||||
export const getFileIcon = (filename) => {
|
||||
if (!filename) return FileUnknownFilled
|
||||
|
||||
const extension = filename.toLowerCase().split('.').pop()
|
||||
|
||||
const iconMap = {
|
||||
// 文本文件
|
||||
'txt': FileTextFilled,
|
||||
'text': FileTextFilled,
|
||||
'log': FileTextFilled,
|
||||
|
||||
// Markdown文件
|
||||
'md': FileMarkdownFilled,
|
||||
'markdown': FileMarkdownFilled,
|
||||
|
||||
// PDF文件
|
||||
'pdf': FilePdfFilled,
|
||||
|
||||
// Word文档
|
||||
'doc': FileWordFilled,
|
||||
'docx': FileWordFilled,
|
||||
|
||||
// Excel文档
|
||||
'xls': FileExcelFilled,
|
||||
'xlsx': FileExcelFilled,
|
||||
'csv': FileExcelFilled,
|
||||
|
||||
// 图片文件
|
||||
'jpg': FileImageFilled,
|
||||
'jpeg': FileImageFilled,
|
||||
'png': FileImageFilled,
|
||||
'gif': FileImageFilled,
|
||||
'bmp': FileImageFilled,
|
||||
'svg': FileImageFilled,
|
||||
'webp': FileImageFilled,
|
||||
}
|
||||
|
||||
return iconMap[extension] || FileUnknownFilled
|
||||
}
|
||||
|
||||
// 根据文件扩展名获取文件图标颜色
|
||||
export const getFileIconColor = (filename) => {
|
||||
if (!filename) return '#8c8c8c'
|
||||
|
||||
const extension = filename.toLowerCase().split('.').pop()
|
||||
|
||||
const colorMap = {
|
||||
// 文本文件 - 蓝色
|
||||
'txt': '#1890ff',
|
||||
'text': '#1890ff',
|
||||
'log': '#1890ff',
|
||||
|
||||
// Markdown文件 - 深蓝色
|
||||
'md': '#0050b3',
|
||||
'markdown': '#0050b3',
|
||||
|
||||
// PDF文件 - 红色
|
||||
'pdf': '#ff4d4f',
|
||||
|
||||
// Word文档 - 深蓝色
|
||||
'doc': '#2f54eb',
|
||||
'docx': '#2f54eb',
|
||||
|
||||
// Excel文档 - 绿色
|
||||
'xls': '#52c41a',
|
||||
'xlsx': '#52c41a',
|
||||
'csv': '#52c41a',
|
||||
|
||||
// 图片文件 - 紫色
|
||||
'jpg': '#722ed1',
|
||||
'jpeg': '#722ed1',
|
||||
'png': '#722ed1',
|
||||
'gif': '#722ed1',
|
||||
'bmp': '#722ed1',
|
||||
'svg': '#722ed1',
|
||||
'webp': '#722ed1',
|
||||
}
|
||||
|
||||
return colorMap[extension] || '#8c8c8c'
|
||||
}
|
||||
|
||||
// Format relative time with more granularity: days ago, weeks ago, months ago
|
||||
export const formatRelativeTime = (timestamp, offset = 0) => {
|
||||
// If you want to adjust to UTC+8, set offset to 8, otherwise 0
|
||||
const timezoneOffset = offset * 60 * 60 * 1000; // offset in milliseconds
|
||||
const adjustedTimestamp = timestamp + timezoneOffset;
|
||||
|
||||
const now = Date.now();
|
||||
const secondsPast = (now - adjustedTimestamp) / 1000;
|
||||
|
||||
if (secondsPast < 60) {
|
||||
return Math.round(secondsPast) + ' 秒前';
|
||||
} else if (secondsPast < 3600) {
|
||||
return Math.round(secondsPast / 60) + ' 分钟前';
|
||||
} else if (secondsPast < 86400) {
|
||||
return Math.round(secondsPast / 3600) + ' 小时前';
|
||||
} else if (secondsPast < 86400 * 7) {
|
||||
// Less than 7 days
|
||||
return Math.round(secondsPast / 86400) + ' 天前';
|
||||
} else if (secondsPast < 86400 * 30) {
|
||||
// Less than 30 days, show in weeks
|
||||
return Math.round(secondsPast / (86400 * 7)) + ' 周前';
|
||||
} else if (secondsPast < 86400 * 365) {
|
||||
// Less than 1 year, show in months
|
||||
return Math.round(secondsPast / (86400 * 30)) + ' 月前';
|
||||
} else {
|
||||
// More than 1 year, show full date
|
||||
const date = new Date(adjustedTimestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year} 年 ${month} 月 ${day} 日`;
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化标准时间
|
||||
export const formatStandardTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
const second = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}年${month}月${day}日 ${hour}:${minute}:${second}`;
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
export const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
'done': '处理完成',
|
||||
'failed': '处理失败',
|
||||
'processing': '处理中',
|
||||
'waiting': '等待处理'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
30
web/src/utils/kb_utils.js
Normal file
30
web/src/utils/kb_utils.js
Normal file
@ -0,0 +1,30 @@
|
||||
|
||||
import { Database, Zap } from 'lucide-vue-next';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
export const getKbTypeLabel = (type) => {
|
||||
const labels = {
|
||||
lightrag: 'LightRAG',
|
||||
chroma: 'Chroma',
|
||||
milvus: 'Milvus'
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
export const getKbTypeIcon = (type) => {
|
||||
const icons = {
|
||||
lightrag: Database,
|
||||
chroma: Zap,
|
||||
milvus: ThunderboltOutlined
|
||||
};
|
||||
return icons[type] || Database;
|
||||
};
|
||||
|
||||
export const getKbTypeColor = (type) => {
|
||||
const colors = {
|
||||
lightrag: 'purple',
|
||||
chroma: 'orange',
|
||||
milvus: 'red'
|
||||
};
|
||||
return colors[type] || 'blue';
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -72,7 +72,15 @@
|
||||
<a-button key="submit" type="primary" :loading="state.creating" @click="createDatabase">创建</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
<div class="databases">
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="state.loading" class="loading-container">
|
||||
<a-spin size="large" />
|
||||
<p>正在加载知识库...</p>
|
||||
</div>
|
||||
|
||||
<!-- 数据库列表 -->
|
||||
<div v-else class="databases">
|
||||
<div class="new-database dbcard" @click="state.openNewDatabaseModel=true">
|
||||
<div class="top">
|
||||
<div class="icon"><BookPlus /></div>
|
||||
@ -682,6 +690,15 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 300px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.new-database-modal {
|
||||
h3 {
|
||||
margin-top: 10px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user