feat: 添加知识库检索功能,支持根据知识库 ID 列出相关会话;更新相关界面和国际化文本

This commit is contained in:
lxfight
2025-10-20 22:23:35 +08:00
parent c56edb4da6
commit 2fc77aed15
6 changed files with 391 additions and 212 deletions

View File

@@ -238,10 +238,13 @@ class KnowledgeBaseManager:
)
if len(embedding_providers) > 1 and not configured_provider_id:
logger.warning(
provider = embedding_providers[0]
provider_id = provider.meta().id
logger.info(
f"检测到 {len(embedding_providers)} 个 Embedding Provider"
f"但未指定使用哪个,将默认使用第一个"
f"未在配置文件中指定 embedding_provider_id将使用第一个: {provider_id}"
)
return provider
provider = embedding_providers[0]
provider_id = provider.meta().id

View File

@@ -57,6 +57,7 @@ class KnowledgeBaseRoute(Route):
"/kb/session/config/set": ("POST", self.set_session_config),
"/kb/session/config/delete": ("POST", self.delete_session_config),
"/kb/session/config/list": ("GET", self.list_session_configs),
"/kb/session/config/list_by_kb": ("GET", self.list_sessions_by_kb),
}
self.register_routes()
@@ -1241,3 +1242,51 @@ class KnowledgeBaseRoute(Route):
logger.error(f"获取会话配置列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取会话配置列表失败: {str(e)}").__dict__
async def list_sessions_by_kb(self):
"""获取使用特定知识库的会话列表
Query 参数:
- kb_id: 知识库 ID (必填)
"""
try:
kb_db = self.kb_db if self.kb_db else self._get_kb_manager() and self.kb_db
kb_id = request.args.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
# 获取所有会话配置
configs = await kb_db.list_all_session_configs(offset=0, limit=1000)
import json
# 筛选包含该知识库的会话
session_list = []
for config in configs:
kb_ids = json.loads(config.kb_ids)
if kb_id in kb_ids:
session_dict = {
"config_id": config.config_id,
"scope": config.scope,
"scope_id": config.scope_id,
"kb_ids": kb_ids,
"top_k": config.top_k,
"enable_rerank": config.enable_rerank,
"created_at": config.created_at.isoformat(),
"updated_at": config.updated_at.isoformat(),
}
session_list.append(session_dict)
return (
Response()
.ok({"sessions": session_list, "total": len(session_list), "kb_id": kb_id})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"获取知识库会话列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取知识库会话列表失败: {str(e)}").__dict__

View File

