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:
Wenjie Zhang 2025-08-05 18:00:45 +08:00
parent 80acc7ad8f
commit 40c2922132
15 changed files with 2648 additions and 2765 deletions

39
docs/changelog/update.md Normal file
View File

@ -0,0 +1,39 @@
目前已有的开发计划包括:
### v2.0 Prerelease
💭 **Features Todo**
- [x] 知识库使用 LightRAGGraphRAG 轻量版)重构(🌟🌟🌟🌟🌟)
- [x] 集成 MinerU 处理 PDF 文件。(🌟🌟🌟)
- [x] 优化现有向量模型的逻辑,全局配置的向量模型作为新建知识库的默认模型,但是查询的时候,使用对应数据库的向量模型查询(单例模式)(🌟🌟🌟)
- [x] 多类型知识库支持(🌟🌟🌟🌟🌟)
- [x] 设计问答知识库,支持针对问答对的调优
- [x] 添加了批量上传文件的脚本,并优化文件信息
🐛**BUGs**
- [x] 智能体的工具切换有问题
- [ ] 知识图谱可视化页面的效果有点问题
- [x] 处理失败的文件,在特定情况下会一直处于 processing 状态,且无法删除
- [ ] 与最新的 LightRAG 版本兼容性存在问题 #233
# 💯 More:
下面的功能会放在后续版本实现,暂时未定
- [ ] 封装现有工具为 mcpstdio调用
- [ ] 支持额外 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。

View File

@ -1,28 +0,0 @@
目前已有的开发计划包括:
### v2.0 Prerelease
💭 **Features Todo**
- [x] 知识库使用 LightRAGGraphRAG 轻量版)重构(🌟🌟🌟🌟🌟)
- [x] 集成 MinerU 处理 PDF 文件。(🌟🌟🌟)
- [x] 优化现有向量模型的逻辑,全局配置的向量模型作为新建知识库的默认模型,但是查询的时候,使用对应数据库的向量模型查询(单例模式)(🌟🌟🌟)
- [x] 多类型知识库支持(🌟🌟🌟🌟🌟)
- [x] 设计问答知识库,支持针对问答对的调优
- [x] 添加了批量上传文件的脚本,并优化文件信息
🐛**BUGs**
- [x] 智能体的工具切换有问题
- [ ] 知识图谱可视化页面的效果有点问题
- [ ] 处理失败的文件,在特定情况下会一直处于 processing 状态,且无法删除
# 💯 More:
下面的功能会放在后续版本实现,暂时未定
- [ ] 封装现有工具为 mcpstdio调用
- [ ] 支持额外 mcp 配置(代码端)
- [ ] 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息(🌟🌟)
- [ ] 对话页面支持文档/图片临时上传(🌟🌟🌟🌟)
- [ ] 在Prompt中添加对于参考文献的要求同时要求使用链接的形式在聊天页面点击该链接后可以选择跳转或者弹出信息预览框最好可以提供源文件下载的功能这里需要将DatabaseInfoView 里面的那个文件详情的弹窗给组件化。但是同时也要能够配置选择显示源文件还是chunk的结果如果可以的话最好是在一个文件里面显示所有的chunk而不是每个chunk都单独显示。不过似乎有点难度毕竟是有 overlap 的存在还是分开吧。对于URL链接的这种可以选择直接跳转就可以。
- [ ] 各种结果的可视化:知识库检索页面,工具调用检索页面,知识图谱检索页面

View 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>

View 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>

View 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>

View 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;
// QAQA
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>

View 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>

View File

@ -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

View 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>

View File

@ -56,7 +56,7 @@ const getRemoteConfig = () => {
}
const getRemoteDatabase = () => {
databaseStore.refreshDatabase()
databaseStore.getDatabaseInfo()
}
// Fetch GitHub stars count

View File

@ -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
View 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
View 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

View File

@ -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;