feat:添加Beta 版本的知识库管理器前端页面;添加i18n相关文件内容。

This commit is contained in:
lxfight
2025-10-19 21:55:21 +08:00
parent beccae933f
commit b240594859
21 changed files with 3207 additions and 43 deletions
@@ -12,6 +12,7 @@
"console": "Console",
"alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base",
"knowledgeBaseBeta": "Knowledge Base (Beta)",
"about": "About",
"settings": "Settings",
"documentation": "Documentation",
@@ -6,13 +6,11 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBaseV2": "Native Knowledge Base",
"knowledgeBase": "Knowledge Base (Plugin)",
"longTermMemory": "Long-term Memory",
"other": "..."
}
},
"knowledgeBaseV2": "Native Knowledge Base",
"features": {
"knowledgeBase": "Knowledge Base",
"longTermMemory": "Long-term Memory",
@@ -0,0 +1,96 @@
{
"title": "Knowledge Base Details",
"backToList": "Back to List",
"tabs": {
"overview": "Overview",
"documents": "Documents",
"sessions": "Sessions",
"settings": "Settings"
},
"overview": {
"title": "Basic Information",
"name": "Name",
"description": "Description",
"emoji": "Icon",
"createdAt": "Created At",
"updatedAt": "Updated At",
"stats": "Statistics",
"docCount": "Documents",
"chunkCount": "Chunks",
"embeddingModel": "Embedding Model",
"rerankModel": "Rerank Model",
"notSet": "Not Set"
},
"documents": {
"title": "Documents",
"upload": "Upload Document",
"empty": "No documents",
"name": "Name",
"type": "Type",
"size": "Size",
"chunks": "Chunks",
"createdAt": "Uploaded At",
"actions": "Actions",
"view": "View",
"delete": "Delete",
"deleteConfirm": "Are you sure you want to delete document '{name}'?",
"deleteWarning": "This will delete the document and all its chunks. This action cannot be undone.",
"uploading": "Uploading...",
"uploadSuccess": "Document uploaded successfully",
"uploadFailed": "Failed to upload document",
"deleteSuccess": "Document deleted successfully",
"deleteFailed": "Failed to delete document"
},
"upload": {
"title": "Upload Document",
"selectFile": "Select File",
"dropzone": "Drop files here or click to select",
"supportedFormats": "Supported formats: TXT, PDF, Markdown",
"maxSize": "Max file size: 50MB",
"chunkSettings": "Chunk Settings",
"chunkSize": "Chunk Size",
"chunkSizeHint": "Number of characters per chunk (default: 512)",
"chunkOverlap": "Chunk Overlap",
"chunkOverlapHint": "Overlapping characters between chunks (default: 50)",
"cancel": "Cancel",
"submit": "Upload",
"fileRequired": "Please select a file to upload"
},
"sessions": {
"title": "Session Configuration",
"subtitle": "Configure which sessions can use this knowledge base",
"empty": "No session configurations",
"add": "Add Configuration",
"scope": "Scope",
"scopeId": "Identifier",
"topK": "Top K Results",
"enableRerank": "Enable Rerank",
"actions": "Actions",
"edit": "Edit",
"delete": "Delete",
"scopeSession": "Session Level",
"scopePlatform": "Platform Level",
"deleteConfirm": "Are you sure you want to delete this configuration?",
"addSuccess": "Configuration added successfully",
"addFailed": "Failed to add configuration",
"deleteSuccess": "Configuration deleted successfully",
"deleteFailed": "Failed to delete configuration"
},
"settings": {
"title": "Knowledge Base Settings",
"basic": "Basic Settings",
"retrieval": "Retrieval Settings",
"chunkSize": "Chunk Size",
"chunkOverlap": "Chunk Overlap",
"topKDense": "Dense Retrieval Count",
"topKSparse": "Sparse Retrieval Count",
"topMFinal": "Final Result Count",
"enableRerank": "Enable Rerank",
"embeddingProvider": "Embedding Provider",
"rerankProvider": "Rerank Provider",
"save": "Save Settings",
"saveSuccess": "Settings saved successfully",
"saveFailed": "Failed to save settings",
"tips": "Tip: Modifying retrieval settings will affect subsequent knowledge base queries."
}
}
@@ -0,0 +1,51 @@
{
"title": "Document Details",
"backToKB": "Back to Knowledge Base",
"info": {
"title": "Document Information",
"name": "Document Name",
"type": "File Type",
"size": "File Size",
"chunkCount": "Chunk Count",
"createdAt": "Uploaded At"
},
"chunks": {
"title": "Chunks",
"empty": "No chunks",
"index": "Index",
"content": "Content",
"charCount": "Characters",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"preview": "Preview",
"search": "Search Chunks",
"searchPlaceholder": "Enter keywords to search chunks..."
},
"edit": {
"title": "Edit Chunk",
"content": "Chunk Content",
"cancel": "Cancel",
"save": "Save",
"saveSuccess": "Chunk saved successfully",
"saveFailed": "Failed to save chunk"
},
"delete": {
"title": "Delete Chunk",
"confirmText": "Are you sure you want to delete this chunk?",
"warning": "This action cannot be undone and may affect knowledge base retrieval performance.",
"cancel": "Cancel",
"confirm": "Delete",
"deleteSuccess": "Chunk deleted successfully",
"deleteFailed": "Failed to delete chunk"
},
"view": {
"title": "Chunk Details",
"index": "Index",
"content": "Content",
"charCount": "Characters",
"vecDocId": "Vector ID",
"close": "Close"
}
}
@@ -0,0 +1,68 @@
{
"title": "Knowledge Base Management",
"subtitle": "Manage and query knowledge base contents",
"list": {
"title": "My Knowledge Bases",
"subtitle": "Manage all your knowledge base collections",
"create": "Create Knowledge Base",
"refresh": "Refresh List",
"empty": "No knowledge bases",
"loading": "Loading...",
"documents": "Documents",
"chunks": "Chunks",
"sessionConfig": "Session Config"
},
"card": {
"edit": "Edit",
"delete": "Delete",
"open": "Open",
"docCount": "{count} Documents",
"chunkCount": "{count} Chunks"
},
"create": {
"title": "Create Knowledge Base",
"nameLabel": "Name",
"namePlaceholder": "Enter knowledge base name",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Describe the purpose of this knowledge base...",
"emojiLabel": "Icon",
"embeddingModelLabel": "Embedding Model",
"rerankModelLabel": "Rerank Model (Optional)",
"providerInfo": "Provider: {id} | Dimensions: {dimensions}",
"rerankProviderInfo": "Provider: {id}",
"tips": "Tip: Once you select an embedding model, do not modify the model or vector dimensions, as this will severely affect recall rate.",
"cancel": "Cancel",
"submit": "Create",
"nameRequired": "Please enter knowledge base name"
},
"edit": {
"title": "Edit Knowledge Base",
"submit": "Save"
},
"delete": {
"title": "Delete Knowledge Base",
"confirmText": "Are you sure you want to delete knowledge base '{name}'?",
"warning": "This action is irreversible. All documents, chunks, and associated configurations will be permanently deleted.",
"cancel": "Cancel",
"confirm": "Delete"
},
"emoji": {
"title": "Select Icon",
"close": "Close",
"categories": {
"books": "Books & Documents",
"emotions": "Emotions & Faces",
"objects": "Objects & Tools",
"symbols": "Symbols & Signs"
}
},
"messages": {
"createSuccess": "Knowledge base created successfully",
"createFailed": "Failed to create",
"updateSuccess": "Knowledge base updated successfully",
"updateFailed": "Failed to update",
"deleteSuccess": "Knowledge base deleted successfully",
"deleteFailed": "Failed to delete",
"loadError": "Failed to load knowledge base list"
}
}
@@ -12,6 +12,7 @@
"console": "控制台",
"alkaid": "Alkaid",
"knowledgeBase": "知识库",
"knowledgeBaseBeta": "知识库 (Beta)",
"about": "关于",
"settings": "设置",
"documentation": "官方文档",
@@ -6,13 +6,11 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha 项目",
"navigation": {
"knowledgeBaseV2": "原生知识库",
"knowledgeBase": "知识库 (插件)",
"longTermMemory": "长期记忆层",
"other": "..."
}
},
"knowledgeBaseV2": "原生知识库",
"features": {
"knowledgeBase": "知识库",
"longTermMemory": "长期记忆",
@@ -0,0 +1,96 @@
{
"title": "知识库详情",
"backToList": "返回列表",
"tabs": {
"overview": "概览",
"documents": "文档管理",
"sessions": "会话配置",
"settings": "设置"
},
"overview": {
"title": "基本信息",
"name": "名称",
"description": "描述",
"emoji": "图标",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"stats": "统计信息",
"docCount": "文档数量",
"chunkCount": "分块数量",
"embeddingModel": "嵌入模型",
"rerankModel": "重排序模型",
"notSet": "未设置"
},
"documents": {
"title": "文档列表",
"upload": "上传文档",
"empty": "暂无文档",
"name": "文档名称",
"type": "类型",
"size": "大小",
"chunks": "分块数",
"createdAt": "上传时间",
"actions": "操作",
"view": "查看",
"delete": "删除",
"deleteConfirm": "确定要删除文档「{name}」吗?",
"deleteWarning": "此操作将删除文档及其所有分块,不可恢复。",
"uploading": "正在上传...",
"uploadSuccess": "文档上传成功",
"uploadFailed": "文档上传失败",
"deleteSuccess": "文档删除成功",
"deleteFailed": "文档删除失败"
},
"upload": {
"title": "上传文档",
"selectFile": "选择文件",
"dropzone": "拖放文件到这里或点击选择",
"supportedFormats": "支持的格式: TXT, PDF, Markdown",
"maxSize": "最大文件大小: 50MB",
"chunkSettings": "分块设置",
"chunkSize": "分块大小",
"chunkSizeHint": "每个文本块的字符数 (默认: 512)",
"chunkOverlap": "分块重叠",
"chunkOverlapHint": "相邻文本块之间的重叠字符数 (默认: 50)",
"cancel": "取消",
"submit": "上传",
"fileRequired": "请选择要上传的文件"
},
"sessions": {
"title": "会话配置",
"subtitle": "配置哪些会话可以使用此知识库",
"empty": "暂无会话配置",
"add": "添加配置",
"scope": "范围",
"scopeId": "标识",
"topK": "返回结果数",
"enableRerank": "启用重排序",
"actions": "操作",
"edit": "编辑",
"delete": "删除",
"scopeSession": "会话级别",
"scopePlatform": "平台级别",
"deleteConfirm": "确定要删除此配置吗?",
"addSuccess": "配置添加成功",
"addFailed": "配置添加失败",
"deleteSuccess": "配置删除成功",
"deleteFailed": "配置删除失败"
},
"settings": {
"title": "知识库设置",
"basic": "基本设置",
"retrieval": "检索设置",
"chunkSize": "分块大小",
"chunkOverlap": "分块重叠",
"topKDense": "稠密检索数量",
"topKSparse": "稀疏检索数量",
"topMFinal": "最终返回数量",
"enableRerank": "启用重排序",
"embeddingProvider": "嵌入模型提供商",
"rerankProvider": "重排序模型提供商",
"save": "保存设置",
"saveSuccess": "设置保存成功",
"saveFailed": "设置保存失败",
"tips": "提示: 修改检索设置后,将影响后续的知识库查询效果。"
}
}
@@ -0,0 +1,51 @@
{
"title": "文档详情",
"backToKB": "返回知识库",
"info": {
"title": "文档信息",
"name": "文档名称",
"type": "文件类型",
"size": "文件大小",
"chunkCount": "分块数量",
"createdAt": "上传时间"
},
"chunks": {
"title": "分块列表",
"empty": "暂无分块",
"index": "序号",
"content": "内容",
"charCount": "字符数",
"actions": "操作",
"view": "查看",
"edit": "编辑",
"delete": "删除",
"preview": "预览",
"search": "搜索分块",
"searchPlaceholder": "输入关键词搜索分块内容..."
},
"edit": {
"title": "编辑分块",
"content": "分块内容",
"cancel": "取消",
"save": "保存",
"saveSuccess": "分块保存成功",
"saveFailed": "分块保存失败"
},
"delete": {
"title": "删除分块",
"confirmText": "确定要删除此分块吗?",
"warning": "删除后将无法恢复,可能影响知识库检索效果。",
"cancel": "取消",
"confirm": "删除",
"deleteSuccess": "分块删除成功",
"deleteFailed": "分块删除失败"
},
"view": {
"title": "分块详情",
"index": "序号",
"content": "内容",
"charCount": "字符数",
"vecDocId": "向量ID",
"close": "关闭"
}
}
@@ -0,0 +1,68 @@
{
"title": "知识库管理",
"subtitle": "统一管理和查询知识库内容",
"list": {
"title": "我的知识库",
"subtitle": "管理您的所有知识库集合",
"create": "创建知识库",
"refresh": "刷新列表",
"empty": "暂无知识库",
"loading": "正在加载...",
"documents": "文档",
"chunks": "分块",
"sessionConfig": "会话配置"
},
"card": {
"edit": "编辑",
"delete": "删除",
"open": "打开",
"docCount": "{count} 个文档",
"chunkCount": "{count} 个分块"
},
"create": {
"title": "创建知识库",
"nameLabel": "知识库名称",
"namePlaceholder": "为知识库起个名字",
"descriptionLabel": "描述",
"descriptionPlaceholder": "简单描述这个知识库的用途...",
"emojiLabel": "图标",
"embeddingModelLabel": "嵌入模型 (Embedding Model)",
"rerankModelLabel": "重排序模型 (Rerank Model, 可选)",
"providerInfo": "提供商: {id} | 维度: {dimensions}",
"rerankProviderInfo": "提供商: {id}",
"tips": "提示: 一旦选择了嵌入模型,请不要修改该提供商的模型或向量维度,否则将严重影响召回率。",
"cancel": "取消",
"submit": "创建",
"nameRequired": "请输入知识库名称"
},
"edit": {
"title": "编辑知识库",
"submit": "保存"
},
"delete": {
"title": "删除知识库",
"confirmText": "确定要删除知识库「{name}」吗?",
"warning": "此操作不可逆,所有文档、分块和关联配置都将被永久删除。",
"cancel": "取消",
"confirm": "删除"
},
"emoji": {
"title": "选择图标",
"close": "关闭",
"categories": {
"books": "书籍与文档",
"emotions": "表情与情感",
"objects": "物品与工具",
"symbols": "符号与标志"
}
},
"messages": {
"createSuccess": "知识库创建成功",
"createFailed": "创建失败",
"updateSuccess": "知识库更新成功",
"updateFailed": "更新失败",
"deleteSuccess": "知识库删除成功",
"deleteFailed": "删除失败",
"loadError": "加载知识库列表失败"
}
}
+18 -26
View File
@@ -25,11 +25,9 @@ import zhCNDashboard from './locales/zh-CN/features/dashboard.json';
import zhCNAlkaidIndex from './locales/zh-CN/features/alkaid/index.json';
import zhCNAlkaidKnowledgeBase from './locales/zh-CN/features/alkaid/knowledge-base.json';
import zhCNAlkaidMemory from './locales/zh-CN/features/alkaid/memory.json';
import zhCNAlkaidKBV2Index from './locales/zh-CN/features/alkaid/knowledge-base-v2/index.json';
import zhCNAlkaidKBV2Documents from './locales/zh-CN/features/alkaid/knowledge-base-v2/documents.json';
import zhCNAlkaidKBV2Search from './locales/zh-CN/features/alkaid/knowledge-base-v2/search.json';
import zhCNAlkaidKBV2Settings from './locales/zh-CN/features/alkaid/knowledge-base-v2/settings.json';
import zhCNAlkaidKBV2SessionConfig from './locales/zh-CN/features/alkaid/knowledge-base-v2/session-config.json';
import zhCNKnowledgeBaseIndex from './locales/zh-CN/features/knowledge-base/index.json';
import zhCNKnowledgeBaseDetail from './locales/zh-CN/features/knowledge-base/detail.json';
import zhCNKnowledgeBaseDocument from './locales/zh-CN/features/knowledge-base/document.json';
import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
@@ -61,11 +59,9 @@ import enUSDashboard from './locales/en-US/features/dashboard.json';
import enUSAlkaidIndex from './locales/en-US/features/alkaid/index.json';
import enUSAlkaidKnowledgeBase from './locales/en-US/features/alkaid/knowledge-base.json';
import enUSAlkaidMemory from './locales/en-US/features/alkaid/memory.json';
import enUSAlkaidKBV2Index from './locales/en-US/features/alkaid/knowledge-base-v2/index.json';
import enUSAlkaidKBV2Documents from './locales/en-US/features/alkaid/knowledge-base-v2/documents.json';
import enUSAlkaidKBV2Search from './locales/en-US/features/alkaid/knowledge-base-v2/search.json';
import enUSAlkaidKBV2Settings from './locales/en-US/features/alkaid/knowledge-base-v2/settings.json';
import enUSAlkaidKBV2SessionConfig from './locales/en-US/features/alkaid/knowledge-base-v2/session-config.json';
import enUSKnowledgeBaseIndex from './locales/en-US/features/knowledge-base/index.json';
import enUSKnowledgeBaseDetail from './locales/en-US/features/knowledge-base/detail.json';
import enUSKnowledgeBaseDocument from './locales/en-US/features/knowledge-base/document.json';
import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
@@ -101,14 +97,12 @@ export const translations = {
alkaid: {
index: zhCNAlkaidIndex,
'knowledge-base': zhCNAlkaidKnowledgeBase,
memory: zhCNAlkaidMemory,
'knowledge-base-v2': {
index: zhCNAlkaidKBV2Index,
documents: zhCNAlkaidKBV2Documents,
search: zhCNAlkaidKBV2Search,
settings: zhCNAlkaidKBV2Settings,
'session-config': zhCNAlkaidKBV2SessionConfig
}
memory: zhCNAlkaidMemory
},
'knowledge-base': {
index: zhCNKnowledgeBaseIndex,
detail: zhCNKnowledgeBaseDetail,
document: zhCNKnowledgeBaseDocument
},
persona: zhCNPersona,
migration: zhCNMigration
@@ -145,14 +139,12 @@ export const translations = {
alkaid: {
index: enUSAlkaidIndex,
'knowledge-base': enUSAlkaidKnowledgeBase,
memory: enUSAlkaidMemory,
'knowledge-base-v2': {
index: enUSAlkaidKBV2Index,
documents: enUSAlkaidKBV2Documents,
search: enUSAlkaidKBV2Search,
settings: enUSAlkaidKBV2Settings,
'session-config': enUSAlkaidKBV2SessionConfig
}
memory: enUSAlkaidMemory
},
'knowledge-base': {
index: enUSKnowledgeBaseIndex,
detail: enUSKnowledgeBaseDetail,
document: enUSKnowledgeBaseDocument
},
persona: enUSPersona,
migration: enUSMigration
@@ -53,6 +53,14 @@ const sidebarItem: menu[] = [
icon: 'mdi-text-box-search',
to: '/alkaid/knowledge-base',
},
{
title: 'core.navigation.knowledgeBaseBeta',
icon: 'mdi-book-open-variant',
to: '/knowledge-base',
chip: 'Beta',
chipColor: 'primary',
chipVariant: 'tonal',
},
{
title: 'core.navigation.config',
icon: 'mdi-cog',
+24 -5
View File
@@ -66,6 +66,30 @@ const MainRoutes = {
path: '/console',
component: () => import('@/views/ConsolePage.vue')
},
{
name: 'NativeKnowledgeBase',
path: '/knowledge-base',
component: () => import('@/views/knowledge-base/index.vue'),
children: [
{
path: '',
name: 'NativeKBList',
component: () => import('@/views/knowledge-base/KBList.vue')
},
{
path: ':kbId',
name: 'NativeKBDetail',
component: () => import('@/views/knowledge-base/KBDetail.vue'),
props: true
},
{
path: ':kbId/document/:docId',
name: 'NativeDocumentDetail',
component: () => import('@/views/knowledge-base/DocumentDetail.vue'),
props: true
}
]
},
// {
// name: 'Alkaid',
// path: '/alkaid',
@@ -93,11 +117,6 @@ const MainRoutes = {
path: '/alkaid',
component: () => import('@/views/AlkaidPage.vue'),
children: [
{
path: 'knowledge-base-v2',
name: 'KnowledgeBaseV2',
component: () => import('@/views/alkaid/knowledge-base-v2/KnowledgeBaseV2.vue')
},
{
path: 'knowledge-base',
name: 'KnowledgeBase',
+2 -8
View File
@@ -8,12 +8,6 @@
</div>
<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"
@click="navigateTo('knowledge-base')">
@@ -75,9 +69,9 @@ export default {
}
},
mounted() {
// 如果在根路径 /alkaid,默认跳转到原生知识库页面
// 如果在根路径 /alkaid,默认跳转到知识库页面
if (this.$route.path === '/alkaid') {
this.navigateTo('knowledge-base-v2');
this.navigateTo('knowledge-base');
}
}
}
@@ -0,0 +1,591 @@
<template>
<div class="document-detail-page">
<!-- 页面头部 -->
<div class="page-header">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.push({ name: 'NativeKBDetail', params: { kbId } })"
/>
<div class="header-content">
<h1 class="text-h4">{{ document.doc_name }}</h1>
<p class="text-subtitle-1 text-medium-emphasis mt-2">{{ t('title') }}</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<!-- 主内容 -->
<div v-else class="document-content">
<!-- 文档信息卡片 -->
<v-card elevation="2" class="mb-6">
<v-card-title>{{ t('info.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="12" md="3">
<div class="info-item">
<v-icon start>mdi-label</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.name') }}</div>
<div class="text-body-1">{{ document.doc_name }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start :color="getFileColor(document.file_type)">
{{ getFileIcon(document.file_type) }}
</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.type') }}</div>
<div class="text-body-1">{{ document.file_type || '-' }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start>mdi-file-chart</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.size') }}</div>
<div class="text-body-1">{{ formatFileSize(document.file_size) }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="2">
<div class="info-item">
<v-icon start>mdi-text-box</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.chunkCount') }}</div>
<div class="text-body-1">{{ document.chunk_count || 0 }}</div>
</div>
</div>
</v-col>
<v-col cols="12" md="3">
<div class="info-item">
<v-icon start>mdi-calendar</v-icon>
<div>
<div class="text-caption text-medium-emphasis">{{ t('info.createdAt') }}</div>
<div class="text-body-1">{{ formatDate(document.created_at) }}</div>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 分块列表 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center pa-4">
<span>{{ t('chunks.title') }}</span>
<v-chip class="ml-2" size="small" variant="tonal">
{{ chunks.length }} {{ t('chunks.title') }}
</v-chip>
<v-spacer />
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:placeholder="t('chunks.searchPlaceholder')"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px"
/>
</v-card-title>
<v-divider />
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="filteredChunks"
:loading="loadingChunks"
:items-per-page="10"
>
<template #item.chunk_index="{ item }">
<v-chip size="small" variant="tonal" color="primary">
#{{ item.chunk_index + 1 }}
</v-chip>
</template>
<template #item.content="{ item }">
<div class="chunk-content-preview">
{{ item.content }}
</div>
</template>
<template #item.char_count="{ item }">
<v-chip size="small" variant="outlined">
{{ item.char_count }} 字符
</v-chip>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-eye"
variant="text"
size="small"
color="info"
@click="viewChunk(item)"
/>
<v-btn
icon="mdi-pencil"
variant="text"
size="small"
color="primary"
@click="editChunk(item)"
/>
<v-btn
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click="confirmDeleteChunk(item)"
/>
</template>
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-text-box-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('chunks.empty') }}</p>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
<!-- 查看分块对话框 -->
<v-dialog v-model="showViewDialog" max-width="800px" scrollable>
<v-card>
<v-card-title class="pa-4">
<span>{{ t('view.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="showViewDialog = false" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-pound</v-icon>
</template>
<v-list-item-title>{{ t('view.index') }}</v-list-item-title>
<v-list-item-subtitle>#{{ (selectedChunk?.chunk_index || 0) + 1 }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-text</v-icon>
</template>
<v-list-item-title>{{ t('view.charCount') }}</v-list-item-title>
<v-list-item-subtitle>{{ selectedChunk?.char_count || 0 }} 字符</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-key</v-icon>
</template>
<v-list-item-title>{{ t('view.vecDocId') }}</v-list-item-title>
<v-list-item-subtitle>{{ selectedChunk?.vec_doc_id || '-' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-4" />
<div class="text-caption text-medium-emphasis mb-2">{{ t('view.content') }}</div>
<div class="chunk-content-view">
{{ selectedChunk?.content }}
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showViewDialog = false">
{{ t('view.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 编辑分块对话框 -->
<v-dialog v-model="showEditDialog" max-width="800px" persistent scrollable>
<v-card>
<v-card-title class="pa-4">
<span>{{ t('edit.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeEditDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-textarea
v-model="editForm.content"
:label="t('edit.content')"
variant="outlined"
rows="15"
auto-grow
/>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeEditDialog">
{{ t('edit.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="saveChunk"
:loading="saving"
>
{{ t('edit.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('delete.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('delete.confirmText') }}</p>
<v-alert type="warning" variant="tonal" density="compact" class="mt-4">
{{ t('delete.warning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">
{{ t('delete.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="deleteChunk"
:loading="deleting"
>
{{ t('delete.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/document')
const route = useRoute()
const kbId = ref(route.params.kbId as string)
const docId = ref(route.params.docId as string)
// 状态
const loading = ref(true)
const loadingChunks = ref(false)
const saving = ref(false)
const deleting = ref(false)
const document = ref<any>({})
const chunks = ref<any[]>([])
const searchQuery = ref('')
const showViewDialog = ref(false)
const showEditDialog = ref(false)
const showDeleteDialog = ref(false)
const selectedChunk = ref<any>(null)
const deleteTarget = ref<any>(null)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 编辑表单
const editForm = ref({
content: ''
})
// 表格列
const headers = [
{ title: t('chunks.index'), key: 'chunk_index', width: 100 },
{ title: t('chunks.content'), key: 'content', sortable: false },
{ title: t('chunks.charCount'), key: 'char_count', width: 150 },
{ title: t('chunks.actions'), key: 'actions', sortable: false, align: 'end', width: 150 }
]
// 过滤分块
const filteredChunks = computed(() => {
if (!searchQuery.value) return chunks.value
const query = searchQuery.value.toLowerCase()
return chunks.value.filter(chunk =>
chunk.content.toLowerCase().includes(query)
)
})
// 加载文档详情
const loadDocument = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/document/get', {
params: { doc_id: docId.value }
})
if (response.data.status === 'ok') {
document.value = response.data.data
}
} catch (error) {
console.error('Failed to load document:', error)
showSnackbar('加载文档详情失败', 'error')
} finally {
loading.value = false
}
}
// 加载分块列表
const loadChunks = async () => {
loadingChunks.value = true
try {
const response = await axios.get('/api/kb/chunk/list', {
params: { doc_id: docId.value }
})
if (response.data.status === 'ok') {
chunks.value = response.data.data.items || []
}
} catch (error) {
console.error('Failed to load chunks:', error)
showSnackbar('加载分块列表失败', 'error')
} finally {
loadingChunks.value = false
}
}
// 查看分块
const viewChunk = (chunk: any) => {
selectedChunk.value = chunk
showViewDialog.value = true
}
// 编辑分块
const editChunk = (chunk: any) => {
selectedChunk.value = chunk
editForm.value.content = chunk.content
showEditDialog.value = true
}
// 关闭编辑对话框
const closeEditDialog = () => {
showEditDialog.value = false
selectedChunk.value = null
editForm.value.content = ''
}
// 保存分块
const saveChunk = async () => {
if (!selectedChunk.value) return
saving.value = true
try {
const response = await axios.post('/api/kb/chunk/update', {
chunk_id: selectedChunk.value.chunk_id,
content: editForm.value.content
})
if (response.data.status === 'ok') {
showSnackbar(t('edit.saveSuccess'))
closeEditDialog()
await loadChunks()
} else {
showSnackbar(response.data.message || t('edit.saveFailed'), 'error')
}
} catch (error) {
console.error('Failed to save chunk:', error)
showSnackbar(t('edit.saveFailed'), 'error')
} finally {
saving.value = false
}
}
// 确认删除分块
const confirmDeleteChunk = (chunk: any) => {
deleteTarget.value = chunk
showDeleteDialog.value = true
}
// 删除分块
const deleteChunk = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: deleteTarget.value.chunk_id
})
if (response.data.status === 'ok') {
showSnackbar(t('delete.deleteSuccess'))
showDeleteDialog.value = false
await loadChunks()
await loadDocument()
} else {
showSnackbar(response.data.message || t('delete.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete chunk:', error)
showSnackbar(t('delete.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 工具函数
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
if (type.includes('md')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
return 'mdi-file'
}
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
return 'grey'
}
const formatFileSize = (bytes: number) => {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadDocument()
loadChunks()
})
</script>
<style scoped>
.document-detail-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
}
.header-content {
flex: 1;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
.document-content {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.info-item {
display: flex;
gap: 12px;
align-items: flex-start;
}
.chunk-content-preview {
max-width: 400px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.875rem;
line-height: 1.5;
}
.chunk-content-view {
padding: 16px;
background: rgba(var(--v-theme-surface-variant), 0.3);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
font-family: 'Consolas', 'Monaco', monospace;
}
/* 响应式设计 */
@media (max-width: 768px) {
.document-detail-page {
padding: 16px;
}
}
</style>
@@ -0,0 +1,359 @@
<template>
<div class="kb-detail-page">
<!-- 页面头部 -->
<div class="page-header">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.push({ name: 'NativeKBList' })"
/>
<div class="header-content">
<div class="kb-title">
<span class="kb-emoji">{{ kb.emoji || '📚' }}</span>
<h1 class="text-h4">{{ kb.kb_name }}</h1>
</div>
<p v-if="kb.description" class="text-subtitle-1 text-medium-emphasis mt-2">
{{ kb.description }}
</p>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
</div>
<!-- 主内容 -->
<div v-else class="kb-content">
<!-- 标签页 -->
<v-tabs v-model="activeTab" class="mb-6" color="primary">
<v-tab value="overview">
<v-icon start>mdi-information-outline</v-icon>
{{ t('tabs.overview') }}
</v-tab>
<v-tab value="documents">
<v-icon start>mdi-file-document-multiple</v-icon>
{{ t('tabs.documents') }}
<v-chip class="ml-2" size="small" variant="tonal">{{ kb.doc_count || 0 }}</v-chip>
</v-tab>
<v-tab value="sessions">
<v-icon start>mdi-account-multiple</v-icon>
{{ t('tabs.sessions') }}
</v-tab>
<v-tab value="settings">
<v-icon start>mdi-cog</v-icon>
{{ t('tabs.settings') }}
</v-tab>
</v-tabs>
<!-- 标签页内容 -->
<v-window v-model="activeTab">
<!-- 概览 -->
<v-window-item value="overview">
<v-row>
<v-col cols="12" md="6">
<v-card elevation="2">
<v-card-title>{{ t('overview.title') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-label</v-icon>
</template>
<v-list-item-title>{{ t('overview.name') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.kb_name }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="kb.description">
<template #prepend>
<v-icon>mdi-text</v-icon>
</template>
<v-list-item-title>{{ t('overview.description') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.description }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-emoticon</v-icon>
</template>
<v-list-item-title>{{ t('overview.emoji') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.emoji || '📚' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-calendar-plus</v-icon>
</template>
<v-list-item-title>{{ t('overview.createdAt') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(kb.created_at) }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-calendar-edit</v-icon>
</template>
<v-list-item-title>{{ t('overview.updatedAt') }}</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(kb.updated_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card elevation="2" class="mb-4">
<v-card-title>{{ t('overview.stats') }}</v-card-title>
<v-divider />
<v-card-text>
<v-row>
<v-col cols="6">
<div class="stat-box">
<v-icon size="48" color="primary">mdi-file-document</v-icon>
<div class="stat-value">{{ kb.doc_count || 0 }}</div>
<div class="stat-label">{{ t('overview.docCount') }}</div>
</div>
</v-col>
<v-col cols="6">
<div class="stat-box">
<v-icon size="48" color="secondary">mdi-text-box</v-icon>
<div class="stat-value">{{ kb.chunk_count || 0 }}</div>
<div class="stat-label">{{ t('overview.chunkCount') }}</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card elevation="2">
<v-card-title>{{ t('overview.embeddingModel') }}</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
<template #prepend>
<v-icon>mdi-vector-point</v-icon>
</template>
<v-list-item-title>{{ t('overview.embeddingModel') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.embedding_provider_id || t('overview.notSet') }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon>mdi-sort-ascending</v-icon>
</template>
<v-list-item-title>{{ t('overview.rerankModel') }}</v-list-item-title>
<v-list-item-subtitle>{{ kb.rerank_provider_id || t('overview.notSet') }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-window-item>
<!-- 文档管理 -->
<v-window-item value="documents">
<DocumentsTab :kb-id="kbId" :kb="kb" @refresh="loadKB" />
</v-window-item>
<!-- 会话配置 -->
<v-window-item value="sessions">
<SessionsTab :kb-id="kbId" />
</v-window-item>
<!-- 设置 -->
<v-window-item value="settings">
<SettingsTab :kb="kb" @updated="loadKB" />
</v-window-item>
</v-window>
</div>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import DocumentsTab from './components/DocumentsTab.vue'
import SessionsTab from './components/SessionsTab.vue'
import SettingsTab from './components/SettingsTab.vue'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const route = useRoute()
const kbId = ref(route.params.kbId as string)
const loading = ref(true)
const activeTab = ref('overview')
const kb = ref<any>({})
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 加载知识库详情
const loadKB = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/get', {
params: { kb_id: kbId.value }
})
if (response.data.status === 'ok') {
kb.value = response.data.data
} else {
showSnackbar(response.data.message || '加载失败', 'error')
}
} catch (error) {
console.error('Failed to load knowledge base:', error)
showSnackbar('加载知识库详情失败', 'error')
} finally {
loading.value = false
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadKB()
})
</script>
<style scoped>
.kb-detail-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 32px;
}
.header-content {
flex: 1;
}
.kb-title {
display: flex;
align-items: center;
gap: 16px;
}
.kb-emoji {
font-size: 48px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
.kb-content {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.stat-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
text-align: center;
border-radius: 12px;
background: rgba(var(--v-theme-surface-variant), 0.3);
transition: all 0.3s ease;
}
.stat-box:hover {
transform: translateY(-4px);
background: rgba(var(--v-theme-surface-variant), 0.5);
}
.stat-value {
font-size: 2rem;
font-weight: 600;
margin-top: 8px;
color: rgb(var(--v-theme-on-surface));
}
.stat-label {
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-top: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.kb-detail-page {
padding: 16px;
}
.kb-title {
flex-direction: column;
align-items: flex-start;
}
.kb-emoji {
font-size: 36px;
}
}
</style>
@@ -0,0 +1,686 @@
<template>
<div class="kb-list-page">
<!-- 页面标题 -->
<div class="page-header">
<div>
<h1 class="text-h4 mb-2">{{ t('list.title') }}</h1>
<p class="text-subtitle-1 text-medium-emphasis">{{ t('list.subtitle') }}</p>
</div>
<v-btn
icon="mdi-information-outline"
variant="text"
size="small"
color="grey"
href="https://astrbot.app/use/knowledge-base.html"
target="_blank"
/>
</div>
<!-- 操作按钮栏 -->
<div class="action-bar mb-6">
<v-btn
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
@click="showCreateDialog = true"
>
{{ t('list.create') }}
</v-btn>
<v-btn
prepend-icon="mdi-refresh"
variant="tonal"
@click="loadKnowledgeBases"
:loading="loading"
>
{{ t('list.refresh') }}
</v-btn>
</div>
<!-- 知识库网格 -->
<div v-if="loading && kbList.length === 0" class="loading-container">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-medium-emphasis">{{ t('list.loading') }}</p>
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card
v-for="kb in kbList"
:key="kb.kb_id"
class="kb-card"
elevation="2"
hover
@click="navigateToDetail(kb.kb_id)"
>
<div class="kb-card-content">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<div class="kb-stats mt-4">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
</div>
<div class="stat-item">
<v-icon size="small" color="secondary">mdi-text-box</v-icon>
<span>{{ kb.chunk_count || 0 }} {{ t('list.chunks') }}</span>
</div>
</div>
<div class="kb-actions">
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
color="info"
@click.stop="editKB(kb)"
/>
<v-btn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click.stop="confirmDelete(kb)"
/>
</div>
</div>
</v-card>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<v-icon size="100" color="grey-lighten-2">mdi-book-open-variant</v-icon>
<h2 class="mt-4">{{ t('list.empty') }}</h2>
<v-btn
class="mt-6"
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
size="large"
@click="showCreateDialog = true"
>
{{ t('list.create') }}
</v-btn>
</div>
<!-- 创建/编辑对话框 -->
<v-dialog v-model="showCreateDialog" max-width="600px" persistent>
<v-card>
<v-card-title class="d-flex align-center pa-4">
<span class="text-h5">{{ editingKB ? t('edit.title') : t('create.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeCreateDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<!-- Emoji 选择器 -->
<div class="text-center mb-6">
<div class="emoji-display" @click="showEmojiPicker = true">
{{ formData.emoji }}
</div>
<p class="text-caption text-medium-emphasis mt-2">{{ t('create.emojiLabel') }}</p>
</div>
<!-- 表单 -->
<v-form ref="formRef" @submit.prevent="submitForm">
<v-text-field
v-model="formData.kb_name"
:label="t('create.nameLabel')"
:placeholder="t('create.namePlaceholder')"
variant="outlined"
:rules="[v => !!v || t('create.nameRequired')]"
required
class="mb-4"
/>
<v-textarea
v-model="formData.description"
:label="t('create.descriptionLabel')"
:placeholder="t('create.descriptionPlaceholder')"
variant="outlined"
rows="3"
class="mb-4"
/>
<v-select
v-model="formData.embedding_provider_id"
:items="embeddingProviders"
:item-title="item => item.embedding_model || item.id"
:item-value="'id'"
:label="t('create.embeddingModelLabel')"
variant="outlined"
class="mb-4"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.providerInfo', {
id: item.raw.id,
dimensions: item.raw.embedding_dimensions || 'N/A'
}) }}
</template>
</v-list-item>
</template>
</v-select>
<v-select
v-model="formData.rerank_provider_id"
:items="rerankProviders"
:item-title="item => item.rerank_model || item.id"
:item-value="'id'"
:label="t('create.rerankModelLabel')"
variant="outlined"
clearable
class="mb-2"
>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #subtitle>
{{ t('create.rerankProviderInfo', { id: item.raw.id }) }}
</template>
</v-list-item>
</template>
</v-select>
<v-alert type="info" variant="tonal" density="compact" class="mt-4">
{{ t('create.tips') }}
</v-alert>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeCreateDialog">
{{ t('create.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="submitForm"
:loading="saving"
>
{{ editingKB ? t('edit.submit') : t('create.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Emoji 选择器对话框 -->
<v-dialog v-model="showEmojiPicker" max-width="500px">
<v-card>
<v-card-title class="pa-4">{{ t('emoji.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div v-for="category in emojiCategories" :key="category.key" class="mb-4">
<p class="text-subtitle-2 mb-2">{{ t(`emoji.categories.${category.key}`) }}</p>
<div class="emoji-grid">
<div
v-for="emoji in category.emojis"
:key="emoji"
class="emoji-item"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</div>
</div>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showEmojiPicker = false">
{{ t('emoji.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px" persistent>
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('delete.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('delete.confirmText', { name: deleteTarget?.kb_name || '' }) }}</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('delete.warning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelDelete">
{{ t('delete.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="deleteKB"
:loading="deleting"
>
{{ t('delete.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/index')
const router = useRouter()
// 状态
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const kbList = ref<any[]>([])
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
// 对话框
const showCreateDialog = ref(false)
const showEmojiPicker = ref(false)
const showDeleteDialog = ref(false)
// Snackbar 通知
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
// 表单
const formRef = ref()
const editingKB = ref<any>(null)
const deleteTarget = ref<any>(null)
const formData = ref({
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null
})
// Emoji 分类
const emojiCategories = [
{
key: 'books',
emojis: ['📚', '📖', '📕', '📗', '📘', '📙', '📓', '📔', '📒', '📑', '🗂️', '📂', '📁', '🗃️', '🗄️']
},
{
key: 'emotions',
emojis: ['😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂', '🙂', '🙃', '😉', '😊', '😇', '🥰', '😍']
},
{
key: 'objects',
emojis: ['💡', '🔬', '🔭', '🗿', '🏆', '🎯', '🎓', '🔑', '🔒', '🔓', '🔔', '🔕', '🔨', '🛠️', '⚙️']
},
{
key: 'symbols',
emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '⭐', '🌟', '✨', '💫', '⚡', '🔥']
}
]
// 加载知识库列表
const loadKnowledgeBases = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/list')
if (response.data.status === 'ok') {
kbList.value = response.data.data.items || []
} else {
showSnackbar(response.data.message || t('messages.loadError'), 'error')
}
} catch (error) {
console.error('Failed to load knowledge bases:', error)
showSnackbar(t('messages.loadError'), 'error')
} finally {
loading.value = false
}
}
// 加载提供商配置
const loadProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' }
})
if (response.data.status === 'ok') {
embeddingProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'embedding'
)
rerankProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'rerank'
)
}
} catch (error) {
console.error('Failed to load providers:', error)
}
}
// 导航到详情页
const navigateToDetail = (kbId: string) => {
router.push({ name: 'NativeKBDetail', params: { kbId } })
}
// 编辑知识库
const editKB = (kb: any) => {
editingKB.value = kb
formData.value = {
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
}
showCreateDialog.value = true
}
// 确认删除
const confirmDelete = (kb: any) => {
deleteTarget.value = kb
showDeleteDialog.value = true
}
// 取消删除
const cancelDelete = () => {
showDeleteDialog.value = false
deleteTarget.value = null
}
// 删除知识库
const deleteKB = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/delete', {
kb_id: deleteTarget.value.kb_id
})
console.log('Delete response:', response.data) // 调试日志
if (response.data.status === 'ok') {
showSnackbar(t('messages.deleteSuccess'))
// 先刷新列表,再关闭对话框
await loadKnowledgeBases()
showDeleteDialog.value = false
deleteTarget.value = null
} else {
showSnackbar(response.data.message || t('messages.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete knowledge base:', error)
showSnackbar(t('messages.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 提交表单
const submitForm = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const payload = {
kb_name: formData.value.kb_name,
description: formData.value.description,
emoji: formData.value.emoji,
embedding_provider_id: formData.value.embedding_provider_id,
rerank_provider_id: formData.value.rerank_provider_id
}
let response
if (editingKB.value) {
response = await axios.post('/api/kb/update', {
kb_id: editingKB.value.kb_id,
...payload
})
} else {
response = await axios.post('/api/kb/create', payload)
}
if (response.data.status === 'ok') {
showSnackbar(editingKB.value ? t('messages.updateSuccess') : t('messages.createSuccess'))
closeCreateDialog()
await loadKnowledgeBases()
} else {
showSnackbar(response.data.message || (editingKB.value ? t('messages.updateFailed') : t('messages.createFailed')), 'error')
}
} catch (error) {
console.error('Failed to save knowledge base:', error)
showSnackbar(editingKB.value ? t('messages.updateFailed') : t('messages.createFailed'), 'error')
} finally {
saving.value = false
}
}
// 关闭创建对话框
const closeCreateDialog = () => {
showCreateDialog.value = false
editingKB.value = null
formData.value = {
kb_name: '',
description: '',
emoji: '📚',
embedding_provider_id: null,
rerank_provider_id: null
}
formRef.value?.reset()
}
// 选择 emoji
const selectEmoji = (emoji: string) => {
formData.value.emoji = emoji
showEmojiPicker.value = false
}
// 显示通知
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
onMounted(() => {
loadKnowledgeBases()
loadProviders()
})
</script>
<style scoped>
.kb-list-page {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
}
.action-bar {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
/* 知识库网格 */
.kb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.kb-card {
position: relative;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.kb-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
.kb-card-content {
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-height: 260px;
position: relative;
}
.kb-emoji {
font-size: 56px;
margin-bottom: 16px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.kb-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 8px;
color: rgb(var(--v-theme-on-surface));
}
.kb-description {
font-size: 0.875rem;
line-height: 1.5;
max-height: 3em;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.kb-stats {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.kb-actions {
position: absolute;
bottom: 16px;
right: 16px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s ease;
}
.kb-card:hover .kb-actions {
opacity: 1;
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
/* 加载状态 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
}
/* Emoji 显示和选择器 */
.emoji-display {
font-size: 72px;
cursor: pointer;
transition: transform 0.2s ease;
display: inline-block;
padding: 16px;
border-radius: 12px;
background: rgba(var(--v-theme-primary), 0.05);
}
.emoji-display:hover {
transform: scale(1.1);
background: rgba(var(--v-theme-primary), 0.1);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 8px;
}
.emoji-item {
font-size: 32px;
padding: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
transition: all 0.2s ease;
}
.emoji-item:hover {
background: rgba(var(--v-theme-primary), 0.1);
transform: scale(1.2);
}
/* 响应式设计 */
@media (max-width: 768px) {
.kb-list-page {
padding: 16px;
}
.kb-grid {
grid-template-columns: 1fr;
}
.emoji-grid {
grid-template-columns: repeat(6, 1fr);
}
}
</style>
@@ -0,0 +1,469 @@
<template>
<div class="documents-tab">
<!-- 操作栏 -->
<div class="action-bar mb-4">
<v-btn
prepend-icon="mdi-upload"
color="primary"
variant="elevated"
@click="showUploadDialog = true"
>
{{ t('documents.upload') }}
</v-btn>
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:placeholder="'搜索文档...'"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px"
/>
</div>
<!-- 文档列表 -->
<v-card elevation="2">
<v-data-table
:headers="headers"
:items="documents"
:loading="loading"
:search="searchQuery"
:items-per-page="10"
>
<template #item.doc_name="{ item }">
<div class="d-flex align-center gap-2">
<v-icon :color="getFileColor(item.file_type)">
{{ getFileIcon(item.file_type) }}
</v-icon>
<span class="font-weight-medium">{{ item.doc_name }}</span>
</div>
</template>
<template #item.file_size="{ item }">
{{ formatFileSize(item.file_size) }}
</template>
<template #item.created_at="{ item }">
{{ formatDate(item.created_at) }}
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-eye"
variant="text"
size="small"
color="info"
@click="viewDocument(item)"
/>
<v-btn
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click="confirmDelete(item)"
/>
</template>
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-file-document-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('documents.empty') }}</p>
</div>
</template>
</v-data-table>
</v-card>
<!-- 上传对话框 -->
<v-dialog v-model="showUploadDialog" max-width="600px" persistent>
<v-card>
<v-card-title class="pa-4">
<span class="text-h5">{{ t('upload.title') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<!-- 文件选择 -->
<div
class="upload-dropzone"
:class="{ 'dragover': isDragging }"
@drop.prevent="handleDrop"
@dragover.prevent="isDragging = true"
@dragleave="isDragging = false"
@click="$refs.fileInput.click()"
>
<v-icon size="64" color="primary">mdi-cloud-upload</v-icon>
<p class="mt-4 text-h6">{{ t('upload.dropzone') }}</p>
<p class="text-caption text-medium-emphasis mt-2">{{ t('upload.supportedFormats') }}</p>
<p class="text-caption text-medium-emphasis">{{ t('upload.maxSize') }}</p>
<input
ref="fileInput"
type="file"
hidden
accept=".txt,.md,.pdf"
@change="handleFileSelect"
/>
</div>
<div v-if="selectedFile" class="mt-4 pa-4 rounded bg-surface-variant">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<v-icon>{{ getFileIcon(selectedFile.name) }}</v-icon>
<div>
<div class="font-weight-medium">{{ selectedFile.name }}</div>
<div class="text-caption">{{ formatFileSize(selectedFile.size) }}</div>
</div>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="selectedFile = null" />
</div>
</div>
<!-- 分块设置 -->
<v-expansion-panels class="mt-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon start>mdi-cog</v-icon>
{{ t('upload.chunkSettings') }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-text-field
v-model.number="uploadSettings.chunk_size"
:label="t('upload.chunkSize')"
:hint="t('upload.chunkSizeHint')"
type="number"
variant="outlined"
density="compact"
class="mb-2"
/>
<v-text-field
v-model.number="uploadSettings.chunk_overlap"
:label="t('upload.chunkOverlap')"
:hint="t('upload.chunkOverlapHint')"
type="number"
variant="outlined"
density="compact"
/>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeUploadDialog">
{{ t('upload.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="uploadDocument"
:loading="uploading"
:disabled="!selectedFile"
>
{{ t('upload.submit') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="450px">
<v-card>
<v-card-title class="pa-4 text-h6">{{ t('documents.delete') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('documents.deleteConfirm', { name: deleteTarget?.doc_name || '' }) }}</p>
<v-alert type="error" variant="tonal" density="compact" class="mt-4">
{{ t('documents.deleteWarning') }}
</v-alert>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn color="error" variant="elevated" @click="deleteDocument" :loading="deleting">
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const router = useRouter()
const props = defineProps<{
kbId: string
kb: any
}>()
const emit = defineEmits(['refresh'])
// 状态
const loading = ref(false)
const uploading = ref(false)
const deleting = ref(false)
const documents = ref<any[]>([])
const searchQuery = ref('')
const showUploadDialog = ref(false)
const showDeleteDialog = ref(false)
const selectedFile = ref<File | null>(null)
const deleteTarget = ref<any>(null)
const isDragging = ref(false)
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 上传设置
const uploadSettings = ref({
chunk_size: null,
chunk_overlap: null
})
// 表格列
const headers = [
{ title: t('documents.name'), key: 'doc_name', sortable: true },
{ title: t('documents.type'), key: 'file_type', sortable: true },
{ title: t('documents.size'), key: 'file_size', sortable: true },
{ title: t('documents.chunks'), key: 'chunk_count', sortable: true },
{ title: t('documents.createdAt'), key: 'created_at', sortable: true },
{ title: t('documents.actions'), key: 'actions', sortable: false, align: 'end' }
]
// 加载文档列表
const loadDocuments = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/document/list', {
params: { kb_id: props.kbId }
})
if (response.data.status === 'ok') {
documents.value = response.data.data.items || []
}
} catch (error) {
console.error('Failed to load documents:', error)
showSnackbar('加载文档列表失败', 'error')
} finally {
loading.value = false
}
}
// 文件选择
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
selectedFile.value = target.files[0]
}
}
// 拖放上传
const handleDrop = (event: DragEvent) => {
isDragging.value = false
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
selectedFile.value = event.dataTransfer.files[0]
}
}
// 上传文档
const uploadDocument = async () => {
if (!selectedFile.value) {
showSnackbar(t('upload.fileRequired'), 'warning')
return
}
uploading.value = true
try {
const formData = new FormData()
formData.append('file', selectedFile.value)
formData.append('kb_id', props.kbId)
if (uploadSettings.value.chunk_size) {
formData.append('chunk_size', uploadSettings.value.chunk_size.toString())
}
if (uploadSettings.value.chunk_overlap) {
formData.append('chunk_overlap', uploadSettings.value.chunk_overlap.toString())
}
const response = await axios.post('/api/kb/document/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (response.data.status === 'ok') {
showSnackbar(t('documents.uploadSuccess'))
closeUploadDialog()
await loadDocuments()
emit('refresh')
} else {
showSnackbar(response.data.message || t('documents.uploadFailed'), 'error')
}
} catch (error) {
console.error('Failed to upload document:', error)
showSnackbar(t('documents.uploadFailed'), 'error')
} finally {
uploading.value = false
}
}
// 关闭上传对话框
const closeUploadDialog = () => {
showUploadDialog.value = false
selectedFile.value = null
uploadSettings.value = { chunk_size: null, chunk_overlap: null }
}
// 查看文档
const viewDocument = (doc: any) => {
router.push({
name: 'NativeDocumentDetail',
params: { kbId: props.kbId, docId: doc.doc_id }
})
}
// 确认删除
const confirmDelete = (doc: any) => {
deleteTarget.value = doc
showDeleteDialog.value = true
}
// 删除文档
const deleteDocument = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/document/delete', {
doc_id: deleteTarget.value.doc_id
})
if (response.data.status === 'ok') {
showSnackbar(t('documents.deleteSuccess'))
showDeleteDialog.value = false
await loadDocuments()
emit('refresh')
} else {
showSnackbar(response.data.message || t('documents.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete document:', error)
showSnackbar(t('documents.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 工具函数
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
if (type.includes('md') || type.includes('markdown')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
return 'mdi-file'
}
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
return 'grey'
}
const formatFileSize = (bytes: number) => {
if (!bytes) return '-'
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`
}
const formatDate = (dateStr: string) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
onMounted(() => {
loadDocuments()
})
</script>
<style scoped>
.documents-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.upload-dropzone {
border: 2px dashed rgba(var(--v-theme-primary), 0.3);
border-radius: 12px;
padding: 48px 24px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: rgba(var(--v-theme-surface-variant), 0.3);
}
.upload-dropzone:hover,
.upload-dropzone.dragover {
border-color: rgb(var(--v-theme-primary));
background: rgba(var(--v-theme-primary), 0.05);
transform: scale(1.02);
}
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: stretch;
}
.action-bar > * {
width: 100%;
}
}
</style>
@@ -0,0 +1,320 @@
<template>
<div class="sessions-tab">
<v-card elevation="2">
<v-card-title class="d-flex align-center pa-4">
<span>{{ t('sessions.title') }}</span>
<v-spacer />
<v-btn
prepend-icon="mdi-plus"
color="primary"
variant="elevated"
size="small"
@click="showAddDialog = true"
>
{{ t('sessions.add') }}
</v-btn>
</v-card-title>
<v-card-subtitle class="px-4 pb-4">
{{ t('sessions.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-card-text class="pa-0">
<v-data-table
:headers="headers"
:items="sessions"
:loading="loading"
>
<template #item.scope="{ item }">
<v-chip :color="item.scope === 'session' ? 'primary' : 'secondary'" size="small" variant="tonal">
{{ item.scope === 'session' ? t('sessions.scopeSession') : t('sessions.scopePlatform') }}
</v-chip>
</template>
<template #item.enable_rerank="{ item }">
<v-icon :color="item.enable_rerank ? 'success' : 'grey'">
{{ item.enable_rerank ? 'mdi-check-circle' : 'mdi-close-circle' }}
</v-icon>
</template>
<template #item.actions="{ item }">
<v-btn
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click="confirmDelete(item)"
/>
</template>
<template #no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-account-multiple-outline</v-icon>
<p class="mt-4 text-medium-emphasis">{{ t('sessions.empty') }}</p>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<!-- 添加配置对话框 -->
<v-dialog v-model="showAddDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="pa-4">
<span class="text-h6">{{ t('sessions.add') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeAddDialog" />
</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="formRef">
<v-select
v-model="formData.scope"
:items="scopeOptions"
:label="t('sessions.scope')"
variant="outlined"
class="mb-4"
/>
<v-text-field
v-model="formData.scope_id"
:label="t('sessions.scopeId')"
:placeholder="formData.scope === 'session' ? 'platform:xxx:session_id' : 'platform_id'"
variant="outlined"
required
class="mb-4"
/>
<v-text-field
v-model.number="formData.top_k"
:label="t('sessions.topK')"
type="number"
variant="outlined"
class="mb-4"
/>
<v-checkbox
v-model="formData.enable_rerank"
:label="t('sessions.enableRerank')"
color="primary"
/>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeAddDialog">取消</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="addSession"
:loading="saving"
>
添加
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="showDeleteDialog" max-width="400px">
<v-card>
<v-card-title class="pa-4 text-h6">确认删除</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<p>{{ t('sessions.deleteConfirm') }}</p>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="showDeleteDialog = false">取消</v-btn>
<v-btn
color="error"
variant="elevated"
@click="deleteSession"
:loading="deleting"
>
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kbId: string
}>()
// 状态
const loading = ref(false)
const saving = ref(false)
const deleting = ref(false)
const sessions = ref<any[]>([])
const showAddDialog = ref(false)
const showDeleteDialog = ref(false)
const deleteTarget = ref<any>(null)
const formRef = ref()
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 表单数据
const formData = ref({
scope: 'session',
scope_id: '',
top_k: 5,
enable_rerank: true
})
// 表格列
const headers = [
{ title: t('sessions.scope'), key: 'scope' },
{ title: t('sessions.scopeId'), key: 'scope_id' },
{ title: t('sessions.topK'), key: 'top_k' },
{ title: t('sessions.enableRerank'), key: 'enable_rerank' },
{ title: t('sessions.actions'), key: 'actions', sortable: false, align: 'end' }
]
// 范围选项
const scopeOptions = [
{ title: t('sessions.scopeSession'), value: 'session' },
{ title: t('sessions.scopePlatform'), value: 'platform' }
]
// 加载会话配置
const loadSessions = async () => {
loading.value = true
try {
const response = await axios.get('/api/kb/session/config/list')
if (response.data.status === 'ok') {
// 过滤出使用当前知识库的配置
sessions.value = response.data.data.items.filter((item: any) => {
const kbIds = JSON.parse(item.kb_ids || '[]')
return kbIds.includes(props.kbId)
})
}
} catch (error) {
console.error('Failed to load session configs:', error)
showSnackbar('加载会话配置失败', 'error')
} finally {
loading.value = false
}
}
// 添加会话配置
const addSession = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const response = await axios.post('/api/kb/session/config', {
scope: formData.value.scope,
scope_id: formData.value.scope_id,
kb_ids: [props.kbId],
top_k: formData.value.top_k,
enable_rerank: formData.value.enable_rerank
})
if (response.data.status === 'ok') {
showSnackbar(t('sessions.addSuccess'))
closeAddDialog()
await loadSessions()
} else {
showSnackbar(response.data.message || t('sessions.addFailed'), 'error')
}
} catch (error) {
console.error('Failed to add session config:', error)
showSnackbar(t('sessions.addFailed'), 'error')
} finally {
saving.value = false
}
}
// 确认删除
const confirmDelete = (session: any) => {
deleteTarget.value = session
showDeleteDialog.value = true
}
// 删除会话配置
const deleteSession = async () => {
if (!deleteTarget.value) return
deleting.value = true
try {
const response = await axios.post('/api/kb/session/config/delete', {
scope: deleteTarget.value.scope,
scope_id: deleteTarget.value.scope_id
})
if (response.data.status === 'ok') {
showSnackbar(t('sessions.deleteSuccess'))
showDeleteDialog.value = false
await loadSessions()
} else {
showSnackbar(response.data.message || t('sessions.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete session config:', error)
showSnackbar(t('sessions.deleteFailed'), 'error')
} finally {
deleting.value = false
}
}
// 关闭添加对话框
const closeAddDialog = () => {
showAddDialog.value = false
formData.value = {
scope: 'session',
scope_id: '',
top_k: 5,
enable_rerank: true
}
formRef.value?.reset()
}
onMounted(() => {
loadSessions()
})
</script>
<style scoped>
.sessions-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -0,0 +1,261 @@
<template>
<div class="settings-tab">
<v-card elevation="2">
<v-card-title class="pa-4">{{ t('settings.title') }}</v-card-title>
<v-divider />
<v-card-text class="pa-6">
<v-form ref="formRef">
<!-- 基本设置 -->
<h3 class="text-h6 mb-4">{{ t('settings.basic') }}</h3>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.chunk_size"
:label="t('settings.chunkSize')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="formData.chunk_overlap"
:label="t('settings.chunkOverlap')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
</v-row>
<!-- 检索设置 -->
<h3 class="text-h6 mb-4 mt-6">{{ t('settings.retrieval') }}</h3>
<v-row>
<v-col cols="12" md="4">
<v-text-field
v-model.number="formData.top_k_dense"
:label="t('settings.topKDense')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="formData.top_k_sparse"
:label="t('settings.topKSparse')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="formData.top_m_final"
:label="t('settings.topMFinal')"
type="number"
variant="outlined"
density="comfortable"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-checkbox
v-model="formData.enable_rerank"
:label="t('settings.enableRerank')"
color="primary"
/>
</v-col>
</v-row>
<!-- 模型设置 -->
<h3 class="text-h6 mb-4 mt-6">{{ t('settings.embeddingProvider') }}</h3>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="formData.embedding_provider_id"
:items="embeddingProviders"
:item-title="item => item.embedding_model || item.id"
:item-value="'id'"
:label="t('settings.embeddingProvider')"
variant="outlined"
density="comfortable"
disabled
hint="嵌入模型创建后不可修改"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="formData.rerank_provider_id"
:items="rerankProviders"
:item-title="item => item.rerank_model || item.id"
:item-value="'id'"
:label="t('settings.rerankProvider')"
variant="outlined"
density="comfortable"
clearable
/>
</v-col>
</v-row>
<v-alert type="info" variant="tonal" class="mt-4">
{{ t('settings.tips') }}
</v-alert>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
color="primary"
variant="elevated"
prepend-icon="mdi-content-save"
@click="saveSettings"
:loading="saving"
>
{{ t('settings.save') }}
</v-btn>
</v-card-actions>
</v-card>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kb: any
}>()
const emit = defineEmits(['updated'])
// 状态
const saving = ref(false)
const formRef = ref()
const embeddingProviders = ref<any[]>([])
const rerankProviders = ref<any[]>([])
const snackbar = ref({
show: false,
text: '',
color: 'success'
})
const showSnackbar = (text: string, color: string = 'success') => {
snackbar.value.text = text
snackbar.value.color = color
snackbar.value.show = true
}
// 表单数据
const formData = ref({
chunk_size: 512,
chunk_overlap: 50,
top_k_dense: 50,
top_k_sparse: 50,
top_m_final: 5,
enable_rerank: true,
embedding_provider_id: '',
rerank_provider_id: ''
})
// 监听 kb 变化,更新表单
watch(() => props.kb, (kb) => {
if (kb) {
formData.value = {
chunk_size: kb.chunk_size || 512,
chunk_overlap: kb.chunk_overlap || 50,
top_k_dense: kb.top_k_dense || 50,
top_k_sparse: kb.top_k_sparse || 50,
top_m_final: kb.top_m_final || 5,
enable_rerank: kb.enable_rerank !== false,
embedding_provider_id: kb.embedding_provider_id || '',
rerank_provider_id: kb.rerank_provider_id || ''
}
}
}, { immediate: true })
// 加载提供商列表
const loadProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'embedding,rerank' }
})
if (response.data.status === 'ok') {
embeddingProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'embedding'
)
rerankProviders.value = response.data.data.filter(
(p: any) => p.provider_type === 'rerank'
)
}
} catch (error) {
console.error('Failed to load providers:', error)
}
}
// 保存设置
const saveSettings = async () => {
const { valid } = await formRef.value.validate()
if (!valid) return
saving.value = true
try {
const response = await axios.post('/api/kb/update', {
kb_id: props.kb.kb_id,
chunk_size: formData.value.chunk_size,
chunk_overlap: formData.value.chunk_overlap,
top_k_dense: formData.value.top_k_dense,
top_k_sparse: formData.value.top_k_sparse,
top_m_final: formData.value.top_m_final,
enable_rerank: formData.value.enable_rerank,
rerank_provider_id: formData.value.rerank_provider_id
})
if (response.data.status === 'ok') {
showSnackbar(t('settings.saveSuccess'))
emit('updated')
} else {
showSnackbar(response.data.message || t('settings.saveFailed'), 'error')
}
} catch (error) {
console.error('Failed to save settings:', error)
showSnackbar(t('settings.saveFailed'), 'error')
} finally {
saving.value = false
}
}
onMounted(() => {
loadProviders()
})
</script>
<style scoped>
.settings-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -0,0 +1,37 @@
<template>
<div class="kb-container">
<router-view v-slot="{ Component }">
<transition name="kb-fade" mode="out-in">
<component :is="Component" :key="$route.fullPath" />
</transition>
</router-view>
</div>
</template>
<script setup lang="ts">
// 主容器组件,提供路由出口和页面切换动画
</script>
<style scoped>
.kb-container {
width: 100%;
height: 100%;
position: relative;
}
/* 页面切换动画 */
.kb-fade-enter-active,
.kb-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.kb-fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.kb-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>