@@ -4,7 +4,8 @@
"tabs": {
"overview": "概览",
"documents": "文档管理",
"sessions": "会话配置",
"retrieval": "知识库检索",
"sessions": "使用会话",
"settings": "设置"
},
"overview": {
@@ -57,24 +58,44 @@
"fileRequired": "请选择要上传的文件"
},
"sessions": {
"title": "会话配置",
"subtitle": "配置哪些会话可以使用此知识库",
"empty": "暂无会话配置",
"add": "添加配置",
"title": "使用该知识库的会话",
"subtitle": "以下会话正在使用此知识库",
"empty": "暂无会话使用此知识库",
"refresh": "刷新",
"scope": "范围",
"scopeId": "标识",
"scopeId": "会话标识",
"topK": "返回结果数",
"enableRerank": "启用重排序",
"actions": "操作",
"edit": "编辑",
"delete": "删除",
"scopeSession": "会话级别",
"scopePlatform": "平台级别",
"deleteConfirm": "确定要删除此配置吗?",
"addSuccess": "配置添加成功",
"addFailed": "配置添加失败",
"deleteSuccess": "配置删除成功",
"deleteFailed": "配置删除失败"
"viewInSessionManagement": "在会话管理中查看",
"goToSessionManagement": "前往会话管理",
"loadFailed": "加载会话列表失败"
},
"retrieval": {
"title": "知识库检索",
"subtitle": "使用稠密检索和稀疏检索测试知识库内容",
"query": "检索查询",
"queryPlaceholder": "输入要检索的内容...",
"search": "检索",
"searching": "检索中...",
"results": "检索结果",
"noResults": "没有找到相关内容",
"tryDifferentQuery": "尝试使用不同的查询词",
"settings": "检索设置",
"topK": "返回结果数量",
"topKHint": "最多返回多少条检索结果",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"score": "相关度分数",
"document": "所属文档",
"chunk": "文本块 #{index}",
"content": "内容",
"charCount": "{count} 字符",
"searchSuccess": "检索完成,找到 {count} 条结果",
"searchFailed": "检索失败",
"queryRequired": "请输入检索查询"
},
"settings": {
"title": "知识库设置",

View File

@@ -36,6 +36,10 @@
{{ t('tabs.documents') }}
<v-chip class="ml-2" size="small" variant="tonal">{{ kb.doc_count || 0 }}</v-chip>
</v-tab>
<v-tab value="retrieval">
<v-icon start>mdi-magnify</v-icon>
{{ t('tabs.retrieval') }}
</v-tab>
<v-tab value="sessions">
<v-icon start>mdi-account-multiple</v-icon>
{{ t('tabs.sessions') }}
@@ -157,7 +161,12 @@
<DocumentsTab :kb-id="kbId" :kb="kb" @refresh="loadKB" />
</v-window-item>
<!-- 会话配置 -->
<!-- 知识库检索 -->
<v-window-item value="retrieval">
<RetrievalTab :kb-id="kbId" />
</v-window-item>
<!-- 使用会话 -->
<v-window-item value="sessions">
<SessionsTab :kb-id="kbId" />
</v-window-item>
@@ -182,6 +191,7 @@ import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import DocumentsTab from './components/DocumentsTab.vue'
import RetrievalTab from './components/RetrievalTab.vue'
import SessionsTab from './components/SessionsTab.vue'
import SettingsTab from './components/SettingsTab.vue'

View File

@@ -0,0 +1,243 @@
<template>
<div class="retrieval-tab">
<v-card elevation="2">
<v-card-title class="pa-4">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="px-4 pb-4">
{{ t('retrieval.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-card-text class="pa-6">
<!-- 查询输入区域 -->
<v-row>
<v-col cols="12" md="8">
<v-textarea
v-model="query"
:label="t('retrieval.query')"
:placeholder="t('retrieval.queryPlaceholder')"
variant="outlined"
rows="3"
auto-grow
clearable
/>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4>
<v-text-field
v-model.number="topK"
:label="t('retrieval.topK')"
:hint="t('retrieval.topKHint')"
type="number"
variant="outlined"
density="compact"
persistent-hint
class="mb-3"
/>
<v-checkbox
v-model="enableRerank"
:label="t('retrieval.enableRerank')"
:hint="t('retrieval.enableRerankHint')"
color="primary"
density="compact"
persistent-hint
/>
</v-card>
</v-col>
</v-row>
<div class="d-flex justify-end mb-4">
<v-btn
prepend-icon="mdi-magnify"
color="primary"
variant="elevated"
@click="performRetrieval"
:loading="loading"
:disabled="!query || query.trim() === ''"
>
{{ loading ? t('retrieval.searching') : t('retrieval.search') }}
</v-btn>
</div>
<!-- 检索结果 -->
<div v-if="hasSearched" class="results-section">
<v-divider class="mb-4" />
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
<v-chip class="ml-3" color="primary" variant="tonal">
{{ results.length }} {{ t('retrieval.results') }}
</v-chip>
</div>
<!-- 结果列表 -->
<div v-if="results.length > 0" class="results-list">
<v-card
v-for="(result, index) in results"
:key="result.chunk_id"
variant="outlined"
class="mb-4"
>
<v-card-title class="d-flex align-center pa-4">
<v-chip size="small" color="primary" class="mr-2">
#{{ index + 1 }}
</v-chip>
<span class="text-subtitle-1">
{{ t('retrieval.chunk', { index: result.chunk_index }) }}
</span>
<v-spacer />
<v-chip size="small" :color="getScoreColor(result.score)">
{{ t('retrieval.score') }}: {{ result.score.toFixed(4) }}
</v-chip>
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div class="mb-3">
<v-chip size="small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-file-document</v-icon>
{{ result.doc_name }}
</v-chip>
<v-chip size="small" variant="tonal">
<v-icon start size="small">mdi-text</v-icon>
{{ t('retrieval.charCount', { count: result.char_count }) }}
</v-chip>
</div>
<div class="content-box">
{{ result.content }}
</div>
</v-card-text>
</v-card>
</div>
<!-- 空结果 -->
<div v-else class="text-center py-12">
<v-icon size="80" color="grey-lighten-2">mdi-text-box-search-outline</v-icon>
<p class="text-h6 mt-4 text-medium-emphasis">{{ t('retrieval.noResults') }}</p>
<p class="text-body-2 text-medium-emphasis">{{ t('retrieval.tryDifferentQuery') }}</p>
</div>
</div>
</v-card-text>
</v-card>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kbId: string
}>()
// 状态
const loading = ref(false)
const query = ref('')
const topK = ref(5)
const enableRerank = ref(true)
const results = ref<any[]>([])
const hasSearched = ref(false)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 执行检索
const performRetrieval = async () => {
if (!query.value || query.value.trim() === '') {
showSnackbar(t('retrieval.queryRequired'), 'warning')
return
}
loading.value = true
hasSearched.value = false
try {
const response = await axios.post('/api/kb/retrieve', {
query: query.value,
kb_ids: [props.kbId],
top_k: topK.value,
enable_rerank: enableRerank.value
})
if (response.data.status === 'ok') {
results.value = response.data.data.results || []
hasSearched.value = true
showSnackbar(t('retrieval.searchSuccess', { count: results.value.length }))
} else {
showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error')
}
} catch (error) {
console.error('Retrieval failed:', error)
showSnackbar(t('retrieval.searchFailed'), 'error')
} finally {
loading.value = false
}
}
// 根据分数获取颜色
const getScoreColor = (score: number) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'info'
if (score >= 0.4) return 'warning'
return 'error'
}
</script>
<style scoped>
.retrieval-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.results-section {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.content-box {
background: rgba(var(--v-theme-surface-variant), 0.3);
border-radius: 8px;
padding: 16px;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.6;
}
</style>

View File

@@ -5,13 +5,13 @@
<span>{{ t('sessions.title') }}</span>
<v-spacer />
<v-btn
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
prepend-icon="mdi-refresh"
variant="tonal"
size="small"
@click="showAddDialog = true"
@click="loadSessions"
:loading="loading"
>
{{ t('sessions.add') }}
{{ t('sessions.refresh') }}
</v-btn>
</v-card-title>
@@ -41,11 +41,12 @@
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete"
icon="mdi-open-in-new"
variant="text"
size="small"
color="error"
@click="confirmDelete(item)"
color="primary"
@click="goToSessionManagement(item)"
:title="t('sessions.viewInSessionManagement')"
/>
</template>
@@ -53,99 +54,21 @@
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-account-multiple-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('sessions.empty') }}</p>
<v-btn
class="mt-4"
prepend-icon="mdi-cog"
variant="tonal"
color="primary"
@click="goToSessionManagement()"
>
{{ t('sessions.goToSessionManagement') }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- 添加配置对话框 -->
<v-dialog v-model="showAddDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="pa-4">
<span class="text-h6">{{ t('sessions.add') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeAddDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="formRef">
<v-select
v-model="formData.scope"
:items="scopeOptions"
:label="t('sessions.scope')"
variant="outlined"
class="mb-4"
/>
<v-text-field
v-model="formData.scope_id"
:label="t('sessions.scopeId')"
:placeholder="formData.scope === 'session' ? 'platform:xxx:session_id' : 'platform_id'"
variant="outlined"
required
class="mb-4"
/>
<v-text-field
v-model.number="formData.top_k"
:label="t('sessions.topK')"
type="number"
variant="outlined"
class="mb-4"
/>
<v-checkbox
v-model="formData.enable_rerank"
:label="t('sessions.enableRerank')"
color="primary"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeAddDialog">取消</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="addSession"
:loading="saving"
>
添加
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card>
<v-card-title class="pa-4 text-h6">确认删除</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('sessions.deleteConfirm') }}</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn
color="error"
variant="elevated"
@click="deleteSession"
:loading="deleting"
>
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
@@ -155,10 +78,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const router = useRouter()
const props = defineProps<{
kbId: string
@@ -166,13 +91,7 @@ const props = defineProps<{
// 状态
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const sessions = ref<any[]>([])
const showAddDialog = ref(false)
const showDeleteDialog = ref(false)
const deleteTarget = ref<any>(null)
const formRef = ref()
const snackbar = ref({
show: false,
@@ -186,14 +105,6 @@ const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.show = true
}
// 表单数据
const formData = ref({
scope: 'session',
scope_id: '',
top_k: 5,
enable_rerank: true
})
// 表格列
const headers = [
{ title: t('sessions.scope'), key: 'scope' },
@@ -203,104 +114,46 @@ const headers = [
{ title: t('sessions.actions'), key: 'actions', sortable: false, align: 'end' }
]
// 范围选项
const scopeOptions = [
{ title: t('sessions.scopeSession'), value: 'session' },
{ title: t('sessions.scopePlatform'), value: 'platform' }
]
// 加载会话配置
// 加载使用该知识库的会话
const loadSessions = async () => {
loading.value = true
console.log('[SessionsTab] 开始加载会话列表, kb_id:', props.kbId)
try {
const response = await axios.get('/api/kb/session/config/list')
const url = '/api/kb/session/config/list_by_kb'
const params = { kb_id: props.kbId }
console.log('[SessionsTab] 请求URL:', url, '参数:', params)
const response = await axios.get(url, { params })
console.log('[SessionsTab] 响应状态:', response.status)
console.log('[SessionsTab] 响应数据:', response.data)
if (response.data.status === 'ok') {
// 过滤出使用当前知识库的配置
sessions.value = response.data.data.items.filter((item: any) => {
const kbIds = JSON.parse(item.kb_ids || '[]')
return kbIds.includes(props.kbId)
})
sessions.value = response.data.data.sessions
console.log('[SessionsTab] 成功加载会话列表, 数量:', sessions.value.length)
} else {
console.error('[SessionsTab] API返回错误:', response.data.message)
showSnackbar(response.data.message || t('sessions.loadFailed'), 'error')
}
} catch (error) {
console.error('Failed to load session configs:', error)
showSnackbar('加载会话配置失败', 'error')
console.error('[SessionsTab] 请求失败:', error)
if (error.response) {
console.error('[SessionsTab] 错误响应状态:', error.response.status)
console.error('[SessionsTab] 错误响应数据:', error.response.data)
} else if (error.request) {
console.error('[SessionsTab] 请求未收到响应:', error.request)
} else {
console.error('[SessionsTab] 请求配置错误:', error.message)
}
showSnackbar(t('sessions.loadFailed'), 'error')
} finally {
loading.value = false
}
}
// 添加会话配置
const addSession = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const response = await axios.post('/api/kb/session/config', {
scope: formData.value.scope,
scope_id: formData.value.scope_id,
kb_ids: [props.kbId],
top_k: formData.value.top_k,
enable_rerank: formData.value.enable_rerank
})
if (response.data.status === 'ok') {
showSnackbar(t('sessions.addSuccess'))
closeAddDialog()
await loadSessions()
} else {
showSnackbar(response.data.message || t('sessions.addFailed'), 'error')
}
} catch (error) {
console.error('Failed to add session config:', error)
showSnackbar(t('sessions.addFailed'), 'error')
} finally {
saving.value = false
}
}
// 确认删除
const confirmDelete = (session: any) => {
deleteTarget.value = session
showDeleteDialog.value = true
}
// 删除会话配置
const deleteSession = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/session/config/delete', {
scope: deleteTarget.value.scope,
scope_id: deleteTarget.value.scope_id
})
if (response.data.status === 'ok') {
showSnackbar(t('sessions.deleteSuccess'))
showDeleteDialog.value = false
await loadSessions()
} else {
showSnackbar(response.data.message || t('sessions.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete session config:', error)
showSnackbar(t('sessions.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 关闭添加对话框
const closeAddDialog = () => {
showAddDialog.value = false
formData.value = {
scope: 'session',
scope_id: '',
top_k: 5,
enable_rerank: true
}
formRef.value?.reset()
// 跳转到会话管理页面
const goToSessionManagement = (session?: any) => {
router.push({ name: 'SessionManagement' })
}
onMounted(() => {