feat: 实现知识库前端组件和路由
- 实现知识库 V2 主页面和 4 个子面板组件 - 文档管理面板:支持上传、删除、查看文档分块 - 检索测试面板:支持测试知识库检索效果 - 全局设置面板:配置嵌入模型、重排序、检索参数 - 会话配置面板:管理会话与知识库的绑定关系 - 重构 Alkaid 路由为嵌套结构,添加知识库 V2 路由 - 在翻译系统中注册知识库 V2 多语言支持 - 默认进入 Alkaid 时跳转到原生知识库页面
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user