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

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