Files
common_agent/frontend/src/pages/rag/RagStoresPage.vue

901 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { CirclePlus, Delete, Edit, FolderAdd, Refresh, Search, UploadFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { loadModelProviderEnumOptions, type EnumOption } from '@/api/modelEnums';
import {
getRagStoreModelConfig,
queryModelConfigs,
rebuildRagStoreIndex,
saveRagStoreModelConfig,
type ModelConfig,
} from '@/api/modelProvider';
import {
deleteRagStore,
getRagStoreById,
getRagStoreDocumentOverview,
getRagStoreOverview,
queryRagStores,
saveRagStore,
type RagStoreDocumentOverview,
type RagStoreOverview,
type RagStore,
} from '@/api/ragStores';
import RagDocumentBatchUploadDialog from '@/components/rag/RagDocumentBatchUploadDialog.vue';
type StoreStatus = '启用' | '停用';
const router = useRouter();
const loading = ref(false);
const detailLoading = ref(false);
const submitting = ref(false);
const storeRows = ref<RagStore[]>([]);
const activeStoreId = ref<string | null>(null);
const activeStore = ref<RagStore | null>(null);
const pageOverview = ref<RagStoreOverview | null>(null);
const activeStoreDocumentOverview = ref<RagStoreDocumentOverview | null>(null);
const embeddingModels = ref<ModelConfig[]>([]);
const ragConfigLoading = ref(false);
const ragConfigSaving = ref(false);
const ragConfig = reactive({
id: '',
embeddingModelId: '',
embeddingDimension: 1024,
chunkStrategy: null as number | null,
chunkSize: null as number | null,
chunkOverlap: null as number | null,
delimiter: '',
remark: '',
});
const chunkStrategyOptions = ref<EnumOption[]>([]);
const queryForm = reactive({
storeName: '',
});
const createDialogVisible = ref(false);
const editDialogVisible = ref(false);
const uploadDialogVisible = ref(false);
const createForm = reactive({
storeCode: '',
storeName: '',
description: '',
status: '启用' as StoreStatus,
remark: '',
});
const editForm = reactive({
id: '',
storeCode: '',
storeName: '',
description: '',
status: '启用' as StoreStatus,
remark: '',
});
const overviewCards = computed(() => {
const totalStores = pageOverview.value?.totalStores ?? storeRows.value.length;
const totalDocuments = pageOverview.value?.totalDocuments ?? '-';
const totalChunks = pageOverview.value?.totalChunks ?? '-';
const retrievableStores =
pageOverview.value?.retrievableStores
?? storeRows.value.filter((row) => row.status === '启用').length;
return [
{ label: '知识库总数', value: totalStores, hint: '当前已登记知识库' },
{ label: '文档总数', value: totalDocuments, hint: '当前知识库已登记文档总量' },
{ label: '切片总数', value: totalChunks, hint: '当前未接入切片表,待后续能力补充' },
{ label: '可检索知识库数', value: retrievableStores, hint: '当前按启用状态统计' },
];
});
async function loadOverview() {
try {
const response = await getRagStoreOverview();
pageOverview.value = response.data ?? null;
} catch {
pageOverview.value = null;
}
}
async function loadStores(preferredStoreId?: string | null) {
loading.value = true;
try {
const response = await queryRagStores({
storeName: queryForm.storeName.trim() || undefined,
});
storeRows.value = response.data ?? [];
if (storeRows.value.length === 0) {
activeStoreId.value = null;
activeStore.value = null;
activeStoreDocumentOverview.value = null;
ragConfig.id = '';
ragConfig.embeddingModelId = '';
return;
}
const firstStore = storeRows.value[0];
const targetId =
preferredStoreId && storeRows.value.some((row) => String(row.id) === preferredStoreId)
? preferredStoreId
: firstStore
? String(firstStore.id)
: null;
if (!targetId) {
activeStoreId.value = null;
activeStore.value = null;
activeStoreDocumentOverview.value = null;
return;
}
await selectStore(targetId);
} finally {
loading.value = false;
}
}
async function selectStore(storeId: string) {
activeStoreId.value = storeId;
detailLoading.value = true;
try {
const [storeResponse, documentOverviewResponse] = await Promise.all([
getRagStoreById(storeId),
getRagStoreDocumentOverview(storeId),
]);
activeStore.value = storeResponse.data ?? null;
activeStoreDocumentOverview.value = documentOverviewResponse.data ?? null;
await loadRagModelConfig(storeId);
} finally {
detailLoading.value = false;
}
}
function handleSearch() {
loadStores(activeStoreId.value);
}
function handleReset() {
queryForm.storeName = '';
loadStores();
}
function openCreateDialog() {
createForm.storeCode = '';
createForm.storeName = '';
createForm.description = '';
createForm.status = '启用';
createForm.remark = '';
createDialogVisible.value = true;
}
function openEditDialog() {
if (!activeStore.value) {
return;
}
editForm.id = String(activeStore.value.id ?? '');
editForm.storeCode = activeStore.value.storeCode;
editForm.storeName = activeStore.value.storeName;
editForm.description = activeStore.value.description ?? '';
editForm.status = (activeStore.value.status as StoreStatus) || '启用';
editForm.remark = activeStore.value.remark ?? '';
editDialogVisible.value = true;
}
async function submitCreateStore() {
if (!createForm.storeCode || !createForm.storeName) {
ElMessage.warning('请填写知识库编码和知识库名称');
return;
}
submitting.value = true;
try {
await saveRagStore({
storeCode: createForm.storeCode,
storeName: createForm.storeName,
description: createForm.description,
status: createForm.status,
remark: createForm.remark,
});
createDialogVisible.value = false;
ElMessage.success('知识库已创建');
await loadStores();
} finally {
submitting.value = false;
}
}
async function submitEditStore() {
if (!editForm.id || !editForm.storeCode || !editForm.storeName) {
ElMessage.warning('请填写知识库编码和知识库名称');
return;
}
submitting.value = true;
try {
await saveRagStore({
id: editForm.id,
storeCode: editForm.storeCode,
storeName: editForm.storeName,
description: editForm.description,
status: editForm.status,
remark: editForm.remark,
});
editDialogVisible.value = false;
ElMessage.success('知识库信息已更新');
await loadStores(editForm.id);
} finally {
submitting.value = false;
}
}
async function removeStore() {
if (!activeStore.value?.id) {
return;
}
await ElMessageBox.confirm(`确认删除知识库「${activeStore.value.storeName}」?`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
});
await deleteRagStore(String(activeStore.value.id));
ElMessage.success('知识库已删除');
await loadStores();
}
function openBatchUploadDialog() {
if (!activeStore.value?.id) {
ElMessage.warning('请选择知识库');
return;
}
uploadDialogVisible.value = true;
}
function viewActiveStoreDocuments() {
if (!activeStore.value?.id) {
return;
}
router.push({
name: 'rag-documents',
query: { storeId: String(activeStore.value.id) },
});
}
async function refreshAfterUpload() {
await Promise.all([
loadOverview(),
activeStoreId.value ? selectStore(activeStoreId.value) : Promise.resolve(),
]);
}
async function loadRagModelConfig(storeId: string) {
ragConfigLoading.value = true;
try {
const response = await getRagStoreModelConfig(storeId);
const data = response.data;
ragConfig.id = data?.id ?? '';
ragConfig.embeddingModelId = data?.embeddingModelId ?? '';
ragConfig.embeddingDimension = data?.embeddingDimension ?? 1024;
ragConfig.chunkStrategy = data?.chunkStrategy ?? null;
ragConfig.chunkSize = data?.chunkSize ?? null;
ragConfig.chunkOverlap = data?.chunkOverlap ?? null;
ragConfig.delimiter = data?.delimiter ?? '';
ragConfig.remark = data?.remark ?? '';
} finally {
ragConfigLoading.value = false;
}
}
async function saveStoreModelConfig() {
if (!activeStoreId.value) {
return;
}
if (!ragConfig.embeddingModelId) {
ElMessage.warning('请选择 Embedding 模型');
return;
}
ragConfigSaving.value = true;
try {
await saveRagStoreModelConfig({
id: ragConfig.id || undefined,
storeId: activeStoreId.value,
embeddingModelId: ragConfig.embeddingModelId,
embeddingDimension: ragConfig.embeddingDimension,
chunkStrategy: ragConfig.chunkStrategy ?? undefined,
chunkSize: ragConfig.chunkSize ?? undefined,
chunkOverlap: ragConfig.chunkOverlap ?? undefined,
delimiter: ragConfig.delimiter || undefined,
remark: ragConfig.remark || undefined,
});
ElMessage.success('知识库模型配置已保存');
await loadRagModelConfig(activeStoreId.value);
} finally {
ragConfigSaving.value = false;
}
}
async function triggerRebuildIndex() {
if (!activeStoreId.value) {
return;
}
await rebuildRagStoreIndex(activeStoreId.value);
ElMessage.success('已触发重建索引');
}
function syncEmbeddingDimensionFromModel() {
const model = embeddingModels.value.find((item) => item.id === ragConfig.embeddingModelId);
if (!model) {
return;
}
if (model.embeddingDimension) {
ragConfig.embeddingDimension = model.embeddingDimension;
}
}
function getStatusTagType(status?: string | null) {
return status === '启用' ? 'success' : 'info';
}
onMounted(() => {
Promise.all([
loadOverview(),
loadStores(),
queryModelConfigs().then((response) => {
embeddingModels.value = (response.data ?? []).filter((item) => item.modelType === 'EMBEDDING');
}),
loadModelProviderEnumOptions().then((enums) => {
chunkStrategyOptions.value = enums.chunk_strategy ?? [];
}),
]);
});
</script>
<template>
<section class="page-panel rag-store-page">
<div class="page-panel__header rag-store-page__header">
<div>
<h2>知识库</h2>
<p>统一管理知识库及其文档索引状态</p>
</div>
<span>RAG Stores</span>
</div>
<div class="overview-grid">
<article v-for="card in overviewCards" :key="card.label" class="overview-card">
<span class="overview-card__label">{{ card.label }}</span>
<strong class="overview-card__value">{{ card.value }}</strong>
<small class="overview-card__hint">{{ card.hint }}</small>
</article>
</div>
<div class="rag-store-page__content">
<section class="store-list-panel">
<div class="section-heading">
<div>
<h3>知识库列表</h3>
<p>按知识库名称检索并切换当前查看对象</p>
</div>
<el-button data-test="create-store" type="primary" :icon="CirclePlus" @click="openCreateDialog">
新建知识库
</el-button>
</div>
<div class="store-search-bar">
<el-input
v-model="queryForm.storeName"
data-test="store-name-input"
clearable
placeholder="请输入知识库名称"
@keyup.enter="handleSearch"
/>
<el-button data-test="store-search" type="primary" :icon="Search" @click="handleSearch">
查询
</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
</div>
<div v-loading="loading" class="store-list">
<button
v-for="store in storeRows"
:key="store.id"
:data-test="`store-card-${String(store.storeCode).toLowerCase()}`"
class="store-card"
:class="{ 'store-card--active': String(store.id) === activeStoreId }"
type="button"
@click="selectStore(String(store.id))"
>
<div class="store-card__title-row">
<strong>{{ store.storeName }}</strong>
<el-tag size="small" :type="getStatusTagType(store.status)">{{ store.status || '未设置' }}</el-tag>
</div>
<p>编码{{ store.storeCode }}</p>
<div class="store-card__metrics">
<span>描述{{ store.description || '暂无描述' }}</span>
</div>
<div class="store-card__metrics">
<span>更新时间{{ store.updateTime || '-' }}</span>
</div>
</button>
<el-empty v-if="!loading && storeRows.length === 0" description="未找到匹配的知识库" />
</div>
</section>
<section class="store-detail-panel">
<div v-loading="detailLoading" class="store-detail-panel__body">
<template v-if="activeStore">
<div class="section-heading section-heading--detail">
<div>
<h3>{{ activeStore.storeName }}</h3>
<p>编码{{ activeStore.storeCode }}</p>
</div>
<div class="detail-actions">
<el-button :icon="Edit" @click="openEditDialog">编辑</el-button>
<el-button
data-test="store-batch-upload"
type="primary"
:icon="UploadFilled"
@click="openBatchUploadDialog"
>
批量导入文件
</el-button>
<el-button :icon="FolderAdd" @click="triggerRebuildIndex">重建索引</el-button>
<el-button type="danger" :icon="Delete" @click="removeStore">删除</el-button>
</div>
</div>
<div class="detail-grid">
<article class="detail-card">
<div class="detail-card__header">
<h4>基本信息</h4>
<el-tag :type="getStatusTagType(activeStore.status)">{{ activeStore.status || '未设置' }}</el-tag>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="知识库名称">
{{ activeStore.storeName }}
</el-descriptions-item>
<el-descriptions-item label="知识库编码">
{{ activeStore.storeCode }}
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ activeStore.description || '暂无描述' }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ activeStore.remark || '暂无备注' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ activeStore.createTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ activeStore.updateTime || '-' }}
</el-descriptions-item>
</el-descriptions>
</article>
<article class="detail-card detail-card--placeholder">
<div class="detail-card__header">
<h4>文档概览</h4>
<el-button
data-test="view-store-documents"
link
type="primary"
@click="viewActiveStoreDocuments"
>
查看文档
</el-button>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="文档总数">
{{ activeStoreDocumentOverview?.documentCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="启用文档数">
{{ activeStoreDocumentOverview?.enabledDocumentCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="已解析文档数">
{{ activeStoreDocumentOverview?.parsedDocumentCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="已索引文档数">
{{ activeStoreDocumentOverview?.indexedDocumentCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="最近上传时间" :span="2">
{{ activeStoreDocumentOverview?.lastUploadTime || '-' }}
</el-descriptions-item>
</el-descriptions>
</article>
<article class="detail-card">
<div class="detail-card__header">
<h4>检索配置</h4>
<el-button type="primary" link :loading="ragConfigSaving" @click="saveStoreModelConfig">
保存配置
</el-button>
</div>
<div v-loading="ragConfigLoading" class="rag-config-form">
<el-form :model="ragConfig" label-width="112px">
<el-form-item label="Embedding 模型" required>
<el-select
v-model="ragConfig.embeddingModelId"
placeholder="请选择模型"
@change="syncEmbeddingDimensionFromModel"
>
<el-option
v-for="item in embeddingModels"
:key="item.id"
:label="`${item.modelName}(${item.modelCode})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="向量维度">
<el-input-number v-model="ragConfig.embeddingDimension" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="切片策略">
<el-select v-model="ragConfig.chunkStrategy" clearable placeholder="可选">
<el-option
v-for="item in chunkStrategyOptions"
:key="item.value"
:label="item.label"
:value="Number(item.value)"
/>
</el-select>
</el-form-item>
<el-form-item label="切片大小">
<el-input-number v-model="ragConfig.chunkSize" :min="1" controls-position="right" />
</el-form-item>
<el-form-item label="切片重叠">
<el-input-number v-model="ragConfig.chunkOverlap" :min="0" controls-position="right" />
</el-form-item>
<el-form-item label="分隔符">
<el-input v-model="ragConfig.delimiter" placeholder="可选,如 \\n\\n" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="ragConfig.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
</div>
</article>
<article class="detail-card detail-card--placeholder">
<div class="detail-card__header">
<h4>最近任务</h4>
<span>下一批接口补充</span>
</div>
<el-empty description="导入任务、索引任务与状态流转待后端补充" />
</article>
</div>
</template>
<el-empty v-else description="请选择左侧一个知识库查看详情" />
</div>
</section>
</div>
<el-dialog v-model="createDialogVisible" title="新建知识库" width="560px">
<el-form :model="createForm" label-width="96px">
<el-form-item label="知识库编码" required>
<el-input v-model="createForm.storeCode" data-test="create-store-code" placeholder="如 PROD_DOC" />
</el-form-item>
<el-form-item label="知识库名称" required>
<el-input
v-model="createForm.storeName"
data-test="create-store-name"
placeholder="请输入知识库名称"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="createForm.status">
<el-radio-button label="启用" />
<el-radio-button label="停用" />
</el-radio-group>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="createForm.description" type="textarea" :rows="3" placeholder="请输入知识库描述" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createForm.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button data-test="create-store-submit" type="primary" :loading="submitting" @click="submitCreateStore">
保存
</el-button>
</template>
</el-dialog>
<el-dialog v-model="editDialogVisible" title="编辑知识库" width="560px">
<el-form :model="editForm" label-width="96px">
<el-form-item label="知识库编码" required>
<el-input v-model="editForm.storeCode" />
</el-form-item>
<el-form-item label="知识库名称" required>
<el-input v-model="editForm.storeName" />
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="editForm.status">
<el-radio-button label="启用" />
<el-radio-button label="停用" />
</el-radio-group>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="editForm.description" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitEditStore">保存</el-button>
</template>
</el-dialog>
<RagDocumentBatchUploadDialog
v-model="uploadDialogVisible"
:stores="activeStore ? [activeStore] : storeRows"
:locked-store-id="activeStoreId"
@uploaded="refreshAfterUpload"
/>
</section>
</template>
<style scoped>
.rag-store-page {
display: flex;
flex-direction: column;
}
.rag-store-page__header {
align-items: flex-start;
}
.rag-store-page__header p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
padding: 20px 22px 0;
}
.overview-card {
border: 1px solid #e6ebf3;
border-radius: 12px;
padding: 18px 18px 16px;
background: linear-gradient(180deg, #ffffff, #f9fbff);
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04);
}
.overview-card__label {
display: block;
color: #667085;
font-size: 13px;
font-weight: 600;
}
.overview-card__value {
display: block;
margin-top: 8px;
color: #172033;
font-size: 28px;
font-weight: 700;
}
.overview-card__hint {
display: block;
margin-top: 8px;
color: #98a2b3;
font-size: 12px;
}
.rag-store-page__content {
display: grid;
grid-template-columns: minmax(320px, 0.95fr) minmax(0, 1.45fr);
gap: 18px;
padding: 18px 22px 22px;
min-height: 0;
flex: 1;
}
.store-list-panel,
.store-detail-panel {
border: 1px solid #e6ebf3;
border-radius: 12px;
background: #fcfdff;
overflow: hidden;
}
.store-detail-panel__body {
min-height: 100%;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 18px 14px;
border-bottom: 1px solid #eef2f7;
background: linear-gradient(180deg, #ffffff, #fbfcff);
}
.section-heading h3 {
margin: 0;
color: #172033;
font-size: 17px;
}
.section-heading p {
margin: 6px 0 0;
color: #667085;
font-size: 13px;
}
.store-search-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 10px;
padding: 16px 18px;
border-bottom: 1px solid #eef2f7;
background: #ffffff;
}
.store-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
max-height: 780px;
overflow: auto;
}
.store-card {
border: 1px solid #e3e8f0;
border-radius: 12px;
padding: 16px;
text-align: left;
background: #ffffff;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.store-card:hover {
border-color: #bfdbfe;
box-shadow: 0 8px 22px rgba(22, 119, 255, 0.08);
transform: translateY(-1px);
}
.store-card--active {
border-color: #1677ff;
box-shadow: 0 10px 24px rgba(22, 119, 255, 0.12);
background: #f7fbff;
}
.store-card__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.store-card strong {
color: #172033;
font-size: 16px;
}
.store-card p {
margin: 10px 0 0;
color: #475467;
font-size: 13px;
}
.store-card__metrics {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
color: #667085;
font-size: 13px;
flex-wrap: wrap;
}
.section-heading--detail {
align-items: flex-start;
}
.detail-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-grid {
display: grid;
gap: 16px;
padding: 18px;
}
.detail-card {
border: 1px solid #e7edf5;
border-radius: 12px;
background: #ffffff;
padding: 16px;
}
.detail-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.detail-card__header h4 {
margin: 0;
color: #172033;
font-size: 15px;
}
.detail-card__header span {
color: #667085;
font-size: 12px;
}
.detail-card--placeholder :deep(.el-empty) {
padding: 12px 0;
}
.rag-config-form :deep(.el-select),
.rag-config-form :deep(.el-input-number) {
width: 100%;
}
@media (max-width: 1280px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.rag-store-page__content {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.overview-grid {
grid-template-columns: 1fr;
padding: 16px 16px 0;
}
.rag-store-page__content {
padding: 16px;
}
.section-heading,
.section-heading--detail {
flex-direction: column;
align-items: stretch;
}
.store-search-bar {
grid-template-columns: 1fr;
}
.detail-actions {
justify-content: flex-start;
}
}
</style>