feat: 增强知识库索引与设置页面模块化拆分

扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 23:47:28 +08:00
parent 88ff04bef8
commit 5b388d08c0
84 changed files with 10170 additions and 2599 deletions

View File

@@ -2,7 +2,8 @@
<aside class="rail" aria-label="主导航">
<div class="rail-brand">
<div class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 36 36">
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
<svg v-else viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
@@ -57,6 +58,10 @@ const props = defineProps({
type: String,
default: ''
},
companyLogo: {
type: String,
default: ''
},
currentUser: {
type: Object,
default: () => ({
@@ -145,6 +150,14 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
display: grid;
place-items: center;
color: #07936f;
border-radius: 6px;
overflow: hidden;
}
.custom-logo {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand-mark svg {
@@ -195,10 +208,9 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
}
.nav-btn.active {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
border-color: rgba(16, 185, 129, 0.1);
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.12);
color: #059669;
box-shadow: inset 3px 0 0 #10b981;
}
.nav-icon {
@@ -224,7 +236,7 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
min-width: 0;
color: currentColor;
font-size: 14px;
font-weight: 750;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;

View File

@@ -0,0 +1,727 @@
<template>
<article class="knowledge-ingest-panel panel">
<header class="ingest-head">
<div>
<span class="eyebrow">LightRAG 知识归集</span>
<h3>{{ model.folder || '未指定目录' }}</h3>
<p>{{ model.phaseLabel }} · {{ model.statusLabel }}</p>
</div>
<div class="progress-ring" :aria-label="`归集进度 ${model.progress.percent}%`">
<strong>{{ model.progress.percent }}%</strong>
<span>进度</span>
</div>
</header>
<div class="progress-bar" aria-hidden="true">
<span :style="{ width: `${model.progress.percent}%` }"></span>
</div>
<div class="metric-strip">
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}</strong>
<small>{{ metric.hint }}</small>
</div>
</div>
<div class="ingest-workspace">
<aside class="file-rail">
<button
v-for="document in model.documents"
:key="document.documentId"
type="button"
class="file-item"
:class="{ active: selectedDocumentId === document.documentId }"
@click="selectDocument(document.documentId)"
>
<i :class="documentIcon(document)"></i>
<span class="file-copy">
<strong>{{ document.name }}</strong>
<small>
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
</small>
</span>
<span class="mini-status" :class="document.statusTone">
{{ document.statusLabel }}
</span>
</button>
</aside>
<section v-if="selectedDocument" class="file-detail">
<div class="detail-topline">
<div>
<h4>{{ selectedDocument.name }}</h4>
<p>
{{ selectedDocument.folder || '根目录' }}
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
</p>
</div>
<span class="status-chip" :class="selectedDocument.statusTone">
{{ selectedDocument.statusLabel }}
</span>
</div>
<div class="detail-stats">
<div>
<span>原文字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
</div>
<div>
<span>索引字符</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
</div>
<div>
<span>Chunk</span>
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
</div>
<div>
<span>实体 / 关系</span>
<strong>
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
/
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
</strong>
</div>
</div>
<p v-if="selectedDocument.error" class="error-note">
{{ selectedDocument.error }}
</p>
<div class="detail-section-grid">
<section class="detail-section">
<div class="section-head">
<h5>Chunk 信息</h5>
<span>{{ selectedDocument.chunks.length }} </span>
</div>
<div v-if="selectedDocument.chunks.length" class="chunk-list">
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
<div>
<strong>{{ compactId(chunk.id) }}</strong>
<p>{{ chunk.summary || '暂无摘要' }}</p>
</div>
<small>{{ chunk.tokens }} tokens</small>
</div>
</div>
<div v-else class="compact-empty">暂无 chunk 明细</div>
</section>
<section class="detail-section">
<div class="section-head">
<h5>章节提取</h5>
<span>{{ selectedDocument.sectionCount }} </span>
</div>
<div v-if="selectedDocument.sections.length" class="section-list">
<div
v-for="section in selectedDocument.sections"
:key="section.title"
class="section-row"
>
<strong>{{ section.title }}</strong>
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
</div>
</div>
<div v-else class="compact-empty">暂无章节信息</div>
</section>
</div>
<section class="detail-section">
<div class="section-head">
<h5>处理事件</h5>
<span>{{ selectedDocument.events.length }} </span>
</div>
<div v-if="selectedDocument.events.length" class="event-list">
<div
v-for="event in selectedDocument.events"
:key="`${event.at}-${event.message}`"
class="event-row"
:class="event.level"
>
<span></span>
<div>
<strong>{{ formatEventTime(event.at) }}</strong>
<p>{{ event.message }}</p>
</div>
</div>
</div>
<div v-else class="compact-empty">暂无处理事件</div>
</section>
</section>
</div>
<section class="graph-section">
<div class="section-head">
<h4>图谱形成</h4>
<span>
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
</span>
</div>
<div class="graph-grid">
<div class="graph-pane">
<h5>实体</h5>
<div v-if="model.graph.entities.length" class="entity-cloud">
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
</div>
<div v-else class="compact-empty">暂无实体</div>
</div>
<div class="graph-pane">
<h5>关系</h5>
<div v-if="model.graph.relations.length" class="relation-list">
<div
v-for="relation in model.graph.relations"
:key="`${relation.source}-${relation.target}-${relation.type}`"
class="relation-row"
>
<strong>{{ relation.source }}</strong>
<i class="mdi mdi-arrow-right-thin"></i>
<strong>{{ relation.target }}</strong>
<span>{{ relation.type }}</span>
</div>
</div>
<div v-else class="compact-empty">暂无关系</div>
</div>
</div>
</section>
</article>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
buildKnowledgeIngestLogModel,
formatKnowledgeMetric
} from '../../utils/knowledgeIngestLogModel.js'
const props = defineProps({
run: {
type: Object,
required: true
}
})
const selectedDocumentId = ref('')
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
const selectedDocument = computed(
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
)
watch(
() => model.value.selectedDocumentId,
(nextDocumentId) => {
if (!nextDocumentId) {
selectedDocumentId.value = ''
return
}
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
selectedDocumentId.value = nextDocumentId
}
},
{ immediate: true }
)
function selectDocument(documentId) {
selectedDocumentId.value = documentId
}
function documentIcon(document) {
const extension = String(document?.extension || '').toLowerCase()
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
return 'mdi mdi-file-document-outline'
}
function compactId(value) {
const text = String(value || '').trim()
if (text.length <= 18) return text || 'chunk'
return `${text.slice(0, 8)}...${text.slice(-6)}`
}
function formatEventTime(value) {
if (!value) return '刚刚'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value)
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
</script>
<style scoped>
.knowledge-ingest-panel {
display: grid;
gap: 14px;
padding: 18px;
}
.ingest-head {
display: flex;
justify-content: space-between;
gap: 18px;
}
.eyebrow {
color: #0f766e;
font-size: 12px;
font-weight: 800;
}
.ingest-head h3 {
margin: 5px 0 0;
color: #0f172a;
font-size: 18px;
}
.ingest-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 13px;
}
.progress-ring {
width: 72px;
height: 72px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 50%;
background: #f8fafc;
}
.progress-ring strong,
.progress-ring span {
grid-area: 1 / 1;
}
.progress-ring strong {
margin-top: -12px;
color: #0f172a;
font-size: 17px;
}
.progress-ring span {
margin-top: 26px;
color: #64748b;
font-size: 11px;
}
.progress-bar {
height: 7px;
overflow: hidden;
border-radius: 999px;
background: #e5eaf0;
}
.progress-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0f766e, #2563eb);
transition: width 0.24s ease;
}
.metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric-tile {
min-width: 0;
display: grid;
gap: 4px;
padding: 11px 12px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.metric-tile span,
.metric-tile small {
color: #64748b;
font-size: 12px;
}
.metric-tile strong {
color: #0f172a;
font-size: 18px;
line-height: 1.2;
}
.ingest-workspace {
display: grid;
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
gap: 14px;
min-height: 360px;
}
.file-rail {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.file-item {
width: 100%;
min-height: 58px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
text-align: left;
}
.file-item.active {
border-color: rgba(15, 118, 110, 0.38);
background: #f0fdfa;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
}
.file-item > i {
color: #334155;
font-size: 24px;
}
.file-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.file-copy strong,
.file-copy small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-copy strong {
color: #0f172a;
font-size: 13px;
}
.file-copy small {
color: #64748b;
font-size: 12px;
}
.mini-status,
.status-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.mini-status.success,
.status-chip.success {
background: #dcfce7;
color: #166534;
}
.mini-status.warning,
.status-chip.warning {
background: #fef3c7;
color: #92400e;
}
.mini-status.danger,
.status-chip.danger {
background: #fee2e2;
color: #991b1b;
}
.mini-status.muted,
.status-chip.muted {
background: #eef2f7;
color: #475569;
}
.file-detail {
min-width: 0;
display: grid;
align-content: start;
gap: 12px;
}
.detail-topline {
display: flex;
justify-content: space-between;
gap: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #e5edf5;
}
.detail-topline h4 {
margin: 0;
color: #0f172a;
font-size: 16px;
}
.detail-topline p {
margin: 5px 0 0;
color: #64748b;
font-size: 12px;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.detail-stats div {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
.detail-stats span {
color: #64748b;
font-size: 12px;
}
.detail-stats strong {
color: #0f172a;
font-size: 13px;
}
.error-note {
margin: 0;
padding: 10px 12px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fff1f2;
color: #991b1b;
font-size: 13px;
}
.detail-section-grid,
.graph-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-section,
.graph-section,
.graph-pane {
min-width: 0;
display: grid;
gap: 10px;
align-content: start;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.section-head h4,
.section-head h5,
.graph-pane h5 {
margin: 0;
color: #0f172a;
font-size: 14px;
}
.section-head span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.chunk-list,
.section-list,
.event-list,
.relation-list {
display: grid;
gap: 8px;
}
.chunk-row,
.section-row,
.event-row,
.relation-row {
min-width: 0;
border: 1px solid #e5edf5;
border-radius: 8px;
background: #fff;
}
.chunk-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
}
.chunk-index {
color: #2563eb;
font-size: 12px;
font-weight: 850;
}
.chunk-row strong,
.section-row strong,
.event-row strong,
.relation-row strong {
color: #0f172a;
font-size: 12px;
}
.chunk-row p,
.section-row p,
.event-row p {
margin: 4px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.55;
}
.chunk-row small {
color: #64748b;
font-size: 11px;
white-space: nowrap;
}
.section-row {
padding: 10px;
}
.event-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
padding: 10px;
}
.event-row > span {
width: 8px;
height: 8px;
margin-top: 5px;
border-radius: 999px;
background: #2563eb;
}
.event-row.error > span {
background: #dc2626;
}
.entity-cloud {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.entity-cloud span {
max-width: 100%;
padding: 5px 9px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 750;
}
.relation-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 9px 10px;
}
.relation-row strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.relation-row i {
color: #0f766e;
font-size: 18px;
}
.relation-row span {
padding: 3px 7px;
border-radius: 999px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
}
.compact-empty {
min-height: 42px;
display: grid;
place-items: center;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 12px;
}
@media (max-width: 980px) {
.metric-strip,
.detail-stats,
.detail-section-grid,
.graph-grid,
.ingest-workspace {
grid-template-columns: 1fr;
}
.file-rail {
max-height: 260px;
overflow: auto;
}
}
@media (max-width: 620px) {
.ingest-head,
.detail-topline {
flex-direction: column;
}
.progress-ring {
width: 64px;
height: 64px;
}
.file-item {
grid-template-columns: auto minmax(0, 1fr);
}
.mini-status {
grid-column: 2;
justify-self: start;
}
.relation-row {
grid-template-columns: 1fr;
}
}
</style>