feat: 实现知识库前端组件和路由

- 实现知识库 V2 主页面和 4 个子面板组件
- 文档管理面板:支持上传、删除、查看文档分块
- 检索测试面板:支持测试知识库检索效果
- 全局设置面板:配置嵌入模型、重排序、检索参数
- 会话配置面板:管理会话与知识库的绑定关系
- 重构 Alkaid 路由为嵌套结构,添加知识库 V2 路由
- 在翻译系统中注册知识库 V2 多语言支持
- 默认进入 Alkaid 时跳转到原生知识库页面
This commit is contained in:
lxfight
2025-10-19 18:43:58 +08:00
parent c04738d9fe
commit 2563ecf3c5
6 changed files with 2350 additions and 5 deletions
+11 -5
View File
@@ -7,9 +7,15 @@
<small style="color: #a3a3a3;">{{ tm('page.subtitle') }}</small>
</div>
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<div style="display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;">
<v-btn size="large" :variant="isActive('knowledge-base-v2') ? 'flat' : 'tonal'"
:color="isActive('knowledge-base-v2') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('knowledge-base-v2')">
<v-icon start>mdi-book-open-page-variant</v-icon>
{{ tm('page.navigation.knowledgeBaseV2') }}
</v-btn>
<v-btn size="large" :variant="isActive('knowledge-base') ? 'flat' : 'tonal'"
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
:color="isActive('knowledge-base') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('knowledge-base')">
<v-icon start>mdi-text-box-search</v-icon>
{{ tm('page.navigation.knowledgeBase') }}
@@ -21,7 +27,7 @@
{{ tm('page.navigation.longTermMemory') }}
</v-btn>
<v-btn size="large" :variant="isActive('other') ? 'flat' : 'tonal'"
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
:color="isActive('other') ? '#9b72cb' : ''" rounded="lg"
@click="navigateTo('other')">
<v-icon start>mdi-tools</v-icon>
{{ tm('page.navigation.other') }}
@@ -69,9 +75,9 @@ export default {
}
},
mounted() {
// 如果在根路径 /alkaid,默认跳转到知识库页面
// 如果在根路径 /alkaid,默认跳转到原生知识库页面
if (this.$route.path === '/alkaid') {
this.navigateTo('knowledge-base');
this.navigateTo('knowledge-base-v2');
}
}
}
@@ -0,0 +1,686 @@
<template>
<div class="kb-v2-container">
<!-- 警告提示 -->
<v-alert
v-if="showCompatibilityWarning"
type="warning"
variant="tonal"
closable
class="mb-4"
@click:close="showCompatibilityWarning = false"
>
<strong>兼容性提示:</strong> 此为 AstrBot 原生知识库系统如您已安装知识库插件,建议不要同时使用两个知识库系统,以避免配置冲突
</v-alert>
<!-- 知识库列表视图 -->
<div v-if="kbCollections.length > 0 || loading">
<div class="d-flex align-center justify-space-between mb-4">
<div>
<h2>{{ tm('list.title') }}</h2>
<small class="text-medium-emphasis">{{ tm('list.subtitle') }}</small>
</div>
<v-icon
size="small"
color="grey"
@click="openUrl('https://astrbot.app/use/knowledge-base.html')"
>
mdi-information-outline
</v-icon>
</div>
<!-- 操作按钮组 -->
<div class="action-buttons mb-4">
<v-btn prepend-icon="mdi-plus" variant="tonal" color="primary" @click="showCreateDialog = true">
{{ tm('list.create') }}
</v-btn>
<v-btn prepend-icon="mdi-account-cog" variant="tonal" color="info" @click="showSessionConfigDialog = true">
{{ tm('list.sessionConfig') }}
</v-btn>
<v-btn prepend-icon="mdi-refresh" variant="tonal" @click="loadKnowledgeBases" :loading="loading">
{{ tm('list.refresh') }}
</v-btn>
</div>
<!-- 知识库网格 -->
<div v-if="loading && kbCollections.length === 0" class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<p class="mt-4">{{ tm('list.loading') }}</p>
</div>
<div v-else class="kb-grid">
<v-card
v-for="(kb, index) in kbCollections"
:key="index"
class="kb-card"
@click="openKnowledgeBase(kb)"
hover
>
<div class="book-spine"></div>
<div class="book-content">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<div class="kb-name">{{ kb.kb_name }}</div>
<div class="kb-stats">
<div class="stat-item">
<v-icon size="small">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ tm('list.documents') }}</span>
</div>
<div class="stat-item">
<v-icon size="small">mdi-text-box</v-icon>
<span>{{ kb.chunk_count || 0 }} {{ tm('list.chunks') }}</span>
</div>
</div>
<div class="kb-actions">
<v-btn
icon
variant="text"
size="small"
color="info"
@click.stop="editKnowledgeBase(kb)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn
icon
variant="text"
size="small"
color="error"
@click.stop="confirmDelete(kb)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
</div>
</v-card>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="d-flex align-center justify-center flex-column"
style="min-height: 400px"
>
<v-icon size="80" color="grey-lighten-1">mdi-book-open-variant</v-icon>
<h2 class="mt-4">{{ tm('empty.title') }}</h2>
<p class="text-medium-emphasis mt-2">{{ tm('empty.subtitle') }}</p>
<v-btn
class="mt-4"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
@click="showCreateDialog = true"
>
{{ tm('empty.create') }}
</v-btn>
</div>
<!-- 创建/编辑知识库对话框 -->
<v-dialog v-model="showCreateDialog" max-width="600px">
<v-card>
<v-card-title class="d-flex align-center">
<span class="text-h5">{{ editingKB ? tm('editDialog.title') : tm('createDialog.title') }}</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="showCreateDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<div class="text-center mb-4">
<span class="emoji-display" @click="showEmojiPicker = true">
{{ newKB.emoji || '📚' }}
</span>
</div>
<v-form @submit.prevent="submitCreateForm">
<v-text-field
v-model="newKB.kb_name"
:label="tm('createDialog.nameLabel')"
:placeholder="tm('createDialog.namePlaceholder')"
variant="outlined"
required
class="mb-2"
></v-text-field>
<v-textarea
v-model="newKB.description"
:label="tm('createDialog.descriptionLabel')"
:placeholder="tm('createDialog.descriptionPlaceholder')"
variant="outlined"
rows="3"
class="mb-2"
></v-textarea>
<v-select
v-model="newKB.embedding_provider_id"
:items="embeddingProviderConfigs"
:item-props="embeddingModelProps"
:label="tm('createDialog.embeddingModelLabel')"
variant="outlined"
density="comfortable"
class="mb-2"
></v-select>
<v-select
v-model="newKB.rerank_provider_id"
:items="rerankProviderConfigs"
:item-props="rerankModelProps"
:label="tm('createDialog.rerankModelLabel')"
variant="outlined"
density="comfortable"
clearable
></v-select>
<small class="text-medium-emphasis">{{ tm('createDialog.tips') }}</small>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showCreateDialog = false">
{{ tm('createDialog.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="text"
@click="submitCreateForm"
:loading="creating"
>
{{ editingKB ? tm('editDialog.save') : tm('createDialog.create') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Emoji 选择器对话框 -->
<v-dialog v-model="showEmojiPicker" max-width="500px">
<v-card>
<v-card-title>{{ tm('emojiPicker.title') }}</v-card-title>
<v-card-text>
<div class="emoji-picker">
<div v-for="(category, catIndex) in emojiCategories" :key="catIndex" class="mb-4">
<div class="text-subtitle-2 mb-2">{{ tm(`emojiPicker.categories.${category.key}`) }}</div>
<div class="emoji-grid">
<div
v-for="(emoji, emojiIndex) in category.emojis"
:key="emojiIndex"
class="emoji-item"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">
{{ tm('emojiPicker.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 知识库详情对话框 -->
<v-dialog v-model="showDetailDialog" max-width="1200px" scrollable>
<v-card>
<v-card-title class="d-flex align-center">
<div class="emoji-sm me-2">{{ currentKB.emoji || '📚' }}</div>
<span>{{ currentKB.kb_name }}</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="showDetailDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-subtitle v-if="currentKB.description">
{{ currentKB.description }}
</v-card-subtitle>
<v-card-text>
<v-tabs v-model="activeTab">
<v-tab value="documents">{{ tm('detailDialog.tabs.documents') }}</v-tab>
<v-tab value="search">{{ tm('detailDialog.tabs.search') }}</v-tab>
<v-tab value="settings">{{ tm('detailDialog.tabs.settings') }}</v-tab>
</v-tabs>
<v-window v-model="activeTab" class="mt-4">
<v-window-item value="documents">
<DocumentListPanel v-if="currentKB.kb_id" :kb="currentKB" />
</v-window-item>
<v-window-item value="search">
<SearchPanel v-if="currentKB.kb_id" :kb="currentKB" />
</v-window-item>
<v-window-item value="settings">
<KBSettingsPanel
v-if="currentKB.kb_id"
:kb="currentKB"
@updated="onKBUpdated"
/>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="text-h5">{{ tm('deleteDialog.title') }}</v-card-title>
<v-card-text>
<p>{{ tm('deleteDialog.confirmText', { name: deleteTarget.kb_name }) }}</p>
<p class="text-error mt-2">{{ tm('deleteDialog.warning') }}</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showDeleteDialog = false">
{{ tm('deleteDialog.cancel') }}
</v-btn>
<v-btn color="error" variant="text" @click="deleteKnowledgeBase" :loading="deleting">
{{ tm('deleteDialog.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 会话配置对话框 -->
<v-dialog v-model="showSessionConfigDialog" max-width="900px" scrollable>
<v-card>
<v-card-title class="d-flex align-center">
<span>{{ tm('sessionConfig.title') }}</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="showSessionConfigDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<SessionConfigPanel />
</v-card-text>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import DocumentListPanel from './components/DocumentListPanel.vue';
import SearchPanel from './components/SearchPanel.vue';
import KBSettingsPanel from './components/KBSettingsPanel.vue';
import SessionConfigPanel from './components/SessionConfigPanel.vue';
export default {
name: 'KnowledgeBaseV2',
components: {
DocumentListPanel,
SearchPanel,
KBSettingsPanel,
SessionConfigPanel,
},
setup() {
const { tm } = useModuleI18n('features/alkaid/knowledge-base-v2/index');
return { tm };
},
data() {
return {
showCompatibilityWarning: true,
kbCollections: [],
loading: false,
creating: false,
deleting: false,
showCreateDialog: false,
showEmojiPicker: false,
showDetailDialog: false,
showDeleteDialog: false,
showSessionConfigDialog: false,
editingKB: null,
currentKB: {},
deleteTarget: {},
activeTab: 'documents',
newKB: {
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null,
},
embeddingProviderConfigs: [],
rerankProviderConfigs: [],
emojiCategories: [
{
key: 'books',
emojis: ['📚', '📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📑', '🗂️', '📂', '📁', '🗃️', '🗄️'],
},
{
key: 'emotions',
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍'],
},
{
key: 'objects',
emojis: ['💡', '🔬', '🔭', '🗿', '🏆', '🎯', '🎓', '🔑', '🔒', '🔓', '🔔', '🔕', '🔨', '🛠️', '⚙️'],
},
{
key: 'symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '⭐', '🌟', '✨', '💫', '⚡', '🔥'],
},
],
snackbar: {
show: false,
text: '',
color: 'success',
},
};
},
mounted() {
this.loadKnowledgeBases();
this.loadProviderConfigs();
},
methods: {
embeddingModelProps(providerConfig) {
return {
title: providerConfig.embedding_model || providerConfig.id,
subtitle: this.tm('createDialog.providerInfo', {
id: providerConfig.id,
dimensions: providerConfig.embedding_dimensions || 'N/A',
}),
};
},
rerankModelProps(providerConfig) {
return {
title: providerConfig.rerank_model || providerConfig.id,
subtitle: this.tm('createDialog.rerankProviderInfo', {
id: providerConfig.id,
}),
};
},
async loadKnowledgeBases() {
this.loading = true;
try {
const response = await axios.get('/api/kb/list');
if (response.data.status === 'ok') {
this.kbCollections = response.data.data.items || [];
} else {
this.showSnackbar(response.data.message || this.tm('messages.loadFailed'), 'error');
}
} catch (error) {
console.error('Error loading knowledge bases:', error);
this.showSnackbar(this.tm('messages.loadError'), 'error');
} finally {
this.loading = false;
}
},
async loadProviderConfigs() {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' },
});
if (response.data.status === 'ok') {
this.embeddingProviderConfigs = response.data.data.filter(
(p) => p.provider_type === 'embedding'
);
this.rerankProviderConfigs = response.data.data.filter(
(p) => p.provider_type === 'rerank'
);
}
} catch (error) {
console.error('Error loading provider configs:', error);
}
},
openKnowledgeBase(kb) {
this.currentKB = kb;
this.activeTab = 'documents';
this.showDetailDialog = true;
},
editKnowledgeBase(kb) {
this.editingKB = kb;
this.newKB = {
kb_name: kb.kb_name,
description: kb.description || '',
emoji: kb.emoji || '📚',
embedding_provider_id: kb.embedding_provider_id,
rerank_provider_id: kb.rerank_provider_id,
};
this.showCreateDialog = true;
},
async submitCreateForm() {
if (!this.newKB.kb_name) {
this.showSnackbar(this.tm('messages.nameRequired'), 'warning');
return;
}
this.creating = true;
try {
const payload = {
kb_name: this.newKB.kb_name,
description: this.newKB.description,
emoji: this.newKB.emoji || '📚',
embedding_provider_id: this.newKB.embedding_provider_id,
rerank_provider_id: this.newKB.rerank_provider_id,
};
let response;
if (this.editingKB) {
response = await axios.post('/api/kb/update', {
kb_id: this.editingKB.kb_id,
...payload,
});
} else {
response = await axios.post('/api/kb/create', payload);
}
if (response.data.status === 'ok') {
this.showSnackbar(
this.editingKB ? this.tm('messages.updateSuccess') : this.tm('messages.createSuccess')
);
this.showCreateDialog = false;
this.resetNewKB();
this.loadKnowledgeBases();
} else {
this.showSnackbar(response.data.message || this.tm('messages.saveFailed'), 'error');
}
} catch (error) {
console.error('Error saving knowledge base:', error);
this.showSnackbar(this.tm('messages.saveError'), 'error');
} finally {
this.creating = false;
}
},
confirmDelete(kb) {
this.deleteTarget = kb;
this.showDeleteDialog = true;
},
async deleteKnowledgeBase() {
if (!this.deleteTarget.kb_id) {
return;
}
this.deleting = true;
try {
const response = await axios.post('/api/kb/delete', {
kb_id: this.deleteTarget.kb_id,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.deleteSuccess'));
this.showDeleteDialog = false;
this.loadKnowledgeBases();
} else {
this.showSnackbar(response.data.message || this.tm('messages.deleteFailed'), 'error');
}
} catch (error) {
console.error('Error deleting knowledge base:', error);
this.showSnackbar(this.tm('messages.deleteError'), 'error');
} finally {
this.deleting = false;
}
},
selectEmoji(emoji) {
this.newKB.emoji = emoji;
this.showEmojiPicker = false;
},
resetNewKB() {
this.editingKB = null;
this.newKB = {
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null,
};
},
onKBUpdated() {
this.loadKnowledgeBases();
this.showDetailDialog = false;
},
openUrl(url) {
window.open(url, '_blank');
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
},
};
</script>
<style scoped>
.kb-v2-container {
padding: 16px;
}
.action-buttons {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 24px;
margin-top: 16px;
}
.kb-card {
height: 280px;
overflow: hidden;
position: relative;
cursor: pointer;
display: flex;
transition: all 0.3s ease;
}
.kb-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
.book-spine {
width: 12px;
background: linear-gradient(180deg, #5c6bc0 0%, #3f51b5 100%);
height: 100%;
border-radius: 2px 0 0 2px;
}
.book-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(145deg, #f5f7fa 0%, #e4e8f0 100%);
position: relative;
}
.kb-emoji {
font-size: 48px;
margin-bottom: 16px;
}
.kb-name {
font-weight: bold;
font-size: 18px;
margin-bottom: 12px;
text-align: center;
color: #333;
}
.kb-stats {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 14px;
color: #666;
}
.stat-item {
display: flex;
align-items: center;
gap: 4px;
}
.kb-actions {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.kb-card:hover .kb-actions {
opacity: 1;
}
.emoji-display {
font-size: 64px;
cursor: pointer;
transition: transform 0.2s ease;
display: inline-block;
}
.emoji-display:hover {
transform: scale(1.1);
}
.emoji-sm {
font-size: 24px;
}
.emoji-picker {
max-height: 400px;
overflow-y: auto;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
}
.emoji-item {
font-size: 28px;
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.emoji-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
</style>
@@ -0,0 +1,639 @@
<template>
<div class="document-list-panel">
<!-- 文档统计 -->
<v-row class="mb-4">
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h5 primary--text">{{ documentStats.total }}</div>
<div class="text-caption">{{ tm('stats.totalDocuments') }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h5 success--text">{{ documentStats.chunks }}</div>
<div class="text-caption">{{ tm('stats.totalChunks') }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h5 warning--text">{{ documentStats.media }}</div>
<div class="text-caption">{{ tm('stats.totalMedia') }}</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-card variant="outlined">
<v-card-text class="text-center">
<div class="text-h5 info--text">{{ formatFileSize(documentStats.size) }}</div>
<div class="text-caption">{{ tm('stats.totalSize') }}</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 操作栏 -->
<div class="d-flex justify-space-between align-center mb-4">
<v-btn prepend-icon="mdi-upload" variant="tonal" color="primary" @click="showUploadDialog = true">
{{ tm('actions.upload') }}
</v-btn>
<v-btn prepend-icon="mdi-refresh" variant="text" @click="loadDocuments" :loading="loading">
{{ tm('actions.refresh') }}
</v-btn>
</div>
<!-- 文档列表 -->
<v-data-table
:headers="headers"
:items="documents"
:loading="loading"
items-per-page="10"
class="elevation-1"
>
<template v-slot:item.doc_name="{ item }">
<div class="d-flex align-center">
<v-icon :icon="getFileIcon(item.file_type)" class="mr-2"></v-icon>
<span>{{ item.doc_name }}</span>
</div>
</template>
<template v-slot:item.file_size="{ item }">
{{ formatFileSize(item.file_size) }}
</template>
<template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn variant="text" size="small" prepend-icon="mdi-eye" @click="viewDocument(item)">
{{ tm('actions.view') }}
</v-btn>
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete" @click="confirmDelete(item)">
{{ tm('actions.delete') }}
</v-btn>
</template>
<template v-slot:no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-file-document-outline</v-icon>
<p class="text-medium-emphasis mt-4">{{ tm('empty.noDocuments') }}</p>
</div>
</template>
</v-data-table>
<!-- 文档上传对话框 -->
<v-dialog v-model="showUploadDialog" max-width="600px">
<v-card>
<v-card-title>{{ tm('upload.title') }}</v-card-title>
<v-card-text>
<div class="upload-zone" @click="triggerFileInput" @dragover.prevent @drop.prevent="onFileDrop">
<input type="file" ref="fileInput" style="display: none" @change="onFileSelected" multiple />
<v-icon size="64" color="primary">mdi-cloud-upload</v-icon>
<p class="mt-4">{{ tm('upload.dropzone') }}</p>
<p class="text-caption text-medium-emphasis">{{ tm('upload.supportedFormats') }}</p>
</div>
<div v-if="selectedFiles.length > 0" class="mt-4">
<h4>{{ tm('upload.selectedFiles') }}</h4>
<v-list density="compact">
<v-list-item v-for="(file, index) in selectedFiles" :key="index">
<template v-slot:prepend>
<v-icon :icon="getFileIcon(getFileExtension(file.name))"></v-icon>
</template>
<v-list-item-title>{{ file.name }}</v-list-item-title>
<v-list-item-subtitle>{{ formatFileSize(file.size) }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon variant="text" size="small" @click="removeFile(index)">
<v-icon>mdi-close</v-icon>
</v-btn>
</template>
</v-list-item>
</v-list>
</div>
<div v-if="uploading" class="mt-4">
<v-progress-linear indeterminate color="primary"></v-progress-linear>
<p class="text-center mt-2">{{ tm('upload.uploading') }}</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showUploadDialog = false">{{ tm('upload.cancel') }}</v-btn>
<v-btn color="primary" @click="uploadFiles" :loading="uploading" :disabled="selectedFiles.length === 0">
{{ tm('upload.upload') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 文档详情对话框 -->
<v-dialog v-model="showDetailDialog" max-width="800px" scrollable>
<v-card v-if="currentDocument">
<v-card-title class="d-flex align-center">
<v-icon :icon="getFileIcon(currentDocument.file_type)" class="mr-2"></v-icon>
<span>{{ currentDocument.doc_name }}</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="showDetailDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-tabs v-model="detailTab">
<v-tab value="info">{{ tm('detail.tabs.info') || '基本信息' }}</v-tab>
<v-tab value="chunks">{{ tm('detail.tabs.chunks') || '文档块' }} ({{ currentDocument.chunk_count || 0 }})</v-tab>
<v-tab value="media">{{ tm('detail.tabs.media') || '多媒体' }} ({{ currentDocument.media_count || 0 }})</v-tab>
</v-tabs>
<v-card-text>
<v-window v-model="detailTab">
<!-- 基本信息 Tab -->
<v-window-item value="info">
<v-row class="mb-4">
<v-col cols="6">
<div class="text-caption text-medium-emphasis">{{ tm('detail.fileType') }}</div>
<div>{{ currentDocument.file_type.toUpperCase() }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">{{ tm('detail.fileSize') }}</div>
<div>{{ formatFileSize(currentDocument.file_size) }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">{{ tm('detail.chunks') }}</div>
<div>{{ currentDocument.chunk_count }}</div>
</v-col>
<v-col cols="6">
<div class="text-caption text-medium-emphasis">{{ tm('detail.uploadedAt') }}</div>
<div>{{ formatDate(currentDocument.created_at) }}</div>
</v-col>
</v-row>
</v-window-item>
<!-- 文档块 Tab -->
<v-window-item value="chunks">
<div v-if="loadingChunks" class="text-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="chunks.length === 0" class="text-center py-4 text-medium-emphasis">
{{ tm('detail.noChunks') }}
</div>
<div v-else>
<v-card
v-for="(chunk, index) in chunks"
:key="index"
variant="outlined"
class="mb-2 chunk-card"
>
<v-card-text>
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-caption">{{ tm('detail.chunkIndex', { index: chunk.chunk_index }) }}</span>
<div class="d-flex align-center">
<span class="text-caption mr-2">{{ chunk.char_count }} {{ tm('detail.characters') }}</span>
<v-btn
icon
variant="text"
size="small"
color="error"
@click.stop="confirmDeleteChunk(chunk)"
>
<v-icon size="small">mdi-delete</v-icon>
</v-btn>
</div>
</div>
<div class="chunk-content">{{ truncateText(chunk.content, 200) }}</div>
</v-card-text>
</v-card>
</div>
</v-window-item>
<!-- 多媒体 Tab -->
<v-window-item value="media">
<div v-if="loadingMedia" class="text-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="mediaList.length === 0" class="text-center py-4 text-medium-emphasis">
{{ tm('detail.noMedia') || '暂无多媒体资源' }}
</div>
<v-list v-else density="compact">
<v-list-item v-for="media in mediaList" :key="media.media_id">
<template v-slot:prepend>
<v-icon :icon="getMediaIcon(media.media_type)"></v-icon>
</template>
<v-list-item-title>{{ media.file_name }}</v-list-item-title>
<v-list-item-subtitle>
{{ media.mime_type }} | {{ formatFileSize(media.file_size) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon size="small" variant="text" color="error" @click="confirmDeleteMedia(media)">
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
</v-list-item>
</v-list>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('delete.title') }}</v-card-title>
<v-card-text>
<p>{{ tm('delete.confirmText', { name: deleteTarget.doc_name }) }}</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteDialog = false">{{ tm('delete.cancel') }}</v-btn>
<v-btn color="error" @click="deleteDocument" :loading="deleting">{{ tm('delete.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除块确认对话框 -->
<v-dialog v-model="showDeleteChunkDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('delete.chunkTitle') || '删除文档块' }}</v-card-title>
<v-card-text>
<p>{{ tm('delete.chunkConfirmText', { index: deleteChunkTarget.chunk_index }) || `确定要删除块 #${deleteChunkTarget.chunk_index} 吗?` }}</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteChunkDialog = false">{{ tm('delete.cancel') }}</v-btn>
<v-btn color="error" @click="deleteChunk" :loading="deletingChunk">{{ tm('delete.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除多媒体确认对话框 -->
<v-dialog v-model="showDeleteMediaDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('delete.mediaTitle') || '删除多媒体资源' }}</v-card-title>
<v-card-text>
<p>{{ tm('delete.mediaConfirmText', { name: deleteMediaTarget.file_name }) || `确定要删除 "${deleteMediaTarget.file_name}" 吗?` }}</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteMediaDialog = false">{{ tm('delete.cancel') }}</v-btn>
<v-btn color="error" @click="deleteMedia" :loading="deletingMedia">{{ tm('delete.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'DocumentListPanel',
props: {
kb: {
type: Object,
required: true,
},
},
setup() {
const { tm } = useModuleI18n('features/alkaid/knowledge-base-v2/documents');
return { tm };
},
data() {
return {
documents: [],
chunks: [],
mediaList: [],
loading: false,
loadingChunks: false,
loadingMedia: false,
uploading: false,
deleting: false,
deletingChunk: false,
deletingMedia: false,
showUploadDialog: false,
showDetailDialog: false,
showDeleteDialog: false,
showDeleteChunkDialog: false,
showDeleteMediaDialog: false,
selectedFiles: [],
currentDocument: null,
deleteTarget: {},
deleteChunkTarget: {},
deleteMediaTarget: {},
detailTab: 'info',
documentStats: {
total: 0,
chunks: 0,
media: 0,
size: 0,
},
headers: [
{ title: '文件名', key: 'doc_name', width: '35%' },
{ title: '类型', key: 'file_type', width: '10%' },
{ title: '大小', key: 'file_size', width: '15%' },
{ title: '块数量', key: 'chunk_count', width: '10%' },
{ title: '上传时间', key: 'created_at', width: '15%' },
{ title: '操作', key: 'actions', width: '15%', sortable: false },
],
snackbar: {
show: false,
text: '',
color: 'success',
},
};
},
mounted() {
this.loadDocuments();
},
methods: {
async loadDocuments() {
this.loading = true;
try {
const response = await axios.get('/api/kb/document/list', {
params: { kb_id: this.kb.kb_id },
});
if (response.data.status === 'ok') {
this.documents = response.data.data.items || [];
this.updateStats();
}
} catch (error) {
console.error('Error loading documents:', error);
this.showSnackbar(this.tm('messages.loadError'), 'error');
} finally {
this.loading = false;
}
},
updateStats() {
this.documentStats = {
total: this.documents.length,
chunks: this.documents.reduce((sum, doc) => sum + (doc.chunk_count || 0), 0),
media: this.documents.reduce((sum, doc) => sum + (doc.media_count || 0), 0),
size: this.documents.reduce((sum, doc) => sum + (doc.file_size || 0), 0),
};
},
getFileIcon(fileType) {
const iconMap = {
pdf: 'mdi-file-pdf-box',
docx: 'mdi-file-word-box',
doc: 'mdi-file-word-box',
txt: 'mdi-file-document-outline',
md: 'mdi-language-markdown',
markdown: 'mdi-language-markdown',
};
return iconMap[fileType?.toLowerCase()] || 'mdi-file-outline';
},
getFileExtension(filename) {
return filename.split('.').pop().toLowerCase();
},
formatFileSize(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
formatDate(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
truncateText(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
},
triggerFileInput() {
this.$refs.fileInput.click();
},
onFileSelected(event) {
const files = Array.from(event.target.files);
this.selectedFiles = [...this.selectedFiles, ...files];
},
onFileDrop(event) {
const files = Array.from(event.dataTransfer.files);
this.selectedFiles = [...this.selectedFiles, ...files];
},
removeFile(index) {
this.selectedFiles.splice(index, 1);
},
async uploadFiles() {
if (this.selectedFiles.length === 0) return;
this.uploading = true;
let successCount = 0;
let failCount = 0;
for (const file of this.selectedFiles) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('kb_id', this.kb.kb_id);
const response = await axios.post('/api/kb/document/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
if (response.data.status === 'ok') {
successCount++;
} else {
failCount++;
}
} catch (error) {
console.error('Error uploading file:', error);
failCount++;
}
}
this.uploading = false;
this.selectedFiles = [];
this.showUploadDialog = false;
if (failCount === 0) {
this.showSnackbar(this.tm('messages.uploadSuccess', { count: successCount }));
} else {
this.showSnackbar(
this.tm('messages.uploadPartial', { success: successCount, fail: failCount }),
'warning'
);
}
this.loadDocuments();
},
async viewDocument(document) {
this.currentDocument = document;
this.showDetailDialog = true;
this.detailTab = 'info'; // 重置为基本信息 Tab
this.loadChunks(document.doc_id);
this.loadMedia(document.doc_id);
},
async loadChunks(docId) {
this.loadingChunks = true;
try {
const response = await axios.get('/api/kb/chunk/list', {
params: { doc_id: docId },
});
if (response.data.status === 'ok') {
this.chunks = response.data.data.items || [];
}
} catch (error) {
console.error('Error loading chunks:', error);
} finally {
this.loadingChunks = false;
}
},
async loadMedia(docId) {
this.loadingMedia = true;
try {
const response = await axios.get('/api/kb/media/list', {
params: { doc_id: docId },
});
if (response.data.status === 'ok') {
this.mediaList = response.data.data.items || [];
}
} catch (error) {
console.error('Error loading media:', error);
} finally {
this.loadingMedia = false;
}
},
confirmDelete(document) {
this.deleteTarget = document;
this.showDeleteDialog = true;
},
async deleteDocument() {
this.deleting = true;
try {
const response = await axios.post('/api/kb/document/delete', {
doc_id: this.deleteTarget.doc_id,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.deleteSuccess'));
this.showDeleteDialog = false;
this.loadDocuments();
} else {
this.showSnackbar(response.data.message || this.tm('messages.deleteFailed'), 'error');
}
} catch (error) {
console.error('Error deleting document:', error);
this.showSnackbar(this.tm('messages.deleteError'), 'error');
} finally {
this.deleting = false;
}
},
confirmDeleteChunk(chunk) {
this.deleteChunkTarget = chunk;
this.showDeleteChunkDialog = true;
},
async deleteChunk() {
this.deletingChunk = true;
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: this.deleteChunkTarget.chunk_id,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.chunkDeleteSuccess') || '块删除成功');
this.showDeleteChunkDialog = false;
// 重新加载块列表
if (this.currentDocument) {
await this.loadChunks(this.currentDocument.doc_id);
// 更新文档列表以反映新的块数量
await this.loadDocuments();
}
} else {
this.showSnackbar(response.data.message || this.tm('messages.chunkDeleteFailed') || '块删除失败', 'error');
}
} catch (error) {
console.error('Error deleting chunk:', error);
this.showSnackbar(this.tm('messages.chunkDeleteError') || '块删除出错', 'error');
} finally {
this.deletingChunk = false;
}
},
confirmDeleteMedia(media) {
this.deleteMediaTarget = media;
this.showDeleteMediaDialog = true;
},
async deleteMedia() {
this.deletingMedia = true;
try {
const response = await axios.post('/api/kb/media/delete', {
media_id: this.deleteMediaTarget.media_id,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.mediaDeleteSuccess') || '多媒体删除成功');
this.showDeleteMediaDialog = false;
// 重新加载多媒体列表
if (this.currentDocument) {
await this.loadMedia(this.currentDocument.doc_id);
// 更新文档列表以反映新的多媒体数量
await this.loadDocuments();
}
} else {
this.showSnackbar(response.data.message || this.tm('messages.mediaDeleteFailed') || '多媒体删除失败', 'error');
}
} catch (error) {
console.error('Error deleting media:', error);
this.showSnackbar(this.tm('messages.mediaDeleteError') || '多媒体删除出错', 'error');
} finally {
this.deletingMedia = false;
}
},
getMediaIcon(mediaType) {
const iconMap = {
image: 'mdi-image',
video: 'mdi-video',
audio: 'mdi-music',
};
return iconMap[mediaType] || 'mdi-file';
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
},
};
</script>
<style scoped>
.upload-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.upload-zone:hover {
border-color: #5c6bc0;
background-color: rgba(92, 107, 192, 0.05);
}
.chunk-card {
transition: all 0.2s ease;
}
.chunk-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
}
.chunk-content {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.9rem;
line-height: 1.5;
color: #666;
}
</style>
@@ -0,0 +1,310 @@
<template>
<div class="kb-settings-panel">
<v-form @submit.prevent="saveSettings">
<!-- 基本信息 -->
<v-card variant="outlined" class="mb-4">
<v-card-title>
<v-icon start>mdi-information-outline</v-icon>
{{ tm('basic.title') }}
</v-card-title>
<v-card-text>
<v-text-field
v-model="settings.kb_name"
:label="tm('basic.nameLabel')"
:placeholder="tm('basic.namePlaceholder')"
variant="outlined"
class="mb-2"
required
></v-text-field>
<v-textarea
v-model="settings.description"
:label="tm('basic.descriptionLabel')"
:placeholder="tm('basic.descriptionPlaceholder')"
variant="outlined"
rows="3"
></v-textarea>
</v-card-text>
</v-card>
<!-- 模型配置 -->
<v-card variant="outlined" class="mb-4">
<v-card-title>
<v-icon start>mdi-brain</v-icon>
{{ tm('models.title') }}
</v-card-title>
<v-card-text>
<v-select
v-model="settings.embedding_provider_id"
:items="embeddingProviders"
:label="tm('models.embeddingLabel')"
:hint="tm('models.embeddingHint')"
persistent-hint
variant="outlined"
item-title="embedding_model"
item-value="id"
class="mb-4"
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<v-list-item-subtitle>
Provider ID: {{ item.raw.id }} | 维度: {{ item.raw.embedding_dimensions }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<v-select
v-model="settings.rerank_provider_id"
:items="rerankProviders"
:label="tm('models.rerankLabel')"
:hint="tm('models.rerankHint')"
persistent-hint
variant="outlined"
item-title="rerank_model"
item-value="id"
clearable
>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<v-list-item-subtitle>
Provider ID: {{ item.raw.id }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
</v-card-text>
</v-card>
<!-- 分块参数配置 -->
<v-card variant="outlined" class="mb-4">
<v-card-title>
<v-icon start>mdi-puzzle-outline</v-icon>
{{ tm('chunking.title') }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="settings.chunk_size"
:label="tm('chunking.chunkSizeLabel')"
:hint="tm('chunking.chunkSizeHint')"
persistent-hint
type="number"
variant="outlined"
min="50"
max="2000"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="settings.chunk_overlap"
:label="tm('chunking.chunkOverlapLabel')"
:hint="tm('chunking.chunkOverlapHint')"
persistent-hint
type="number"
variant="outlined"
min="0"
:max="settings.chunk_size ? settings.chunk_size / 2 : 100"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 检索参数配置 -->
<v-card variant="outlined" class="mb-4">
<v-card-title>
<v-icon start>mdi-magnify</v-icon>
{{ tm('retrieval.title') }}
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model.number="settings.top_k_dense"
:label="tm('retrieval.topKDenseLabel')"
:hint="tm('retrieval.topKDenseHint')"
persistent-hint
type="number"
variant="outlined"
min="1"
max="100"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="settings.top_k_sparse"
:label="tm('retrieval.topKSparseLabel')"
:hint="tm('retrieval.topKSparseHint')"
persistent-hint
type="number"
variant="outlined"
min="1"
max="100"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="settings.top_m_final"
:label="tm('retrieval.topMFinalLabel')"
:hint="tm('retrieval.topMFinalHint')"
persistent-hint
type="number"
variant="outlined"
min="1"
max="50"
></v-text-field>
</v-col>
</v-row>
<v-switch
v-model="settings.enable_rerank"
:label="tm('retrieval.enableRerankLabel')"
color="primary"
class="mt-2"
></v-switch>
</v-card-text>
</v-card>
<!-- 操作按钮 -->
<div class="text-center">
<v-btn
color="primary"
type="submit"
:loading="saving"
prepend-icon="mdi-content-save"
size="large"
>
{{ tm('actions.save') }}
</v-btn>
</div>
</v-form>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'KBSettingsPanel',
props: {
kb: {
type: Object,
required: true,
},
},
setup() {
const { tm } = useModuleI18n('features/alkaid/knowledge-base-v2/settings');
return { tm };
},
data() {
return {
settings: {
kb_name: '',
description: '',
embedding_provider_id: null,
rerank_provider_id: null,
chunk_size: 512,
chunk_overlap: 50,
top_k_dense: 50,
top_k_sparse: 50,
top_m_final: 5,
enable_rerank: true,
},
embeddingProviders: [],
rerankProviders: [],
saving: false,
snackbar: {
show: false,
text: '',
color: 'success',
},
};
},
mounted() {
this.loadSettings();
this.loadProviders();
},
methods: {
loadSettings() {
this.settings = {
kb_name: this.kb.kb_name,
description: this.kb.description || '',
embedding_provider_id: this.kb.embedding_provider_id,
rerank_provider_id: this.kb.rerank_provider_id,
chunk_size: this.kb.chunk_size || 512,
chunk_overlap: this.kb.chunk_overlap || 50,
top_k_dense: this.kb.top_k_dense || 50,
top_k_sparse: this.kb.top_k_sparse || 50,
top_m_final: this.kb.top_m_final || 5,
enable_rerank: this.kb.enable_rerank !== undefined ? this.kb.enable_rerank : true,
};
},
async loadProviders() {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' },
});
if (response.data.status === 'ok') {
this.embeddingProviders = response.data.data.filter((p) => p.provider_type === 'embedding');
this.rerankProviders = response.data.data.filter((p) => p.provider_type === 'rerank');
}
} catch (error) {
console.error('Error loading providers:', error);
this.showSnackbar(this.tm('messages.loadProvidersError'), 'error');
}
},
async saveSettings() {
// 表单验证
if (!this.settings.kb_name || !this.settings.kb_name.trim()) {
this.showSnackbar(this.tm('messages.nameRequired'), 'warning');
return;
}
if (!this.settings.embedding_provider_id) {
this.showSnackbar(this.tm('messages.embeddingRequired'), 'warning');
return;
}
this.saving = true;
try {
const response = await axios.post('/api/kb/update', {
kb_id: this.kb.kb_id,
...this.settings,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.saveSuccess'));
this.$emit('updated');
} else {
this.showSnackbar(response.data.message || this.tm('messages.saveFailed'), 'error');
}
} catch (error) {
console.error('Error saving settings:', error);
this.showSnackbar(this.tm('messages.saveError'), 'error');
} finally {
this.saving = false;
}
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
},
};
</script>
<style scoped>
.kb-settings-panel {
max-width: 900px;
margin: 0 auto;
}
</style>
@@ -0,0 +1,194 @@
<template>
<div class="search-panel">
<v-card variant="outlined" class="mb-4">
<v-card-text>
<v-text-field
v-model="searchQuery"
:label="tm('search.queryLabel')"
:placeholder="tm('search.queryPlaceholder')"
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
@keyup.enter="performSearch"
></v-text-field>
<v-row class="mt-2">
<v-col cols="12" md="6">
<v-select
v-model="topK"
:items="[3, 5, 10, 20]"
:label="tm('search.topKLabel')"
variant="outlined"
density="compact"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="enableRerank"
:label="tm('search.enableRerankLabel')"
color="primary"
hide-details
></v-switch>
</v-col>
</v-row>
<div class="text-center mt-4">
<v-btn
color="primary"
prepend-icon="mdi-magnify"
@click="performSearch"
:loading="searching"
:disabled="!searchQuery"
>
{{ tm('search.search') }}
</v-btn>
</div>
</v-card-text>
</v-card>
<!-- 搜索结果 -->
<div v-if="searching" class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<p class="mt-4">{{ tm('search.searching') }}</p>
</div>
<div v-else-if="searchPerformed && searchResults.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-file-search-outline</v-icon>
<p class="text-medium-emphasis mt-4">{{ tm('search.noResults') }}</p>
</div>
<div v-else-if="searchResults.length > 0">
<div class="d-flex justify-space-between align-center mb-4">
<h4>{{ tm('search.resultsTitle', { count: searchResults.length }) }}</h4>
<small class="text-medium-emphasis">{{ tm('search.searchTime', { time: searchTime }) }}</small>
</div>
<v-card
v-for="(result, index) in searchResults"
:key="index"
variant="outlined"
class="mb-3 result-card"
>
<v-card-text>
<div class="d-flex justify-space-between align-center mb-2">
<div class="d-flex align-center">
<v-icon size="small" color="primary" class="mr-1">mdi-file-document-outline</v-icon>
<span class="text-caption">{{ result.doc_name || result.metadata?.source }}</span>
</div>
<v-chip v-if="result.score" size="small" color="primary" variant="tonal">
{{ tm('search.relevance') }}: {{ Math.round(result.score * 100) }}%
</v-chip>
</div>
<div class="result-content">{{ result.content }}</div>
<div class="text-caption text-medium-emphasis mt-2">
{{ tm('search.chunkInfo', { index: result.metadata?.chunk_index, chars: result.char_count }) }}
</div>
</v-card-text>
</v-card>
</div>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'SearchPanel',
props: {
kb: {
type: Object,
required: true,
},
},
setup() {
const { tm } = useModuleI18n('features/alkaid/knowledge-base-v2/search');
return { tm };
},
data() {
return {
searchQuery: '',
topK: 5,
enableRerank: true,
searching: false,
searchPerformed: false,
searchResults: [],
searchTime: 0,
snackbar: {
show: false,
text: '',
color: 'success',
},
};
},
methods: {
async performSearch() {
if (!this.searchQuery || !this.searchQuery.trim()) {
this.showSnackbar(this.tm('messages.queryRequired'), 'warning');
return;
}
this.searching = true;
this.searchPerformed = true;
const startTime = Date.now();
try {
const response = await axios.post('/api/kb/retrieve', {
query: this.searchQuery,
kb_ids: [this.kb.kb_id],
top_k: this.topK,
enable_rerank: this.enableRerank,
});
if (response.data.status === 'ok') {
this.searchResults = response.data.data.results || [];
this.searchTime = ((Date.now() - startTime) / 1000).toFixed(2);
if (this.searchResults.length === 0) {
this.showSnackbar(this.tm('messages.noResults'), 'info');
}
} else {
this.showSnackbar(response.data.message || this.tm('messages.searchFailed'), 'error');
}
} catch (error) {
console.error('Error searching knowledge base:', error);
this.showSnackbar(this.tm('messages.searchError'), 'error');
} finally {
this.searching = false;
}
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
},
};
</script>
<style scoped>
.result-card {
transition: all 0.2s ease;
}
.result-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
}
.result-content {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.95rem;
line-height: 1.6;
padding: 12px;
background-color: rgba(0, 0, 0, 0.02);
border-radius: 4px;
max-height: 300px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,510 @@
<template>
<div class="session-config-panel">
<!-- 说明提示 -->
<v-alert type="info" variant="tonal" class="mb-4" border="start">
<div class="text-subtitle-2 mb-2">
<v-icon start>mdi-information-outline</v-icon>
{{ tm('info.title') }}
</div>
<p class="text-caption">{{ tm('info.description') }}</p>
<ul class="text-caption mt-2">
<li>{{ tm('info.platformLevel') }}</li>
<li>{{ tm('info.sessionLevel') }}</li>
</ul>
</v-alert>
<!-- 操作按钮组 -->
<div class="d-flex justify-space-between align-center mb-4">
<h3>{{ tm('list.title') }}</h3>
<div class="d-flex gap-2">
<v-btn
prepend-icon="mdi-refresh"
variant="text"
@click="loadConfigs"
:loading="loading"
>
{{ tm('list.refresh') }}
</v-btn>
<v-btn
prepend-icon="mdi-plus"
variant="tonal"
color="primary"
@click="openCreateDialog"
>
{{ tm('list.add') }}
</v-btn>
</div>
</div>
<!-- 配置列表 -->
<v-data-table
:headers="headers"
:items="configs"
:loading="loading"
items-per-page="10"
class="elevation-1"
>
<template v-slot:item.scope="{ item }">
<v-chip
:color="item.scope === 'platform' ? 'primary' : 'secondary'"
size="small"
variant="tonal"
>
<v-icon start size="small">
{{ item.scope === 'platform' ? 'mdi-desktop-classic' : 'mdi-chat' }}
</v-icon>
{{ item.scope === 'platform' ? tm('scope.platform') : tm('scope.session') }}
</v-chip>
</template>
<template v-slot:item.scope_id="{ item }">
<code class="scope-id-code">{{ item.scope_id }}</code>
</template>
<template v-slot:item.kb_ids="{ item }">
<div class="kb-list">
<v-chip
v-for="kbId in (item.kb_ids || [])"
:key="kbId"
size="small"
variant="outlined"
class="mr-1 mb-1"
>
<v-icon start size="small">mdi-book-open-variant</v-icon>
{{ getKBName(kbId) }}
</v-chip>
<span v-if="!item.kb_ids || item.kb_ids.length === 0" class="text-medium-emphasis">
{{ tm('list.noKB') }}
</span>
</div>
</template>
<template v-slot:item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template v-slot:item.actions="{ item }">
<v-btn
variant="text"
size="small"
prepend-icon="mdi-pencil"
@click="editConfig(item)"
>
{{ tm('actions.edit') }}
</v-btn>
<v-btn
variant="text"
size="small"
color="error"
prepend-icon="mdi-delete"
@click="confirmDelete(item)"
>
{{ tm('actions.delete') }}
</v-btn>
</template>
<template v-slot:no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-cog-outline</v-icon>
<p class="text-medium-emphasis mt-4">{{ tm('empty.noConfigs') }}</p>
<v-btn
class="mt-2"
variant="tonal"
color="primary"
prepend-icon="mdi-plus"
@click="openCreateDialog"
>
{{ tm('empty.createFirst') }}
</v-btn>
</div>
</template>
</v-data-table>
<!-- 新增/编辑配置对话框 -->
<v-dialog v-model="showConfigDialog" max-width="700px" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon start>mdi-cog</v-icon>
<span>{{ editingConfig ? tm('dialog.editTitle') : tm('dialog.addTitle') }}</span>
<v-spacer></v-spacer>
<v-btn variant="plain" icon @click="closeConfigDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-form ref="configForm" @submit.prevent="saveConfig">
<!-- 配置范围选择 -->
<v-radio-group
v-model="configForm.scope"
:label="tm('dialog.scopeLabel')"
class="mb-2"
>
<v-radio :label="tm('scope.platform')" value="platform">
<template v-slot:label>
<div class="d-flex align-center">
<v-icon start>mdi-desktop-classic</v-icon>
<span>{{ tm('scope.platform') }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" class="ml-2">
mdi-information-outline
</v-icon>
</template>
<span>{{ tm('dialog.platformTooltip') }}</span>
</v-tooltip>
</div>
</template>
</v-radio>
<v-radio :label="tm('scope.session')" value="session">
<template v-slot:label>
<div class="d-flex align-center">
<v-icon start>mdi-chat</v-icon>
<span>{{ tm('scope.session') }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" class="ml-2">
mdi-information-outline
</v-icon>
</template>
<span>{{ tm('dialog.sessionTooltip') }}</span>
</v-tooltip>
</div>
</template>
</v-radio>
</v-radio-group>
<!-- 标识输入 -->
<v-text-field
v-model="configForm.scope_id"
:label="configForm.scope === 'platform' ? tm('dialog.platformIdLabel') : tm('dialog.sessionIdLabel')"
:placeholder="configForm.scope === 'platform' ? tm('dialog.platformIdPlaceholder') : tm('dialog.sessionIdPlaceholder')"
:hint="configForm.scope === 'platform' ? tm('dialog.platformIdHint') : tm('dialog.sessionIdHint')"
persistent-hint
variant="outlined"
class="mb-4"
required
></v-text-field>
<!-- 知识库选择 -->
<v-select
v-model="configForm.kb_ids"
:items="availableKBs"
item-title="kb_name"
item-value="kb_id"
:label="tm('dialog.kbLabel')"
:placeholder="tm('dialog.kbPlaceholder')"
:hint="tm('dialog.kbHint')"
persistent-hint
variant="outlined"
multiple
chips
closable-chips
class="mb-2"
required
>
<template v-slot:chip="{ props, item }">
<v-chip v-bind="props" :text="item.raw.kb_name">
<template v-slot:prepend>
<span class="mr-1">{{ item.raw.emoji || '📚' }}</span>
</template>
</v-chip>
</template>
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:prepend>
<span class="emoji-icon">{{ item.raw.emoji || '📚' }}</span>
</template>
<v-list-item-subtitle>
{{ item.raw.doc_count || 0 }} 个文档 | {{ item.raw.chunk_count || 0 }} 个块
</v-list-item-subtitle>
</v-list-item>
</template>
</v-select>
<!-- 检索参数可选 -->
<v-expansion-panels class="mt-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-tune</v-icon>
{{ tm('dialog.advancedSettings') }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="configForm.top_k"
:label="tm('dialog.topKLabel')"
:hint="tm('dialog.topKHint')"
persistent-hint
type="number"
variant="outlined"
min="1"
max="50"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-switch
v-model="configForm.enable_rerank"
:label="tm('dialog.enableRerankLabel')"
color="primary"
></v-switch>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="closeConfigDialog">{{ tm('dialog.cancel') }}</v-btn>
<v-btn
color="primary"
@click="saveConfig"
:loading="saving"
>
{{ tm('dialog.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="text-h5">{{ tm('delete.title') }}</v-card-title>
<v-card-text>
<p>{{ tm('delete.confirmText') }}</p>
<v-alert type="warning" variant="tonal" class="mt-4" density="compact">
{{ tm('delete.warning') }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="showDeleteDialog = false">{{ tm('delete.cancel') }}</v-btn>
<v-btn color="error" @click="deleteConfig" :loading="deleting">
{{ tm('delete.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
export default {
name: 'SessionConfigPanel',
setup() {
const { tm } = useModuleI18n('features/alkaid/knowledge-base-v2/session-config');
return { tm };
},
data() {
return {
configs: [],
availableKBs: [],
loading: false,
saving: false,
deleting: false,
showConfigDialog: false,
showDeleteDialog: false,
editingConfig: null,
deleteTarget: null,
configForm: {
scope: 'session',
scope_id: '',
kb_ids: [],
top_k: 5,
enable_rerank: true,
},
headers: [
{ title: '范围', key: 'scope', width: '12%' },
{ title: '标识', key: 'scope_id', width: '23%' },
{ title: '关联知识库', key: 'kb_ids', width: '35%' },
{ title: '创建时间', key: 'created_at', width: '15%' },
{ title: '操作', key: 'actions', width: '15%', sortable: false },
],
snackbar: {
show: false,
text: '',
color: 'success',
},
};
},
mounted() {
this.loadConfigs();
this.loadAvailableKBs();
},
methods: {
async loadConfigs() {
this.loading = true;
try {
const response = await axios.get('/api/kb/session/config/list');
if (response.data.status === 'ok') {
this.configs = response.data.data.items || [];
} else {
this.showSnackbar(response.data.message || this.tm('messages.loadFailed'), 'error');
}
} catch (error) {
console.error('Error loading configs:', error);
this.showSnackbar(this.tm('messages.loadError'), 'error');
} finally {
this.loading = false;
}
},
async loadAvailableKBs() {
try {
const response = await axios.get('/api/kb/list');
if (response.data.status === 'ok') {
this.availableKBs = response.data.data.items || [];
}
} catch (error) {
console.error('Error loading KBs:', error);
}
},
getKBName(kbId) {
const kb = this.availableKBs.find((k) => k.kb_id === kbId);
return kb ? kb.kb_name : kbId;
},
formatDate(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleString();
},
openCreateDialog() {
this.editingConfig = null;
this.resetForm();
this.showConfigDialog = true;
},
editConfig(config) {
this.editingConfig = config;
this.configForm = {
scope: config.scope,
scope_id: config.scope_id,
kb_ids: config.kb_ids || [],
top_k: config.top_k || 5,
enable_rerank: config.enable_rerank !== undefined ? config.enable_rerank : true,
};
this.showConfigDialog = true;
},
closeConfigDialog() {
this.showConfigDialog = false;
this.resetForm();
},
async saveConfig() {
// 表单验证
if (!this.configForm.scope_id || !this.configForm.scope_id.trim()) {
this.showSnackbar(this.tm('messages.scopeIdRequired'), 'warning');
return;
}
if (!this.configForm.kb_ids || this.configForm.kb_ids.length === 0) {
this.showSnackbar(this.tm('messages.kbIdsRequired'), 'warning');
return;
}
this.saving = true;
try {
const payload = {
scope: this.configForm.scope,
scope_id: this.configForm.scope_id,
kb_ids: this.configForm.kb_ids,
top_k: this.configForm.top_k,
enable_rerank: this.configForm.enable_rerank,
};
const response = await axios.post('/api/kb/session/config/set', payload);
if (response.data.status === 'ok') {
this.showSnackbar(
this.editingConfig ? this.tm('messages.updateSuccess') : this.tm('messages.createSuccess')
);
this.closeConfigDialog();
this.loadConfigs();
} else {
this.showSnackbar(response.data.message || this.tm('messages.saveFailed'), 'error');
}
} catch (error) {
console.error('Error saving config:', error);
this.showSnackbar(this.tm('messages.saveError'), 'error');
} finally {
this.saving = false;
}
},
confirmDelete(config) {
this.deleteTarget = config;
this.showDeleteDialog = true;
},
async deleteConfig() {
if (!this.deleteTarget) return;
this.deleting = true;
try {
const response = await axios.post('/api/kb/session/config/delete', {
scope: this.deleteTarget.scope,
scope_id: this.deleteTarget.scope_id,
});
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.deleteSuccess'));
this.showDeleteDialog = false;
this.loadConfigs();
} else {
this.showSnackbar(response.data.message || this.tm('messages.deleteFailed'), 'error');
}
} catch (error) {
console.error('Error deleting config:', error);
this.showSnackbar(this.tm('messages.deleteError'), 'error');
} finally {
this.deleting = false;
}
},
resetForm() {
this.editingConfig = null;
this.configForm = {
scope: 'session',
scope_id: '',
kb_ids: [],
top_k: 5,
enable_rerank: true,
};
},
showSnackbar(text, color = 'success') {
this.snackbar.text = text;
this.snackbar.color = color;
this.snackbar.show = true;
},
},
};
</script>
<style scoped>
.kb-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 400px;
}
.scope-id-code {
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 8px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
}
.emoji-icon {
font-size: 24px;
margin-right: 8px;
}
</style>