You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
265 lines
8.5 KiB
265 lines
8.5 KiB
<template>
|
|
<div class="knowledge-page">
|
|
<el-row :gutter="20">
|
|
<el-col :span="16">
|
|
<el-card>
|
|
<template #header>
|
|
<div class="card-header">
|
|
<span>知识库文档</span>
|
|
<div style="display: flex; gap: 8px; align-items: center">
|
|
<el-tag type="info" size="small">共 {{ stats.total_files || 0 }} 个文件 / {{ stats.total_chunks || 0 }} 个分块</el-tag>
|
|
<el-upload :http-request="uploadDoc" :show-file-list="false" accept=".pdf,.docx,.doc,.xlsx,.xls,.txt,.md">
|
|
<el-button type="primary" size="small">上传文档</el-button>
|
|
</el-upload>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<el-table :data="documents" v-loading="loading" stripe empty-text="暂无文档,请上传或手动索引文本">
|
|
<el-table-column prop="source" label="来源文件" min-width="200" show-overflow-tooltip />
|
|
<el-table-column prop="chunk_count" label="分块数" width="90" sortable>
|
|
<template #default="{ row }">
|
|
<el-tag size="small">{{ row.chunk_count }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="内容预览" min-width="250" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<span style="color: #666; font-size: 12px">{{ row.preview || '无预览' }}</span>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="120" fixed="right">
|
|
<template #default="{ row }">
|
|
<el-popconfirm title="确认删除该文档的所有分块?" @confirm="deleteDoc(row)" confirm-button-text="删除" cancel-button-text="取消">
|
|
<template #reference>
|
|
<el-button size="small" type="danger" link>删除</el-button>
|
|
</template>
|
|
</el-popconfirm>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<div v-if="documents.length > 0" style="margin-top: 12px; color: #909399; font-size: 12px; text-align: right">
|
|
提示:删除操作会移除该来源下的所有向量分块,不可恢复
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
|
|
<el-col :span="8">
|
|
<el-card>
|
|
<template #header>检索测试</template>
|
|
<el-input
|
|
v-model="query"
|
|
placeholder="输入问题检索知识库..."
|
|
@keyup.enter="doSearch"
|
|
clearable
|
|
style="margin-bottom: 12px"
|
|
>
|
|
<template #append>
|
|
<el-button @click="doSearch" :loading="searching">检索</el-button>
|
|
</template>
|
|
</el-input>
|
|
|
|
<div v-if="results.length > 0" class="results-container">
|
|
<div v-for="(r, i) in results" :key="i" class="result-item">
|
|
<div class="result-header">
|
|
<el-tag size="small" :type="r.score > 0.7 ? 'success' : r.score > 0.5 ? 'warning' : 'info'">
|
|
相关度 {{ (r.score * 100).toFixed(1) }}%
|
|
</el-tag>
|
|
<span class="result-source">来源: {{ r.source }}</span>
|
|
</div>
|
|
<div class="result-content">{{ r.content }}</div>
|
|
</div>
|
|
</div>
|
|
<el-empty v-else-if="searched && !searching" description="未找到相关内容" :image-size="60" />
|
|
<div v-else-if="!searched" class="search-placeholder">
|
|
<el-icon :size="40" color="#c0c4cc"><Search /></el-icon>
|
|
<p>输入问题后点击检索</p>
|
|
</div>
|
|
</el-card>
|
|
|
|
<el-card style="margin-top: 20px">
|
|
<template #header>手动索引</template>
|
|
<el-input
|
|
v-model="manualText"
|
|
type="textarea"
|
|
:rows="4"
|
|
placeholder="粘贴需要索引的文本内容..."
|
|
maxlength="10000"
|
|
show-word-limit
|
|
/>
|
|
<el-input v-model="manualSource" placeholder="来源标识(如:产品手册-v2)" style="margin-top: 8px" />
|
|
<el-button
|
|
type="primary"
|
|
@click="indexText"
|
|
:loading="indexing"
|
|
:disabled="!manualText.trim()"
|
|
style="width: 100%; margin-top: 8px"
|
|
>索引文本</el-button>
|
|
</el-card>
|
|
|
|
<el-card style="margin-top: 20px">
|
|
<template #header>知识库状态</template>
|
|
<el-descriptions :column="1" border size="small">
|
|
<el-descriptions-item label="状态">
|
|
<el-tag :type="stats.status === 'initialized' ? 'success' : 'danger'" size="small">
|
|
{{ stats.status === 'initialized' ? '正常' : '异常' }}
|
|
</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="集合名称">{{ stats.collection_name || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="向量维度">{{ stats.dimensions || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="总分块数">{{ stats.total_chunks || 0 }}</el-descriptions-item>
|
|
<el-descriptions-item label="文件数量">{{ stats.total_files || 0 }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Search } from '@element-plus/icons-vue'
|
|
import { ragApi } from '@/api'
|
|
|
|
const loading = ref(false)
|
|
const searching = ref(false)
|
|
const indexing = ref(false)
|
|
const searched = ref(false)
|
|
const documents = ref<any[]>([])
|
|
const query = ref('')
|
|
const results = ref<any[]>([])
|
|
const manualText = ref('')
|
|
const manualSource = ref('manual')
|
|
const stats = ref<any>({})
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const res: any = await ragApi.getStats()
|
|
stats.value = res?.data || {}
|
|
} catch {
|
|
stats.value = { status: 'error', total_chunks: 0, total_files: 0 }
|
|
}
|
|
}
|
|
|
|
async function loadDocs() {
|
|
loading.value = true
|
|
try {
|
|
const res: any = await ragApi.getDocuments()
|
|
documents.value = (res?.data || []).map((d: any) => ({
|
|
source: d.source,
|
|
chunk_count: d.chunk_count || 0,
|
|
preview: d.preview || '',
|
|
}))
|
|
await loadStats()
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || '获取文档列表失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function uploadDoc(options: any) {
|
|
try {
|
|
await ragApi.upload(options.file)
|
|
ElMessage.success('文档已上传并开始索引')
|
|
await loadDocs()
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || '上传失败')
|
|
}
|
|
}
|
|
|
|
async function doSearch() {
|
|
if (!query.value.trim()) return
|
|
searching.value = true
|
|
searched.value = true
|
|
try {
|
|
const res: any = await ragApi.search(query.value.trim(), 5)
|
|
results.value = Array.isArray(res?.data) ? res.data : []
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || '检索失败')
|
|
results.value = []
|
|
} finally {
|
|
searching.value = false
|
|
}
|
|
}
|
|
|
|
async function indexText() {
|
|
if (!manualText.value.trim()) return
|
|
indexing.value = true
|
|
try {
|
|
await ragApi.indexText({ text: manualText.value, source: manualSource.value || 'manual' })
|
|
ElMessage.success('文本已索引到知识库')
|
|
manualText.value = ''
|
|
manualSource.value = 'manual'
|
|
await loadDocs()
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || '索引失败')
|
|
} finally {
|
|
indexing.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteDoc(row: any) {
|
|
try {
|
|
await ragApi.deleteDocument(row.source)
|
|
ElMessage.success(`已删除 "${row.source}" 的所有分块`)
|
|
await loadDocs()
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.response?.data?.message || '删除失败')
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadDocs()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.knowledge-page {
|
|
padding: 0;
|
|
}
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.results-container {
|
|
max-height: 350px;
|
|
overflow-y: auto;
|
|
}
|
|
.result-item {
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.result-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 4px;
|
|
}
|
|
.result-source {
|
|
font-size: 11px;
|
|
color: #909399;
|
|
}
|
|
.result-content {
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: #303133;
|
|
max-height: 80px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.search-placeholder {
|
|
text-align: center;
|
|
padding: 30px 0;
|
|
color: #c0c4cc;
|
|
}
|
|
.search-placeholder p {
|
|
margin-top: 8px;
|
|
font-size: 13px;
|
|
}
|
|
</style>
|