Merge remote-tracking branch 'origin/master' into feat/agent-runner

This commit is contained in:
Soulter
2025-11-23 14:48:08 +08:00
402 changed files with 23406 additions and 8751 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
<template>
<div class="input-area fade-in">
<div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ProviderModelSelector ref="providerModelSelectorRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
</v-chip>
</template>
</v-tooltip>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<input type="file" ref="imageInputRef" @change="handleFileSelect" accept="image/*"
style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
class="add-btn" size="small" />
<v-btn @click="handleRecordClick"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!canSend" class="send-btn" size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" />
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue';
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:prompt': [value: string];
send: [];
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
fileSelect: [files: FileList];
}>();
const { tm } = useModuleI18n('features/chat');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
const localPrompt = computed({
get: () => props.prompt,
set: (value) => emit('update:prompt', value)
});
const canSend = computed(() => {
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
});
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
const ctrlKeyLongPressThreshold = 300;
function handleKeyDown(e: KeyboardEvent) {
// Enter 发送消息
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
if (canSend.value) {
emit('send');
}
}
// Ctrl+B 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
if (ctrlKeyTimer.value) {
clearTimeout(ctrlKeyTimer.value);
ctrlKeyTimer.value = null;
}
if (props.isRecording) {
emit('stopRecording');
}
}
}
function handlePaste(e: ClipboardEvent) {
emit('pasteImage', e);
}
function triggerImageInput() {
imageInputRef.value?.click();
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files) {
emit('fileSelect', files);
}
target.value = '';
}
function handleRecordClick() {
if (props.isRecording) {
emit('stopRecording');
} else {
emit('startRecording');
}
}
function getCurrentSelection() {
return providerModelSelectorRef.value?.getCurrentSelection();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
}
document.addEventListener('keyup', handleKeyUp);
});
onBeforeUnmount(() => {
if (inputField.value) {
inputField.value.removeEventListener('paste', handlePaste);
}
document.removeEventListener('keyup', handleKeyUp);
});
defineExpose({
getCurrentSelection
});
</script>
<style scoped>
.input-area {
padding: 16px;
background-color: var(--v-theme-surface);
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.attachments-preview {
display: flex;
gap: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview,
.audio-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip {
height: 36px;
border-radius: 18px;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
.streaming-toggle-chip {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.streaming-toggle-chip:hover {
opacity: 0.8;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.input-area {
padding: 0 !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
}
</style>
@@ -0,0 +1,296 @@
<template>
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
@mouseenter="handleSidebarMouseEnter"
@mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="$emit('deleteConversation', item.session_id)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
interface Props {
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
newChat: [];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
closeMobileSidebar: [];
}>();
const { tm } = useModuleI18n('features/chat');
const { t } = useI18n();
const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
// 从 localStorage 读取侧边栏折叠状态
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
if (savedCollapsedState !== null) {
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
} else {
sidebarCollapsed.value = true;
}
function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
</script>
<style scoped>
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.04);
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
.sidebar-collapse-btn-container {
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.conversation-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
height: auto !important;
min-height: 56px;
padding: 8px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.fade-in {
animation: fadeInContent 0.3s ease;
}
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
+146 -23
View File
@@ -33,33 +33,53 @@
<v-avatar class="bot-avatar" size="36">
<v-progress-circular :index="index" v-if="isStreaming && index === messages.length - 1" indeterminate size="28"
width="2"></v-progress-circular>
<span v-else-if="messages[index - 1]?.content.type !== 'bot'" class="text-h2"></span>
<v-icon v-else-if="messages[index - 1]?.content.type !== 'bot'" size="64" color="#8fb6d2">mdi-star-four-points-small</v-icon>
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">
<!-- Text -->
<div v-if="msg.content.message && msg.content.message.trim()"
v-html="md.render(msg.content.message)" class="markdown-content"></div>
<!-- Image -->
<div class="embedded-images"
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
class="embedded-image">
<img :src="img" class="bot-embedded-image"
@click="$emit('openImagePreview', img)" />
<!-- Loading state -->
<div v-if="msg.content.isLoading" class="loading-container">
<span class="loading-text">{{ tm('message.loading') }}</span>
</div>
<template v-else>
<!-- Reasoning Block (Collapsible) -->
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" class="reasoning-container">
<div class="reasoning-header" @click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
<div v-html="md.render(msg.content.reasoning)" class="markdown-content reasoning-text"></div>
</div>
</div>
</div>
<!-- Text -->
<div v-if="msg.content.message && msg.content.message.trim()"
v-html="md.render(msg.content.message)" class="markdown-content"></div>
<!-- Audio -->
<div class="embedded-audio" v-if="msg.content.embedded_audio">
<audio controls class="audio-player">
<source :src="msg.content.embedded_audio" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Image -->
<div class="embedded-images"
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
class="embedded-image">
<img :src="img" class="bot-embedded-image"
@click="$emit('openImagePreview', img)" />
</div>
</div>
<!-- Audio -->
<div class="embedded-audio" v-if="msg.content.embedded_audio">
<audio controls class="audio-player">
<source :src="msg.content.embedded_audio" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
</template>
</div>
<div class="message-actions">
<div class="message-actions" v-if="!msg.content.isLoading">
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
@@ -125,7 +145,8 @@ export default {
copiedMessages: new Set(),
isUserNearBottom: true,
scrollThreshold: 1,
scrollTimer: null
scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
};
},
mounted() {
@@ -142,6 +163,22 @@ export default {
}
},
methods: {
// Toggle reasoning expansion state
toggleReasoning(messageIndex) {
if (this.expandedReasoning.has(messageIndex)) {
this.expandedReasoning.delete(messageIndex);
} else {
this.expandedReasoning.add(messageIndex);
}
// Force reactivity
this.expandedReasoning = new Set(this.expandedReasoning);
},
// Check if reasoning is expanded
isReasoningExpanded(messageIndex) {
return this.expandedReasoning.has(messageIndex);
},
// 复制代码到剪贴板
copyCodeToClipboard(code) {
navigator.clipboard.writeText(code).then(() => {
@@ -348,7 +385,7 @@ export default {
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
transform: translateY(0);
}
to {
@@ -539,6 +576,69 @@ export default {
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
}
.v-theme--dark .reasoning-container {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 8px;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.v-theme--dark .reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.15);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
.v-theme--dark .reasoning-text {
opacity: 0.85;
}
</style>
<style>
@@ -748,6 +848,29 @@ export default {
margin: 10px 0;
}
.loading-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
margin-top: 2px;
}
.loading-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.markdown-content blockquote {
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;
@@ -1,13 +1,13 @@
<template>
<div>
<!-- 选择提供商和模型按钮 -->
<v-btn class="text-none" variant="tonal" rounded="xl" size="small"
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
{{ selectedProviderId }} / {{ selectedModelName }}
</v-btn>
<v-btn variant="tonal" rounded="xl" size="small" v-else @click="openDialog">
</v-chip>
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
选择模型
</v-btn>
</v-chip>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showDialog" max-width="800" persistent>
@@ -1,8 +1,8 @@
<template>
<v-dialog v-model="showDialog" max-width="800px" height="90%">
<v-dialog v-model="showDialog" max-width="800px" height="90%" @after-enter="prepareData">
<v-card
:title="updatingMode ? `${tm('dialog.edit')} ${updatingPlatformConfig.id} ${tm('dialog.adapter')}` : tm('dialog.addPlatform')">
<v-card-text class="pa-4 ml-2" style="overflow-y: auto;">
<v-card-text ref="dialogScrollContainer" class="pa-4 ml-2" style="overflow-y: auto;">
<div class="d-flex align-start" style="width: 100%;">
<div>
<v-icon icon="mdi-numeric-1-circle" class="mr-3"></v-icon>
@@ -74,7 +74,7 @@
<small style="color: grey;" v-if="!updatingMode">默认使用默认配置文件 default您也可以稍后配置</small>
</div>
<div>
<v-btn variant="plain" icon @click="showConfigSection = !showConfigSection" class="mt-2">
<v-btn variant="plain" icon @click="toggleConfigSection" class="mt-2">
<v-icon>{{ showConfigSection ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</div>
@@ -89,10 +89,16 @@
<span>使用现有配置文件</span>
</template>
</v-radio>
<v-select v-if="aBConfigRadioVal === '0'" v-model="selectedAbConfId" :items="configInfoList"
item-title="name" item-value="id" label="选择配置文件" variant="outlined" rounded="md" dense hide-details
style="max-width: 30%; min-width: 200px;" class="ml-10 my-2">
</v-select>
<div class="d-flex align-center ml-10 my-2" v-if="aBConfigRadioVal === '0'">
<v-select v-model="selectedAbConfId" :items="configInfoList" item-title="name"
item-value="id" label="选择配置文件" variant="outlined" rounded="md" dense hide-details
style="max-width: 30%; min-width: 200px;">
</v-select>
<v-btn icon variant="text" density="comfortable" class="ml-2"
:disabled="!selectedAbConfId" @click="openConfigDrawer(selectedAbConfId)">
<v-icon>mdi-arrow-top-right-thick</v-icon>
</v-btn>
</div>
<v-radio value="1" label="创建新配置文件">
</v-radio>
<div class="d-flex align-center" v-if="aBConfigRadioVal === '1'">
@@ -104,7 +110,7 @@
</v-radio-group>
<!-- 现有配置文件预览区域 -->
<div v-if="aBConfigRadioVal === '0' && selectedAbConfId" class="mt-4">
<!-- <div v-if="aBConfigRadioVal === '0' && selectedAbConfId" class="mt-4">
<div v-if="configPreviewLoading" class="d-flex justify-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
@@ -117,8 +123,7 @@
<v-icon>mdi-information-outline</v-icon>
<p class="mt-2">无法加载配置文件预览</p>
</div>
</div>
</div> -->
<!-- 新配置文件编辑区域 -->
<div v-if="aBConfigRadioVal === '1'" class="mt-4">
@@ -145,34 +150,48 @@
添加路由规则
</v-btn>
</div>
<v-btn :color="isEditingRoutes ? 'grey' : 'primary'" variant="tonal" size="small" @click="toggleEditMode">
<v-btn :color="isEditingRoutes ? 'grey' : 'primary'" variant="tonal" size="small"
@click="toggleEditMode">
<v-icon start>{{ isEditingRoutes ? 'mdi-eye' : 'mdi-pencil' }}</v-icon>
{{ isEditingRoutes ? '查看' : '编辑' }}
</v-btn>
</div>
<v-data-table :headers="routeTableHeaders" :items="platformRoutes" item-value="umop"
no-data-text="该平台暂无路由规则将使用默认配置文件" hide-default-footer :items-per-page="-1" class="mt-2" variant="outlined">
no-data-text="该平台暂无路由规则将使用默认配置文件" hide-default-footer :items-per-page="-1" class="mt-2"
variant="outlined">
<template v-slot:item.source="{ item }">
<div class="d-flex align-center" style="min-width: 250px;">
<v-select v-if="isEditingRoutes" v-model="item.messageType" :items="messageTypeOptions" item-title="label" item-value="value"
variant="outlined" density="compact" hide-details style="max-width: 120px;" class="mr-2">
<v-select v-if="isEditingRoutes" v-model="item.messageType" :items="messageTypeOptions"
item-title="label" item-value="value" variant="outlined" density="compact" hide-details
style="max-width: 140px;">
</v-select>
<span v-else class="mr-2">{{ getMessageTypeLabel(item.messageType) }}</span>
<span class="mx-1">:</span>
<v-text-field v-if="isEditingRoutes" v-model="item.sessionId" variant="outlined" density="compact" hide-details
placeholder="会话ID或*" style="max-width: 120px;">
<small v-else>{{ getMessageTypeLabel(item.messageType) }}</small>
<small class="mx-1">:</small>
<v-text-field v-if="isEditingRoutes" v-model="item.sessionId" variant="outlined" density="compact"
hide-details placeholder="会话ID或*">
</v-text-field>
<span v-else>{{ item.sessionId === '*' ? '全部会话' : item.sessionId }}</span>
<small v-else>{{ item.sessionId === '*' ? '全部会话' : item.sessionId }}</small>
</div>
</template>
<template v-slot:item.configId="{ item }">
<v-select v-if="isEditingRoutes" v-model="item.configId" :items="configInfoList" item-title="name" item-value="id"
variant="outlined" density="compact" hide-details style="min-width: 200px;">
</v-select>
<span v-else>{{ getConfigName(item.configId) }}</span>
<div class="d-flex align-center">
<v-select v-if="isEditingRoutes" v-model="item.configId" :items="configInfoList"
item-title="name" item-value="id" variant="outlined" density="compact"
style="min-width: 200px;" hide-details>
</v-select>
<div v-else>
<small>{{ getConfigName(item.configId) }}</small>
</div>
<v-btn icon variant="text" density="compact" class="ml-2"
:disabled="!item.configId" @click="openConfigDrawer(item.configId)">
<v-icon size="18">mdi-arrow-top-right-thick</v-icon>
</v-btn>
</div>
<small v-if="configInfoList.findIndex(c => c.id === item.configId) === -1" style="color: red;"
class="ml-2">配置文件不存在</small>
</template>
<template v-slot:item.actions="{ item, index }">
@@ -180,7 +199,8 @@
<v-btn icon size="x-small" variant="text" @click="moveRouteUp(index)" :disabled="index === 0">
<v-icon>mdi-arrow-up</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" @click="moveRouteDown(index)" :disabled="index === platformRoutes.length - 1">
<v-btn icon size="x-small" variant="text" @click="moveRouteDown(index)"
:disabled="index === platformRoutes.length - 1">
<v-icon>mdi-arrow-down</v-icon>
</v-btn>
<v-btn icon size="x-small" variant="text" color="error" @click="deleteRoute(index)">
@@ -191,7 +211,8 @@
</template>
</v-data-table>
<small class="ml-2 mt-2 d-block">*消息下发时根据会话来源按顺序从上到下匹配首个符合条件的配置文件使用 * 表示匹配所有使用 /sid 指令获取会话 ID</small>
<small class="ml-2 mt-2 d-block" style="color: grey">*消息下发时根据会话来源按顺序从上到下匹配首个符合条件的配置文件使用 * 表示匹配所有使用 /sid 指令获取会话
ID全部不匹配时将使用默认配置文件</small>
</div>
</div>
@@ -253,6 +274,33 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-overlay
v-model="showConfigDrawer"
class="config-drawer-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeConfigDrawer"
>
<v-card class="config-drawer-card" elevation="12">
<div class="config-drawer-header">
<div>
<span class="text-h6">配置文件管理</span>
<div v-if="configDrawerTargetId" class="text-caption text-grey">
ID: {{ configDrawerTargetId }}
</div>
</div>
<v-btn icon variant="text" @click="closeConfigDrawer">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<v-divider></v-divider>
<div class="config-drawer-content">
<ConfigPage v-if="showConfigDrawer" :initial-config-id="configDrawerTargetId" />
</div>
</v-card>
</v-overlay>
</template>
@@ -262,10 +310,11 @@ import { useModuleI18n } from '@/i18n/composables';
import { getPlatformIcon, getPlatformDescription, getTutorialLink } from '@/utils/platformUtils';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import AstrBotCoreConfigWrapper from '@/components/config/AstrBotCoreConfigWrapper.vue';
import ConfigPage from '@/views/ConfigPage.vue';
export default {
name: 'AddNewPlatform',
components: { AstrBotConfig, AstrBotCoreConfigWrapper },
components: { AstrBotConfig, AstrBotCoreConfigWrapper, ConfigPage },
emits: ['update:show', 'show-toast', 'refresh-config'],
props: {
show: {
@@ -318,8 +367,8 @@ export default {
// 平台路由表
platformRoutes: [],
routeTableHeaders: [
{ title: '消息会话来源(消息类型:会话 ID)', key: 'source', sortable: false, width: '40%' },
{ title: '使用配置文件', key: 'configId', sortable: false, width: '40%' },
{ title: '消息会话来源(消息类型:会话 ID)', key: 'source', sortable: false, width: '60%' },
{ title: '使用配置文件', key: 'configId', sortable: false, width: '20%' },
{ title: '操作', key: 'actions', sortable: false, align: 'center', width: '20%' },
],
messageTypeOptions: [
@@ -341,6 +390,10 @@ export default {
loading: false,
showConfigSection: false,
// 配置抽屉
showConfigDrawer: false,
configDrawerTargetId: null,
};
},
setup() {
@@ -437,6 +490,11 @@ export default {
if (newValue && !this.updatingMode && this.aBConfigRadioVal === '0') {
this.getConfigForPreview(this.selectedAbConfId);
}
if (newValue) {
this.$nextTick(() => {
this.scrollDialogToBottom();
});
}
},
// 监听编辑模式变化,自动展开配置文件部分
updatingMode: {
@@ -472,6 +530,9 @@ export default {
this.showConfigSection = false;
this.isEditingRoutes = false; // 重置编辑模式
this.showConfigDrawer = false;
this.configDrawerTargetId = null;
},
closeDialog() {
this.resetForm();
@@ -528,6 +589,19 @@ export default {
const tutorialUrl = getTutorialLink(this.selectedPlatformConfig.type);
window.open(tutorialUrl, '_blank');
},
openConfigDrawer(configId) {
const targetId = configId || 'default';
if (configId && this.configInfoList.findIndex(c => c.id === configId) === -1) {
this.showError('目标配置文件不存在,已打开配置页面以便检查。');
}
this.configDrawerTargetId = targetId;
this.showConfigDrawer = true;
},
closeConfigDrawer() {
this.showConfigDrawer = false;
},
newPlatform() {
this.loading = true;
if (this.updatingMode) {
@@ -674,7 +748,7 @@ export default {
const newConfigId = createRes.data.data.conf_id;
console.log(`成功创建新配置文件 ${configName}ID: ${newConfigId}`);
return newConfigId;
} catch (err) {
console.error('创建新配置文件失败:', err);
@@ -754,7 +828,7 @@ export default {
}
this.platformRoutes = routes;
// 如果没有路由,添加一个默认的空路由供用户编辑
if (this.platformRoutes.length === 0) {
this.platformRoutes.push({
@@ -833,7 +907,7 @@ export default {
const messageType = route.messageType === '*' ? '*' : route.messageType;
const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
const newUmop = `${platformId}:${messageType}:${sessionId}`;
if (route.configId) {
fullRoutingTable[newUmop] = route.configId;
}
@@ -853,6 +927,9 @@ export default {
toggleEditMode() {
this.isEditingRoutes = !this.isEditingRoutes;
},
toggleConfigSection() {
this.showConfigSection = !this.showConfigSection;
},
// 根据配置文件ID获取名称
getConfigName(configId) {
@@ -882,16 +959,30 @@ export default {
toggleShowConfigSection() {
this.showConfigSection = false;
this.showConfigSection = true;
},
prepareData() {
this.getConfigInfoList();
this.getConfigForPreview(this.selectedAbConfId);
if (this.updatingMode && this.updatingPlatformConfig && this.updatingPlatformConfig.id) {
this.getPlatformConfigs(this.updatingPlatformConfig.id);
}
},
scrollDialogToBottom() {
const containerRef = this.$refs.dialogScrollContainer;
const el = containerRef?.$el || containerRef;
if (!el) {
return;
}
const scrollOptions = { top: el.scrollHeight, behavior: 'smooth' };
if (typeof el.scrollTo === 'function') {
el.scrollTo(scrollOptions);
} else {
el.scrollTop = el.scrollHeight;
}
}
},
mounted() {
this.getConfigInfoList();
this.getConfigForPreview(this.selectedAbConfId);
if (this.updatingMode && this.updatingPlatformConfig && this.updatingPlatformConfig.id) {
this.getPlatformConfigs(this.updatingPlatformConfig.id);
}
}
}
</script>
@@ -899,4 +990,30 @@ export default {
.v-select__selection-text {
font-size: 12px;
}
.config-drawer-overlay {
align-items: stretch;
justify-content: flex-end;
}
.config-drawer-card {
width: clamp(320px, 60vw, 820px);
height: calc(100vh - 32px);
display: flex;
flex-direction: column;
margin: 16px;
}
.config-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.config-drawer-content {
flex: 1;
overflow-y: auto;
padding: 16px 16px 24px 16px;
}
</style>
@@ -7,6 +7,8 @@ import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import { useI18n } from '@/i18n/composables'
import axios from 'axios'
import { useToast } from '@/utils/toast'
const props = defineProps({
metadata: {
@@ -40,6 +42,7 @@ const currentEditingKey = ref('')
const currentEditingLanguage = ref('json')
const currentEditingTheme = ref('vs-light')
let currentEditingKeyIterable = null
const loadingEmbeddingDim = ref(false)
function openEditorDialog(key, value, theme, language) {
currentEditingKey.value = key
@@ -49,10 +52,34 @@ function openEditorDialog(key, value, theme, language) {
dialog.value = true
}
function saveEditedContent() {
dialog.value = false
}
async function getEmbeddingDimensions(providerConfig) {
if (loadingEmbeddingDim.value) return
loadingEmbeddingDim.value = true
try {
const response = await axios.post('/api/config/provider/get_embedding_dim', {
provider_config: providerConfig
})
if (response.data.status != "error" && response.data.data?.embedding_dimensions) {
console.log(response.data.data.embedding_dimensions)
providerConfig.embedding_dimensions = response.data.data.embedding_dimensions
useToast().success("获取成功: " + response.data.data.embedding_dimensions)
} else {
useToast().error(response.data.message)
}
} catch (error) {
console.error('Error getting embedding dimensions:', error)
} finally {
loadingEmbeddingDim.value = false
}
}
function getValueBySelector(obj, selector) {
const keys = selector.split('.')
let current = obj
@@ -184,6 +211,29 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-model="iterable[key]"
/>
</div>
<!-- Numeric input with get_embedding_dim button -->
<div v-else-if="metadata[metadataKey].items[key]?._special === 'get_embedding_dim'"
class="d-flex align-center gap-2">
<v-text-field
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<v-btn
color="primary"
variant="tonal"
size="small"
@click="getEmbeddingDimensions(iterable)"
:loading="loadingEmbeddingDim"
class="ml-2"
>
自动检测
</v-btn>
</div>
<!-- List item with options-->
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
class="d-flex flex-wrap gap-20">
@@ -130,19 +130,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
return false
}
// 将 options 和 labels 转换为 v-select 的 items 格式
function getSelectItems(itemMeta) {
if (!itemMeta?.options) {
return []
}
if (itemMeta?.labels && itemMeta.labels.length === itemMeta.options.length) {
return itemMeta.options.map((value, index) => ({
title: itemMeta.labels[index],
value: value
}))
}
return itemMeta.options
}
</script>
<template>
@@ -184,7 +171,8 @@ function getSelectItems(itemMeta) {
<div class="w-100" v-if="!itemMeta?._special">
<!-- Select input for JSON selector -->
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
:items="getSelectItems(itemMeta)" :disabled="itemMeta?.readonly" density="compact" variant="outlined"
:items="itemMeta?.labels ? itemMeta.options.map((value, index) => ({ title: itemMeta.labels[index] || value, value: value })) : itemMeta.options"
:disabled="itemMeta?.readonly" density="compact" variant="outlined"
class="config-field" hide-details></v-select>
<!-- Code Editor for JSON selector -->
@@ -2,6 +2,7 @@
import { ref, computed, inject } from 'vue';
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import UninstallConfirmDialog from './UninstallConfirmDialog.vue';
const props = defineProps({
extension: {
@@ -31,6 +32,7 @@ const emit = defineEmits([
]);
const reveal = ref(false);
const showUninstallDialog = ref(false);
// 国际化
const { tm } = useModuleI18n('features/extension');
@@ -55,19 +57,11 @@ const installExtension = async () => {
};
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error(tm("card.errors.confirmNotRegistered"));
return;
}
showUninstallDialog.value = true;
};
const confirmed = await $confirm({
title: tm("dialogs.uninstall.title"),
message: tm("dialogs.uninstall.message"),
});
if (confirmed) {
emit("uninstall", props.extension);
}
const handleUninstallConfirm = (options: { deleteConfig: boolean; deleteData: boolean }) => {
emit("uninstall", props.extension, options);
};
const toggleActivation = () => {
@@ -84,25 +78,25 @@ const viewReadme = () => {
</script>
<template>
<v-card class="mx-auto d-flex flex-column" elevation="2" :style="{
<v-card class="mx-auto d-flex flex-column" elevation="0" :style="{
position: 'relative',
backgroundColor: useCustomizerStore().uiTheme === 'PurpleTheme' ? marketMode ? '#f8f0dd' : '#ffffff' : '#282833',
color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'
}">
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; gap: 16px; width: 100%;">
<div v-if="extension?.icon">
<v-avatar size="65">
<v-img :src="extension.icon"
:alt="extension.name" cover></v-img>
</v-avatar>
<div v-if="extension?.logo">
<img :src="extension.logo" :alt="extension.name" cover width="100"/>
</div>
<div style="width: 100%;">
<div style="overflow-x: auto;">
<!-- Top-right three-dot menu -->
<div style="position: absolute; right: 8px; top: 8px; z-index: 5;">
<v-menu offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-btn icon variant="text" aria-label="more" v-if="extension?.repo" :href="extension?.repo"
target="_blank">
<v-icon icon="mdi-github"></v-icon>
</v-btn>
<v-btn v-bind="menuProps" icon variant="text" aria-label="more">
<v-icon icon="mdi-dots-vertical"></v-icon>
</v-btn>
@@ -148,7 +142,7 @@ const viewReadme = () => {
<v-list-item @click="viewHandlers">
<v-list-item-title>{{ tm('card.actions.viewHandlers') }} ({{ extension.handlers.length
}})</v-list-item-title>
}})</v-list-item-title>
</v-list-item>
<v-list-item @click="updateExtension" :disabled="!extension?.has_update">
@@ -163,11 +157,12 @@ const viewReadme = () => {
<div style="width: 100%; margin-bottom: 24px;">
<!-- 最多一行 -->
<div class="text-caption" style="color: gray; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 36px;">
<div class="text-caption"
style="color: gray; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 84px;">
{{ extension.author }} / {{ extension.name }}
</div>
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<p class="text-h3 font-weight-black extension-title" :class="{ 'text-h4': $vuetify.display.xs }">
<span class="extension-title__text">{{ extension.display_name?.length ? extension.display_name : extension.name }}</span>
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
@@ -191,7 +186,7 @@ const viewReadme = () => {
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</v-chip>
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length" @click="viewHandlers" style="cursor: pointer;">
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length }}{{ tm("card.status.handlersCount") }}
</v-chip>
@@ -201,15 +196,30 @@ const viewReadme = () => {
</v-chip>
</div>
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="overflow-y: auto; height: 60px;">
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="overflow-y: auto; height: 70px; font-size: 90%;">
{{ extension.desc }}
</div>
</div>
</div>
</v-card-text>
<v-card-actions class="extension-actions">
<v-btn color="primary" size="small" @click="viewReadme">
{{ tm('buttons.viewDocs') }}
</v-btn>
<v-btn v-if="!marketMode" color="primary" size="small" @click="configure">
{{ tm('card.actions.pluginConfig') }}
</v-btn>
</v-card-actions>
</v-card>
<!-- 卸载确认对话框 -->
<UninstallConfirmDialog
v-model="showUninstallDialog"
@confirm="handleUninstallConfirm"
/>
</template>
<style scoped>
@@ -219,9 +229,27 @@ const viewReadme = () => {
margin-left: 12px;
}
.extension-title {
display: flex;
align-items: center;
}
.extension-title__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-top: 6px
}
@media (max-width: 600px) {
.extension-image-container {
margin-left: 8px;
}
}
.extension-actions {
margin-top: auto;
gap: 8px;
}
</style>
@@ -1,12 +1,25 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
<div class="d-flex align-center justify-space-between" style="gap: 8px;">
<div style="flex: 1; min-width: 0; overflow: hidden;">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
未选择
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
v-for="name in modelValue"
:key="name"
size="small"
color="primary"
variant="tonal"
closable
@click:close="removeKnowledgeBase(name)"
style="max-width: 100%;">
<span class="text-truncate" style="max-width: 200px;">{{ name }}</span>
</v-chip>
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog" style="flex-shrink: 0;">
{{ buttonText }}
</v-btn>
</div>
@@ -21,86 +34,57 @@
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<!-- 插件未安装提示 -->
<div v-if="!loading && !pluginInstalled" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-puzzle-outline</v-icon>
<p class="text-grey mt-4 mb-4">知识库插件未安装</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
前往知识库页面
</v-btn>
</div>
<!-- 知识库列表 -->
<v-list v-else-if="!loading && pluginInstalled" density="compact">
<!-- 不使用选项 -->
<v-list-item
:value="''"
@click="selectKnowledgeBase({ collection_name: '' })"
:active="selectedKnowledgeBase === ''"
rounded="md"
class="ma-1">
<template v-slot:prepend>
<v-icon color="grey-lighten-1">mdi-close-circle-outline</v-icon>
</template>
<v-list-item-title>不使用</v-list-item-title>
<v-list-item-subtitle>不使用任何知识库</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedKnowledgeBase === ''" color="primary">mdi-check-circle</v-icon>
</template>
</v-list-item>
<v-divider v-if="knowledgeBaseList.length > 0" class="my-2"></v-divider>
<v-list v-if="!loading" density="compact">
<!-- 知识库选项 -->
<v-list-item
v-for="kb in knowledgeBaseList"
:key="kb.collection_name"
:value="kb.collection_name"
@click="selectKnowledgeBase(kb)"
:active="selectedKnowledgeBase === kb.collection_name"
:key="kb.kb_id"
:value="kb.kb_name"
@click="selectKnowledgeBase(kb.kb_name)"
:active="isSelected(kb.kb_name)"
rounded="md"
class="ma-1">
<template v-slot:prepend>
<span class="emoji-icon">{{ kb.emoji || '🙂' }}</span>
<span class="emoji-icon">{{ kb.emoji || '📚' }}</span>
</template>
<v-list-item-title>{{ kb.collection_name }}</v-list-item-title>
<v-list-item-title>{{ kb.kb_name }}</v-list-item-title>
<v-list-item-subtitle>
{{ kb.description || '无描述' }}
<span v-if="kb.count !== undefined"> - {{ kb.count }} 项知识</span>
<span v-if="kb.doc_count !== undefined"> - {{ kb.doc_count }} 个文档</span>
<span v-if="kb.chunk_count !== undefined"> - {{ kb.chunk_count }} 个块</span>
</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedKnowledgeBase === kb.collection_name" color="primary">mdi-check-circle</v-icon>
<v-icon v-if="isSelected(kb.kb_name)" color="primary">
mdi-checkbox-marked
</v-icon>
<v-icon v-else color="grey-lighten-1">
mdi-checkbox-blank-outline
</v-icon>
</template>
</v-list-item>
<!-- 当没有知识库时显示创建提示 -->
<div v-if="knowledgeBaseList.length === 0" class="text-center py-4">
<p class="text-grey mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" size="small" @click="goToKnowledgeBasePage">
<div v-if="knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
创建知识库
</v-btn>
</div>
</v-list>
<!-- 空状态插件未安装时保留原有逻辑 -->
<div v-else-if="!loading && !pluginInstalled && knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
创建知识库
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pa-4">
<div v-if="selectedKnowledgeBases.length > 0" class="text-caption text-grey">
已选择 {{ selectedKnowledgeBases.length }} 个知识库
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="selectedKnowledgeBase === null || selectedKnowledgeBase === undefined">
@click="confirmSelection">
确认选择
</v-btn>
</v-card-actions>
@@ -115,8 +99,8 @@ import { useRouter } from 'vue-router'
const props = defineProps({
modelValue: {
type: String,
default: ''
type: Array,
default: () => []
},
buttonText: {
type: String,
@@ -130,74 +114,87 @@ const router = useRouter()
const dialog = ref(false)
const knowledgeBaseList = ref([])
const loading = ref(false)
const selectedKnowledgeBase = ref('')
const pluginInstalled = ref(false)
const selectedKnowledgeBases = ref([])
// 监听 modelValue 变化,同步到 selectedKnowledgeBase
// 监听 modelValue 变化,同步到 selectedKnowledgeBases
watch(() => props.modelValue, (newValue) => {
selectedKnowledgeBase.value = newValue || ''
selectedKnowledgeBases.value = Array.isArray(newValue) ? [...newValue] : []
}, { immediate: true })
async function openDialog() {
selectedKnowledgeBase.value = props.modelValue || ''
// 初始化选中状态
selectedKnowledgeBases.value = Array.isArray(props.modelValue)
? [...props.modelValue]
: []
dialog.value = true
await checkPluginAndLoadKnowledgeBases()
await loadKnowledgeBases()
}
async function checkPluginAndLoadKnowledgeBases() {
async function loadKnowledgeBases() {
loading.value = true
try {
// 首先检查插件是否安装
const pluginResponse = await axios.get('/api/plugin/get?name=astrbot_plugin_knowledge_base')
const response = await axios.get('/api/kb/list', {
params: {
page: 1,
page_size: 100
}
})
if (pluginResponse.data.status === 'ok' && pluginResponse.data.data.length > 0) {
pluginInstalled.value = true
// 插件已安装,获取知识库列表
await loadKnowledgeBases()
if (response.data.status === 'ok') {
knowledgeBaseList.value = response.data.data.items || []
} else {
pluginInstalled.value = false
console.error('加载知识库列表失败:', response.data.message)
knowledgeBaseList.value = []
}
} catch (error) {
console.error('检查知识库插件失败:', error)
pluginInstalled.value = false
console.error('加载知识库列表失败:', error)
knowledgeBaseList.value = []
} finally {
loading.value = false
}
}
async function loadKnowledgeBases() {
try {
const response = await axios.get('/api/plug/alkaid/kb/collections')
if (response.data.status === 'ok') {
knowledgeBaseList.value = response.data.data || []
} else {
knowledgeBaseList.value = []
}
} catch (error) {
console.error('加载知识库列表失败:', error)
knowledgeBaseList.value = []
function isSelected(kbName) {
return selectedKnowledgeBases.value.includes(kbName)
}
function selectKnowledgeBase(kbName) {
// 多选模式:切换选中状态
const index = selectedKnowledgeBases.value.indexOf(kbName)
if (index > -1) {
selectedKnowledgeBases.value.splice(index, 1)
} else {
selectedKnowledgeBases.value.push(kbName)
}
}
function selectKnowledgeBase(kb) {
selectedKnowledgeBase.value = kb.collection_name
function removeKnowledgeBase(kbName) {
const index = selectedKnowledgeBases.value.indexOf(kbName)
if (index > -1) {
selectedKnowledgeBases.value.splice(index, 1)
}
// 立即更新父组件
emit('update:modelValue', [...selectedKnowledgeBases.value])
}
function confirmSelection() {
emit('update:modelValue', selectedKnowledgeBase.value)
emit('update:modelValue', [...selectedKnowledgeBases.value])
dialog.value = false
}
function cancelSelection() {
selectedKnowledgeBase.value = props.modelValue || ''
// 恢复到原始值
selectedKnowledgeBases.value = Array.isArray(props.modelValue)
? [...props.modelValue]
: []
dialog.value = false
}
function goToKnowledgeBasePage() {
dialog.value = false
router.push('/alkaid/knowledge-base')
router.push('/knowledge-base')
}
</script>
@@ -222,4 +219,15 @@ function goToKnowledgeBasePage() {
align-items: center;
justify-content: center;
}
.gap-1 {
gap: 4px;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
</style>
-4
View File
@@ -70,10 +70,6 @@ const formatTitle = (title: string) => {
transition: transform 0.3s ease;
}
.logo-image img:hover {
transform: scale(1.05);
}
.logo-text {
display: flex;
flex-direction: column;
@@ -268,7 +268,12 @@ export default {
watch: {
modelValue(newValue) {
if (newValue) {
this.initForm();
// 只有在不是编辑状态时才初始化空表单
if (this.editingPersona) {
this.initFormWithPersona(this.editingPersona);
} else {
this.initForm();
}
this.loadMcpServers();
this.loadTools();
}
@@ -276,10 +281,13 @@ export default {
editingPersona: {
immediate: true,
handler(newPersona) {
if (newPersona) {
this.initFormWithPersona(newPersona);
} else {
this.initForm();
// 只有在对话框打开时才处理editingPersona的变化
if (this.modelValue) {
if (newPersona) {
this.initFormWithPersona(newPersona);
} else {
this.initForm();
}
}
}
},
@@ -0,0 +1,290 @@
<template>
<div style="margin-top: 16px;">
<v-btn
color="primary"
variant="outlined"
size="small"
@click="openDialog"
style="margin-bottom: 8px;"
>
{{ t('features.settings.sidebar.customize.title') }}
</v-btn>
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>{{ t('features.settings.sidebar.customize.title') }}</span>
<v-btn
icon="mdi-close"
variant="text"
@click="dialog = false"
></v-btn>
</v-card-title>
<v-card-text>
<p class="text-body-2 mb-4">{{ t('features.settings.sidebar.customize.subtitle') }}</p>
<v-row>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.mainItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'main')"
>
<v-list-item
v-for="(item, index) in mainItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'main', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'main', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-right"
variant="text"
size="x-small"
@click="moveToMore(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="6">
<div class="mb-2 font-weight-medium">{{ t('features.settings.sidebar.customize.moreItems') }}</div>
<v-list
density="compact"
class="custom-list"
@dragover.prevent
@drop="handleDropToList($event, 'more')"
>
<v-list-item
v-for="(item, index) in moreItems"
:key="item.title"
class="mb-1 draggable-item"
draggable="true"
@dragstart="handleDragStart($event, 'more', index)"
@dragover.prevent
@drop.stop="handleDrop($event, 'more', index)"
>
<template v-slot:prepend>
<v-icon :icon="item.icon" size="small" class="mr-2"></v-icon>
</template>
<v-list-item-title>{{ t(item.title) }}</v-list-item-title>
<template v-slot:append>
<v-btn
icon="mdi-arrow-left"
variant="text"
size="x-small"
@click="moveToMain(index)"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-btn
color="error"
variant="text"
@click="resetToDefault"
>
{{ t('features.settings.sidebar.customize.reset') }}
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="saveCustomization"
>
{{ t('core.actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from '@/i18n/composables';
import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
clearSidebarCustomization
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
const dialog = ref(false);
const mainItems = ref([]);
const moreItems = ref([]);
const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
if (customization) {
// Load from customization
const allItemsMap = new Map();
sidebarItems.forEach(item => {
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
}
function openDialog() {
initializeItems();
dialog.value = true;
}
function handleDragStart(event, listType, index) {
draggedItem.value = {
type: listType,
index: index,
item: listType === 'main' ? mainItems.value[index] : moreItems.value[index]
};
event.dataTransfer.effectAllowed = 'move';
}
function handleDrop(event, targetListType, targetIndex) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target
if (targetListType === 'main') {
mainItems.value.splice(targetIndex, 0, item);
} else {
moreItems.value.splice(targetIndex, 0, item);
}
draggedItem.value = null;
}
function handleDropToList(event, targetListType) {
event.preventDefault();
if (!draggedItem.value) return;
const sourceListType = draggedItem.value.type;
const sourceIndex = draggedItem.value.index;
const item = draggedItem.value.item;
// Remove from source
if (sourceListType === 'main') {
mainItems.value.splice(sourceIndex, 1);
} else {
moreItems.value.splice(sourceIndex, 1);
}
// Add to target list at the end
if (targetListType === 'main') {
mainItems.value.push(item);
} else {
moreItems.value.push(item);
}
draggedItem.value = null;
}
function moveToMore(index) {
const item = mainItems.value.splice(index, 1)[0];
moreItems.value.push(item);
}
function moveToMain(index) {
const item = moreItems.value.splice(index, 1)[0];
mainItems.value.push(item);
}
function saveCustomization() {
const config = {
mainItems: mainItems.value.map(item => item.title),
moreItems: moreItems.value.map(item => item.title)
};
setSidebarCustomization(config);
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
dialog.value = false;
}
function resetToDefault() {
clearSidebarCustomization();
initializeItems();
// Notify the sidebar to reload
window.dispatchEvent(new CustomEvent('sidebar-customization-changed'));
}
onMounted(() => {
initializeItems();
});
</script>
<style scoped>
.draggable-item {
cursor: move;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
background-color: rgba(var(--v-theme-surface));
transition: all 0.2s;
}
.draggable-item:hover {
background-color: rgba(var(--v-theme-primary), 0.1);
border-color: rgba(var(--v-theme-primary), 0.3);
}
.custom-list {
min-height: 200px;
border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
padding: 8px;
}
</style>
@@ -0,0 +1,135 @@
<template>
<v-dialog
v-model="show"
max-width="500"
@click:outside="handleCancel"
@keydown.esc="handleCancel"
>
<v-card>
<v-card-title class="text-h5">
{{ tm('dialogs.uninstall.title') }}
</v-card-title>
<v-card-text>
<div class="mb-4">
{{ tm('dialogs.uninstall.message') }}
</div>
<v-divider class="my-4"></v-divider>
<div class="text-subtitle-2 mb-3">{{ t('core.common.actions') }}:</div>
<v-checkbox
v-model="deleteConfig"
:label="tm('dialogs.uninstall.deleteConfig')"
color="warning"
hide-details
class="mb-2"
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.configHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-checkbox
v-model="deleteData"
:label="tm('dialogs.uninstall.deleteData')"
color="error"
hide-details
>
<template v-slot:append>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="grey">mdi-information-outline</v-icon>
</template>
<span>{{ tm('dialogs.uninstall.dataHint') }}</span>
</v-tooltip>
</template>
</v-checkbox>
<v-alert
v-if="deleteConfig || deleteData"
type="warning"
variant="tonal"
density="compact"
class="mt-4"
>
<template v-slot:prepend>
<v-icon>mdi-alert</v-icon>
</template>
{{ t('messages.validation.operation_cannot_be_undone') }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
variant="text"
@click="handleCancel"
>
{{ t('core.common.cancel') }}
</v-btn>
<v-btn
color="error"
variant="elevated"
@click="handleConfirm"
>
{{ t('core.common.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/extension');
const show = ref(props.modelValue);
const deleteConfig = ref(false);
const deleteData = ref(false);
watch(() => props.modelValue, (val) => {
show.value = val;
if (val) {
// 重置选项
deleteConfig.value = false;
deleteData.value = false;
}
});
watch(show, (val) => {
emit('update:modelValue', val);
});
const handleConfirm = () => {
emit('confirm', {
deleteConfig: deleteConfig.value,
deleteData: deleteData.value,
});
show.value = false;
};
const handleCancel = () => {
emit('cancel');
show.value = false;
};
</script>
@@ -0,0 +1,145 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
export interface Conversation {
cid: string;
title: string;
updated_at: number;
}
export function useConversations(chatboxMode: boolean = false) {
const router = useRouter();
const conversations = ref<Conversation[]>([]);
const selectedConversations = ref<string[]>([]);
const currCid = ref('');
const pendingCid = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
const editingCid = ref('');
const getCurrentConversation = computed(() => {
if (!currCid.value) return null;
return conversations.value.find(c => c.cid === currCid.value);
});
async function getConversations() {
try {
const response = await axios.get('/api/chat/conversations');
conversations.value = response.data.data;
// 处理待加载的会话
if (pendingCid.value) {
const conversation = conversations.value.find(c => c.cid === pendingCid.value);
if (conversation) {
selectedConversations.value = [pendingCid.value];
pendingCid.value = null;
}
} else if (!currCid.value && conversations.value.length > 0) {
// 默认选择第一个会话
const firstConversation = conversations.value[0];
selectedConversations.value = [firstConversation.cid];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
}
}
async function newConversation() {
try {
const response = await axios.get('/api/chat/new_conversation');
const cid = response.data.data.conversation_id;
currCid.value = cid;
// 更新 URL
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(`${basePath}/${cid}`);
await getConversations();
return cid;
} catch (err) {
console.error(err);
throw err;
}
}
async function deleteConversation(cid: string) {
try {
await axios.get('/api/chat/delete_conversation?conversation_id=' + cid);
await getConversations();
currCid.value = '';
selectedConversations.value = [];
} catch (err) {
console.error(err);
}
}
function showEditTitleDialog(cid: string, title: string) {
editingCid.value = cid;
editingTitle.value = title || '';
editTitleDialog.value = true;
}
async function saveTitle() {
if (!editingCid.value) return;
const trimmedTitle = editingTitle.value.trim();
try {
await axios.post('/api/chat/rename_conversation', {
conversation_id: editingCid.value,
title: trimmedTitle
});
// 更新本地会话标题
const conversation = conversations.value.find(c => c.cid === editingCid.value);
if (conversation) {
conversation.title = trimmedTitle;
}
editTitleDialog.value = false;
} catch (err) {
console.error('重命名对话失败:', err);
}
}
function updateConversationTitle(cid: string, title: string) {
const conversation = conversations.value.find(c => c.cid === cid);
if (conversation) {
conversation.title = title;
}
}
function newChat(closeMobileSidebar?: () => void) {
currCid.value = '';
selectedConversations.value = [];
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(basePath);
if (closeMobileSidebar) {
closeMobileSidebar();
}
}
return {
conversations,
selectedConversations,
currCid,
pendingCid,
editTitleDialog,
editingTitle,
editingCid,
getCurrentConversation,
getConversations,
newConversation,
deleteConversation,
showEditTitleDialog,
saveTitle,
updateConversationTitle,
newChat
};
}
@@ -0,0 +1,104 @@
import { ref } from 'vue';
import axios from 'axios';
export function useMediaHandling() {
const stagedImagesName = ref<string[]>([]);
const stagedImagesUrl = ref<string[]>([]);
const stagedAudioUrl = ref<string>('');
const mediaCache = ref<Record<string, string>>({});
async function getMediaFile(filename: string): Promise<string> {
if (mediaCache.value[filename]) {
return mediaCache.value[filename];
}
try {
const response = await axios.get('/api/chat/get_file', {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
mediaCache.value[filename] = blobUrl;
return blobUrl;
} catch (error) {
console.error('Error fetching media file:', error);
return '';
}
}
async function processAndUploadImage(file: File) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const img = response.data.data.filename;
stagedImagesName.value.push(img);
stagedImagesUrl.value.push(URL.createObjectURL(file));
} catch (err) {
console.error('Error uploading image:', err);
}
}
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
await processAndUploadImage(file);
}
}
}
}
function removeImage(index: number) {
const urlToRevoke = stagedImagesUrl.value[index];
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
stagedImagesName.value.splice(index, 1);
stagedImagesUrl.value.splice(index, 1);
}
function removeAudio() {
stagedAudioUrl.value = '';
}
function clearStaged() {
stagedImagesName.value = [];
stagedImagesUrl.value = [];
stagedAudioUrl.value = '';
}
function cleanupMediaCache() {
Object.values(mediaCache.value).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
mediaCache.value = {};
}
return {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
};
}
+304
View File
@@ -0,0 +1,304 @@
import { ref, reactive, type Ref } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
export interface MessageContent {
type: string;
message: string;
reasoning?: string;
image_url?: string[];
audio_url?: string;
embedded_images?: string[];
embedded_audio?: string;
isLoading?: boolean;
}
export interface Message {
content: MessageContent;
}
export function useMessages(
currSessionId: Ref<string>,
getMediaFile: (filename: string) => Promise<string>,
updateSessionTitle: (sessionId: string, title: string) => void,
onSessionsUpdate: () => void
) {
const messages = ref<Message[]>([]);
const isStreaming = ref(false);
const isConvRunning = ref(false);
const isToastedRunningInfo = ref(false);
const activeSSECount = ref(0);
const enableStreaming = ref(true);
// 从 localStorage 读取流式响应开关状态
const savedStreamingState = localStorage.getItem('enableStreaming');
if (savedStreamingState !== null) {
enableStreaming.value = JSON.parse(savedStreamingState);
}
function toggleStreaming() {
enableStreaming.value = !enableStreaming.value;
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
}
async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return;
try {
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
isConvRunning.value = response.data.data.is_running || false;
let history = response.data.data.history;
if (isConvRunning.value) {
if (!isToastedRunningInfo.value) {
useToast().info("该会话正在运行中。", { timeout: 5000 });
isToastedRunningInfo.value = true;
}
// 如果会话还在运行,3秒后重新获取消息
setTimeout(() => {
getSessionMessages(currSessionId.value, router);
}, 3000);
}
// 处理历史消息中的媒体文件
for (let i = 0; i < history.length; i++) {
let content = history[i].content;
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
}
}
if (content.audio_url) {
content.audio_url = await getMediaFile(content.audio_url);
}
}
messages.value = history;
} catch (err) {
console.error(err);
}
}
async function sendMessage(
prompt: string,
imageNames: string[],
audioName: string,
selectedProviderId: string,
selectedModelName: string
) {
// Create user message
const userMessage: MessageContent = {
type: 'user',
message: prompt,
image_url: [],
audio_url: undefined
};
// Convert image filenames to blob URLs
if (imageNames.length > 0) {
const imagePromises = imageNames.map(name => {
if (!name.startsWith('blob:')) {
return getMediaFile(name);
}
return Promise.resolve(name);
});
userMessage.image_url = await Promise.all(imagePromises);
}
// Convert audio filename to blob URL
if (audioName) {
if (!audioName.startsWith('blob:')) {
userMessage.audio_url = await getMediaFile(audioName);
} else {
userMessage.audio_url = audioName;
}
}
messages.value.push({ content: userMessage });
// 添加一个加载中的机器人消息占位符
const loadingMessage = reactive({
type: 'bot',
message: '',
reasoning: '',
isLoading: true
});
messages.value.push({ content: loadingMessage });
try {
activeSSECount.value++;
if (activeSSECount.value === 1) {
isConvRunning.value = true;
}
const response = await fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({
message: prompt,
session_id: currSessionId.value,
image_url: imageNames,
audio_url: audioName ? [audioName] : [],
selected_provider: selectedProviderId,
selected_model: selectedModelName,
enable_streaming: enableStreaming.value
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let in_streaming = false;
let message_obj: any = null;
isStreaming.value = true;
while (true) {
try {
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
let chunk_json;
try {
chunk_json = JSON.parse(line.replace('data: ', ''));
} catch (parseError) {
console.warn('JSON解析失败:', line, parseError);
continue;
}
if (!chunk_json || typeof chunk_json !== 'object' || !chunk_json.hasOwnProperty('type')) {
console.warn('无效的数据对象:', chunk_json);
continue;
}
if (chunk_json.type === 'error') {
console.error('Error received:', chunk_json.data);
continue;
}
if (chunk_json.type === 'image') {
let img = chunk_json.data.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_images: [imageUrl]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'record') {
let audio = chunk_json.data.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_audio: audioUrl
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) {
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
message_obj = reactive({
type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data,
reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
if (chain_type === 'reasoning') {
// 使用 reactive 对象,直接修改属性会触发响应式更新
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
} else {
message_obj.message = (message_obj.message || '') + chunk_json.data;
}
}
} else if (chunk_json.type === 'update_title') {
updateSessionTitle(chunk_json.session_id, chunk_json.data);
}
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
in_streaming = false;
if (!chunk_json.streaming) {
isStreaming.value = false;
}
}
}
} catch (readError) {
console.error('SSE读取错误:', readError);
break;
}
}
// 获取最新的会话列表
onSessionsUpdate();
} catch (err) {
console.error('发送消息失败:', err);
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
} finally {
isStreaming.value = false;
activeSSECount.value--;
if (activeSSECount.value === 0) {
isConvRunning.value = false;
}
}
}
return {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages,
sendMessage,
toggleStreaming
};
}
+74
View File
@@ -0,0 +1,74 @@
import { ref } from 'vue';
import axios from 'axios';
export function useRecording() {
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(null);
async function startRecording(onStart?: (label: string) => void) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
};
mediaRecorder.value.start();
isRecording.value = true;
if (onStart) {
onStart('录音中...');
}
} catch (error) {
console.error('Failed to start recording:', error);
}
}
async function stopRecording(onStop?: (label: string) => void): Promise<string> {
return new Promise((resolve, reject) => {
if (!mediaRecorder.value) {
reject('No media recorder');
return;
}
isRecording.value = false;
if (onStop) {
onStop('聊天输入框');
}
mediaRecorder.value.stop();
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
audioChunks.value = [];
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
const formData = new FormData();
formData.append('file', audioBlob);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const audio = response.data.data.filename;
console.log('Audio uploaded:', audio);
resolve(audio);
} catch (err) {
console.error('Error uploading audio:', err);
reject(err);
}
};
});
}
return {
isRecording,
startRecording,
stopRecording
};
}
+145
View File
@@ -0,0 +1,145 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
export interface Session {
session_id: string;
display_name: string;
updated_at: string;
}
export function useSessions(chatboxMode: boolean = false) {
const router = useRouter();
const sessions = ref<Session[]>([]);
const selectedSessions = ref<string[]>([]);
const currSessionId = ref('');
const pendingSessionId = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
const editingSessionId = ref('');
const getCurrentSession = computed(() => {
if (!currSessionId.value) return null;
return sessions.value.find(s => s.session_id === currSessionId.value);
});
async function getSessions() {
try {
const response = await axios.get('/api/chat/sessions');
sessions.value = response.data.data;
// 处理待加载的会话
if (pendingSessionId.value) {
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (!currSessionId.value && sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
}
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
// 更新 URL
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(`${basePath}/${sessionId}`);
await getSessions();
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
async function deleteSession(sessionId: string) {
try {
await axios.get('/api/chat/delete_session?session_id=' + sessionId);
await getSessions();
currSessionId.value = '';
selectedSessions.value = [];
} catch (err) {
console.error(err);
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
editTitleDialog.value = true;
}
async function saveTitle() {
if (!editingSessionId.value) return;
const trimmedTitle = editingTitle.value.trim();
try {
await axios.post('/api/chat/update_session_display_name', {
session_id: editingSessionId.value,
display_name: trimmedTitle
});
// 更新本地会话标题
const session = sessions.value.find(s => s.session_id === editingSessionId.value);
if (session) {
session.display_name = trimmedTitle;
}
editTitleDialog.value = false;
} catch (err) {
console.error('重命名会话失败:', err);
}
}
function updateSessionTitle(sessionId: string, title: string) {
const session = sessions.value.find(s => s.session_id === sessionId);
if (session) {
session.display_name = title;
}
}
function newChat(closeMobileSidebar?: () => void) {
currSessionId.value = '';
selectedSessions.value = [];
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(basePath);
if (closeMobileSidebar) {
closeMobileSidebar();
}
}
return {
sessions,
selectedSessions,
currSessionId,
pendingSessionId,
editTitleDialog,
editingTitle,
editingSessionId,
getCurrentSession,
getSessions,
newSession,
deleteSession,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
newChat
};
}
@@ -18,5 +18,6 @@
"refresh": "Refresh",
"submit": "Submit",
"reset": "Reset",
"clear": "Clear"
"clear": "Clear",
"save": "Save"
}
@@ -72,8 +72,9 @@
"enabled": "Enabled",
"disabled": "Disabled",
"delete": "Delete",
"copy": "Copy",
"edit": "Edit",
"copy": "Copy",
"noData": "No data available"
}
}
}
@@ -32,7 +32,6 @@
"issueLink": "GitHub Issues"
},
"tip": "💡 TIP:",
"tipLink": "",
"tipContinue": "By default, the corresponding version of the WebUI files will be downloaded when switching versions. The WebUI code is located in the dashboard directory of the project, and you can use npm to build it yourself.",
"dockerTip": "When switching versions, it will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use",
"dockerTipLink": "watchtower",
@@ -56,6 +55,9 @@
"linkText": "View master branch commit history (click copy on the right to copy)",
"confirm": "Confirm Switch"
},
"releaseNotes": {
"title": "Release Notes"
},
"dashboardUpdate": {
"title": "Update Dashboard to Latest Version Only",
"currentVersion": "Current Version",
@@ -88,4 +90,4 @@
"updateFailed": "Update failed, please try again"
}
}
}
}
@@ -5,8 +5,8 @@
"persona": "Persona",
"toolUse": "MCP Tools",
"config": "Config",
"extension": "Extensions",
"chat": "Chat",
"extension": "Extensions",
"conversation": "Conversations",
"sessionManagement": "Session Management",
"console": "Console",
@@ -20,4 +20,4 @@
"groups": {
"more": "More Features"
}
}
}
@@ -6,7 +6,7 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "Knowledge Base",
"knowledgeBase": "Knowledge Base (Plugin)",
"longTermMemory": "Long-term Memory",
"other": "..."
}
@@ -2,6 +2,7 @@
"login": "Login",
"username": "Username",
"password": "Password",
"defaultHint": "Default username and password: astrbot",
"logo": {
"title": "AstrBot Dashboard",
"subtitle": "Welcome"
@@ -47,7 +47,11 @@
"noHistory": "No conversation history",
"systemStatus": "System Status",
"llmService": "LLM Service",
"speechToText": "Speech to Text"
"speechToText": "Speech to Text",
"editDisplayName": "Edit Session Name",
"displayName": "Session Name",
"displayNameUpdated": "Session name updated",
"displayNameUpdateFailed": "Failed to update session name"
},
"modes": {
"darkMode": "Switch to Dark Mode",
@@ -57,6 +61,15 @@
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image"
},
"streaming": {
"enabled": "Streaming enabled",
"disabled": "Streaming disabled",
"on": "Stream",
"off": "Normal"
},
"reasoning": {
"thinking": "Thinking Process"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",
@@ -30,7 +30,11 @@
"configApplyError": "Configuration not applied, JSON format error.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration"
"loadError": "Failed to load configuration",
"deleteSuccess": "Deleted successfully",
"deleteError": "Failed to delete",
"updateSuccess": "Updated successfully",
"updateError": "Failed to update"
},
"sections": {
"general": "General Settings",
@@ -59,4 +63,4 @@
"rateLimit": "Rate Limit",
"encryption": "Encryption Settings"
}
}
}
@@ -79,6 +79,14 @@
"devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository"
},
"sort": {
"default": "Default",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"ascending": "Ascending",
"descending": "Descending"
},
"tags": {
"danger": "Danger"
},
@@ -97,7 +105,11 @@
},
"uninstall": {
"title": "Confirm Deletion",
"message": "Are you sure you want to delete this extension?"
"message": "Are you sure you want to delete this extension?",
"deleteConfig": "Also delete plugin configuration file",
"deleteData": "Also delete plugin persistent data",
"configHint": "Configuration file located in data/config directory",
"dataHint": "Deletes data in data/plugin_data and data/plugins_data"
},
"install": {
"title": "Install Extension",
@@ -0,0 +1,118 @@
{
"title": "Knowledge Base Details",
"backToList": "Back to List",
"tabs": {
"overview": "Overview",
"documents": "Documents",
"retrieval": "Retrieval",
"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: ",
"maxSize": "Max file size: 128MB",
"chunkSettings": "Chunk Settings",
"batchSettings": "Batch Settings",
"cleaningSettings": "Cleaning Settings",
"enableCleaning": "Enable Content Cleaning",
"cleaningProvider": "Cleaning Service Provider",
"cleaningProviderHint": "Select an LLM provider to clean and summarize the extracted web page content",
"chunkSize": "Chunk Size",
"chunkSizeHint": "Number of characters per chunk (default: 512)",
"chunkOverlap": "Chunk Overlap",
"chunkOverlapHint": "Overlapping characters between chunks (default: 50)",
"batchSize": "Batch Size",
"batchSizeHint": "Number of chunks to process in each batch (default: 32)",
"tasksLimit": "Concurrent Tasks Limit",
"tasksLimitHint": "Maximum number of concurrent upload tasks (default: 3)",
"maxRetries": "Max Retries",
"maxRetriesHint": "Number of times to retry a failed upload task (default: 3)",
"cancel": "Cancel",
"submit": "Upload",
"fileRequired": "Please select a file to upload",
"fileUpload": "File Upload",
"fromUrl": "From URL",
"urlPlaceholder": "Enter the URL of the web page to extract content from",
"urlRequired": "Please enter a URL",
"urlHint": "The main content will be automatically extracted from the target URL as a document. Currently supports {supported} pages. Before use, please ensure that the target web page allows crawler access.",
"beta": "Beta"
},
"retrieval": {
"title": "Retrieval",
"subtitle": "Test the knowledge base using dense and sparse retrieval methods",
"query": "Query",
"queryPlaceholder": "Enter a query...",
"search": "Search",
"searching": "Searching...",
"results": "Results",
"noResults": "No results found",
"tryDifferentQuery": "Try a different query",
"settings": "Retrieval Settings",
"topK": "Number of Results",
"topKHint": "Maximum number of results to return",
"enableRerank": "Enable Rerank",
"enableRerankHint": "Use a rerank model to improve retrieval quality",
"score": "Relevance Score",
"document": "Document",
"chunk": "Chunk #{index}",
"content": "Content",
"charCount": "{count} characters",
"searchSuccess": "Search completed, found {count} results",
"searchFailed": "Search failed",
"queryRequired": "Please enter a query"
},
"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,55 @@
{
"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...",
"showing": "Showing",
"deleteConfirm": "Are you sure you want to delete this chunk?",
"deleteSuccess": "Chunk deleted successfully",
"deleteFailed": "Failed to delete chunk"
},
"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,67 @@
{
"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}",
"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"
}
}
@@ -30,6 +30,7 @@
"ttsProvider": "TTS Provider",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"knowledgeBase": "Knowledge Base",
"pluginManagement": "Plugin Management",
"actions": "Actions"
}
@@ -67,6 +68,34 @@
"fullSessionId": "Full Session ID",
"hint": "Custom names help you easily identify sessions. The small information icon (!) will show the actual UMO when hovering."
},
"knowledgeBase": {
"title": "Knowledge Base Configuration",
"configure": "Configure",
"selectKB": "Select Knowledge Bases",
"selectMultiple": "You can select multiple knowledge bases",
"noKBAvailable": "No knowledge bases available",
"noKBDesc": "No knowledge bases have been created yet",
"createKB": "Create Knowledge Base",
"advancedSettings": "Advanced Settings",
"topK": "Result Count",
"topKHint": "Number of results to retrieve from knowledge base",
"enableRerank": "Enable Reranking",
"enableRerankHint": "Use reranking model to improve retrieval quality",
"clearConfig": "Clear Configuration",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading knowledge base configuration...",
"description": "Configure knowledge bases for this session. The session will use configured knowledge bases to enhance conversation context.",
"saveSuccess": "Knowledge base configuration saved successfully",
"saveFailed": "Failed to save knowledge base configuration",
"loadFailed": "Failed to load knowledge base configuration",
"clearSuccess": "Knowledge base configuration cleared",
"clearFailed": "Failed to clear knowledge base configuration",
"clearConfirm": "Are you sure you want to clear the knowledge base configuration for this session?"
},
"list": {
"documents": "documents"
},
"deleteConfirm": {
"message": "Are you sure you want to delete session {sessionName}?",
"warning": "This action will permanently delete all chat history and preference settings for this session (except for data linked via plugins), and this cannot be undone. Continue?"
@@ -19,5 +19,15 @@
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
"button": "Start Migration Assistant"
}
},
"sidebar": {
"title": "Sidebar",
"customize": {
"title": "Customize Sidebar",
"subtitle": "Drag to reorder modules, or move modules in/out of the \"More Features\" group. Settings are saved locally in your browser.",
"reset": "Reset to Default",
"mainItems": "Main Modules",
"moreItems": "More Features"
}
}
}
@@ -96,6 +96,42 @@
},
"confirmDelete": "Are you sure you want to delete server {name}?"
},
"syncProvider": {
"title": "Sync MCP Servers",
"subtitle": "Sync MCP server configurations from providers to local",
"steps": {
"selectProvider": "Step 1: Select Provider",
"configureAuth": "Step 2: Configure Authentication",
"syncServers": "Step 3: Sync Servers"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope is an open model community providing MCP servers for various machine learning and AI services"
},
"fields": {
"provider": "Select Provider",
"accessToken": "Access Token",
"tokenRequired": "Access token is required",
"tokenHint": "Please enter your ModelScope access token"
},
"buttons": {
"cancel": "Cancel",
"previous": "Previous",
"next": "Next",
"sync": "Start Sync",
"getToken": "Get Token"
},
"status": {
"selectProvider": "Please select an MCP server provider",
"enterToken": "Please enter the access token to continue",
"readyToSync": "Ready to sync server configurations"
},
"messages": {
"syncSuccess": "MCP servers synced successfully!",
"syncError": "Sync failed: {error}",
"tokenHelp": "How to get a ModelScope access token? Click the button on the right for instructions"
}
},
"messages": {
"getServersError": "Failed to get MCP server list: {error}",
"getToolsError": "Failed to get function tools list: {error}",
@@ -117,4 +153,4 @@
"toggleToolError": "Failed to toggle tool status: {error}",
"testError": "Test connection failed: {error}"
}
}
}
@@ -20,5 +20,6 @@
"invalid_date": "Please enter a valid date",
"date_range": "Invalid date range",
"upload_failed": "File upload failed",
"network_error": "Network connection error, please try again"
"network_error": "Network connection error, please try again",
"operation_cannot_be_undone": "⚠️ This operation cannot be undone, please choose carefully!"
}
@@ -18,5 +18,6 @@
"refresh": "刷新",
"submit": "提交",
"reset": "重置",
"clear": "清空"
"clear": "清空",
"save": "保存"
}
@@ -55,6 +55,9 @@
"linkText": "查看 master 分支提交记录(点击右边的 copy 即可复制)",
"confirm": "确定切换"
},
"releaseNotes": {
"title": "更新日志"
},
"dashboardUpdate": {
"title": "单独更新管理面板到最新版本",
"currentVersion": "当前版本",
@@ -6,7 +6,7 @@
"title": "The Alkaid Project.",
"subtitle": "AstrBot Alpha 项目",
"navigation": {
"knowledgeBase": "知识库",
"knowledgeBase": "知识库 (插件)",
"longTermMemory": "长期记忆层",
"other": "..."
}
@@ -2,8 +2,9 @@
"login": "登录",
"username": "用户名",
"password": "密码",
"defaultHint": "默认账户和密码均为:astrbot",
"logo": {
"title": "AstrBot 仪表盘",
"title": "AstrBot WebUI",
"subtitle": "欢迎使用"
},
"theme": {
@@ -43,11 +43,15 @@
"exitFullscreen": "退出全屏"
},
"conversation": {
"newConversation": "新对话",
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
"systemStatus": "系统状态",
"llmService": "LLM 服务",
"speechToText": "语音转文本"
"speechToText": "语音转文本",
"editDisplayName": "编辑会话名称",
"displayName": "会话名称",
"displayNameUpdated": "会话名称已更新",
"displayNameUpdateFailed": "更新会话名称失败"
},
"modes": {
"darkMode": "切换到夜间模式",
@@ -57,6 +61,15 @@
"voiceRecord": "录制语音",
"pasteImage": "粘贴图片"
},
"streaming": {
"enabled": "流式响应已开启",
"disabled": "流式响应已关闭",
"on": "流式",
"off": "普通"
},
"reasoning": {
"thinking": "思考过程"
},
"connection": {
"title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。",
@@ -79,6 +79,14 @@
"devDocs": "插件开发文档",
"submitRepo": "提交插件仓库"
},
"sort": {
"default": "默认排序",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"ascending": "升序",
"descending": "降序"
},
"tags": {
"danger": "危险"
},
@@ -97,7 +105,11 @@
},
"uninstall": {
"title": "删除确认",
"message": "你确定要删除当前插件吗?"
"message": "你确定要删除当前插件吗?",
"deleteConfig": "同时删除插件配置文件",
"deleteData": "同时删除插件持久化数据",
"configHint": "配置文件位于 data/config 目录",
"dataHint": "删除 data/plugin_data 和 data/plugins_data 目录下的数据"
},
"install": {
"title": "安装插件",
@@ -0,0 +1,118 @@
{
"title": "知识库详情",
"backToList": "返回列表",
"tabs": {
"overview": "概览",
"documents": "文档管理",
"retrieval": "知识库检索",
"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": "支持的格式: ",
"maxSize": "最大文件大小: 128MB",
"chunkSettings": "分块设置",
"batchSettings": "批处理设置",
"cleaningSettings": "清洗设置",
"enableCleaning": "启用内容清洗",
"cleaningProvider": "清洗服务提供商",
"cleaningProviderHint": "选择一个 LLM 服务商来对提取的网页内容进行清洗和总结",
"chunkSize": "分块大小",
"chunkSizeHint": "每个文本块的字符数 (默认: 512)",
"chunkOverlap": "分块重叠",
"chunkOverlapHint": "相邻文本块之间的重叠字符数 (默认: 50)",
"batchSize": "批处理大小",
"batchSizeHint": "每批处理的文本块数量 (默认: 32)",
"tasksLimit": "并发任务限制",
"tasksLimitHint": "最大并发上传任务数 (默认: 3)",
"maxRetries": "最大重试次数",
"maxRetriesHint": "上传失败任务的重试次数 (默认: 3)",
"cancel": "取消",
"submit": "上传",
"fileRequired": "请选择要上传的文件",
"fileUpload": "文件上传",
"fromUrl": "从 URL",
"urlPlaceholder": "请输入要提取内容的网页 URL",
"urlRequired": "请输入 URL",
"urlHint": "将自动从目标 URL 提取主要内容作为文档。目前支持 {supported} 页面,请确保目标网页允许爬虫访问。",
"beta": "测试版"
},
"retrieval": {
"title": "知识库检索",
"subtitle": "使用稠密检索和稀疏检索测试知识库内容",
"query": "检索查询",
"queryPlaceholder": "输入要检索的内容...",
"search": "检索",
"searching": "检索中...",
"results": "检索结果",
"noResults": "没有找到相关内容",
"tryDifferentQuery": "尝试使用不同的查询词",
"settings": "检索设置",
"topK": "返回结果数量",
"topKHint": "最多返回多少条检索结果",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"score": "相关度分数",
"document": "所属文档",
"chunk": "文本块 #{index}",
"content": "内容",
"charCount": "{count} 字符",
"searchSuccess": "检索完成,找到 {count} 条结果",
"searchFailed": "检索失败",
"queryRequired": "请输入检索查询"
},
"settings": {
"title": "知识库设置",
"basic": "基本设置",
"retrieval": "检索设置",
"chunkSize": "分块大小",
"chunkOverlap": "分块重叠",
"topKDense": "稠密检索数量",
"topKSparse": "稀疏检索数量",
"topMFinal": "最终返回数量",
"enableRerank": "启用重排序",
"embeddingProvider": "嵌入模型提供商",
"rerankProvider": "重排序模型提供商",
"save": "保存设置",
"saveSuccess": "设置保存成功",
"saveFailed": "设置保存失败",
"tips": "提示: 修改检索设置后,将影响后续的知识库查询效果。"
}
}
@@ -0,0 +1,55 @@
{
"title": "文档详情",
"backToKB": "返回知识库",
"info": {
"title": "文档信息",
"name": "文档名称",
"type": "文件类型",
"size": "文件大小",
"chunkCount": "分块数量",
"createdAt": "上传时间"
},
"chunks": {
"title": "分块列表",
"empty": "暂无分块",
"index": "序号",
"content": "内容",
"charCount": "字符数",
"actions": "操作",
"view": "查看",
"edit": "编辑",
"delete": "删除",
"preview": "预览",
"search": "搜索分块",
"searchPlaceholder": "输入关键词搜索分块内容...",
"showing": "显示",
"deleteConfirm": "确定要删除该文本块吗?",
"deleteSuccess": "文本块删除成功",
"deleteFailed": "文本块删除失败"
},
"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,67 @@
{
"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}",
"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": "加载知识库列表失败"
}
}
@@ -30,6 +30,7 @@
"ttsProvider": "语音合成模型",
"llmStatus": "启用 LLM",
"ttsStatus": "启用 TTS",
"knowledgeBase": "知识库配置",
"pluginManagement": "插件管理",
"actions": "操作"
}
@@ -67,6 +68,34 @@
"fullSessionId": "完整会话ID",
"hint": "自定义名称帮助您轻松识别会话。当设置了自定义名称时,会显示一个小感叹号标识(!),鼠标悬停时会显示实际的UMO。"
},
"knowledgeBase": {
"title": "知识库配置",
"configure": "配置",
"selectKB": "选择知识库",
"selectMultiple": "可以选择多个知识库",
"noKBAvailable": "暂无可用的知识库",
"noKBDesc": "目前没有创建任何知识库",
"createKB": "创建知识库",
"advancedSettings": "高级配置",
"topK": "返回结果数量",
"topKHint": "从知识库检索的结果数量",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"clearConfig": "清除配置",
"save": "保存",
"cancel": "取消",
"loading": "加载知识库配置中...",
"description": "为此会话配置使用的知识库。会话将使用配置的知识库来增强对话上下文。",
"saveSuccess": "知识库配置保存成功",
"saveFailed": "保存知识库配置失败",
"loadFailed": "加载知识库配置失败",
"clearSuccess": "知识库配置已清除",
"clearFailed": "清除知识库配置失败",
"clearConfirm": "确定要清除此会话的知识库配置吗?"
},
"list": {
"documents": "篇文档"
},
"deleteConfirm": {
"message": "确定要删除会话 {sessionName} 吗?",
"warning": "此操作将永久删除本次会话的「全部对话记录」与「偏好设置」(插件对会话的关联数据除外),且无法恢复。确认继续?"
@@ -19,5 +19,15 @@
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
"button": "启动迁移助手"
}
},
"sidebar": {
"title": "侧边栏",
"customize": {
"title": "自定义侧边栏",
"subtitle": "拖拽以调整模块顺序,或者将模块移入/移出\"更多功能\"分组。设置仅保存在浏览器本地。",
"reset": "恢复默认",
"mainItems": "主要模块",
"moreItems": "更多功能"
}
}
}
@@ -20,5 +20,6 @@
"invalid_date": "请输入有效的日期",
"date_range": "日期范围无效",
"upload_failed": "文件上传失败",
"network_error": "网络连接错误,请重试"
"network_error": "网络连接错误,请重试",
"operation_cannot_be_undone": "⚠️ 此操作无法撤销,请谨慎选择!"
}
+16
View File
@@ -25,6 +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 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';
@@ -56,6 +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 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';
@@ -93,6 +99,11 @@ export const translations = {
'knowledge-base': zhCNAlkaidKnowledgeBase,
memory: zhCNAlkaidMemory
},
'knowledge-base': {
index: zhCNKnowledgeBaseIndex,
detail: zhCNKnowledgeBaseDetail,
document: zhCNKnowledgeBaseDocument
},
persona: zhCNPersona,
migration: zhCNMigration
},
@@ -130,6 +141,11 @@ export const translations = {
'knowledge-base': enUSAlkaidKnowledgeBase,
memory: enUSAlkaidMemory
},
'knowledge-base': {
index: enUSKnowledgeBaseIndex,
detail: enUSKnowledgeBaseDetail,
document: enUSKnowledgeBaseDocument
},
persona: enUSPersona,
migration: enUSMigration
},
+12 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import { ref, onMounted } from 'vue';
import { RouterView, useRoute } from 'vue-router';
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
@@ -8,6 +8,12 @@ import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import { useCustomizerStore } from '@/stores/customizer';
const customizer = useCustomizerStore();
const route = useRoute();
// 计算是否在聊天页面(非全屏模式)
const isChatPage = computed(() => {
return route.path.startsWith('/chat');
});
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
// 检查是否需要迁移
@@ -45,7 +51,10 @@ onMounted(() => {
<VerticalHeaderVue />
<VerticalSidebarVue />
<v-main>
<v-container fluid class="page-wrapper" style="height: calc(100% - 8px)">
<v-container fluid class="page-wrapper" :style="{
height: 'calc(100% - 8px)',
padding: isChatPage ? '0' : undefined
}">
<div style="height: 100%;">
<RouterView />
</div>
@@ -10,6 +10,7 @@ import { useCommonStore } from '@/stores/common';
import MarkdownIt from 'markdown-it';
import { useI18n } from '@/i18n/composables';
import { router } from '@/router';
import { useTheme } from 'vuetify';
// 配置markdown-it,默认安全设置
const md = new MarkdownIt({
@@ -20,6 +21,7 @@ const md = new MarkdownIt({
});
const customizer = useCustomizerStore();
const theme = useTheme();
const { t } = useI18n();
let dialog = ref(false);
let accountWarning = ref(false)
@@ -41,6 +43,11 @@ let devCommits = ref<{ sha: string; date: string; message: string }[]>([]);
let updatingDashboardLoading = ref(false);
let installLoading = ref(false);
// Release Notes Modal
let releaseNotesDialog = ref(false);
let selectedReleaseNotes = ref('');
let selectedReleaseTag = ref('');
let tab = ref(0);
const releasesHeader = computed(() => [
@@ -191,7 +198,7 @@ function getReleases() {
function getDevCommits() {
let proxy = localStorage.getItem('selectedGitHubProxy') || '';
const originalUrl = "https://api.github.com/repos/Soulter/AstrBot/commits";
const originalUrl = "https://api.github.com/repos/AstrBotDevs/AstrBot/commits";
let commits_url = originalUrl;
if (proxy !== '') {
proxy = proxy.endsWith('/') ? proxy : proxy + '/';
@@ -276,7 +283,15 @@ function updateDashboard() {
}
function toggleDarkMode() {
customizer.SET_UI_THEME(customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark');
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
}
function openReleaseNotesDialog(body: string, tag: string) {
selectedReleaseNotes.value = body;
selectedReleaseTag.value = tag;
releaseNotesDialog.value = true;
}
getVersion();
@@ -397,7 +412,7 @@ commonStore.getStartTime();
<strong>{{ t('core.header.updateDialog.preReleaseWarning.title') }}</strong>
<br>
{{ t('core.header.updateDialog.preReleaseWarning.description') }}
<a href="https://github.com/Soulter/AstrBot/issues" target="_blank" class="text-decoration-none">
<a href="https://github.com/AstrBotDevs/AstrBot/issues" target="_blank" class="text-decoration-none">
{{ t('core.header.updateDialog.preReleaseWarning.issueLink') }}
</a>
</div>
@@ -413,13 +428,10 @@ commonStore.getStartTime();
</v-chip>
</div>
</template>
<template v-slot:item.body="{ item }: { item: { body: string } }">
<v-tooltip :text="item.body">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" rounded="xl" variant="tonal" color="primary" size="x-small">{{
t('core.header.updateDialog.table.view') }}</v-btn>
</template>
</v-tooltip>
<template v-slot:item.body="{ item }: { item: { body: string; tag_name: string } }">
<v-btn @click="openReleaseNotesDialog(item.body, item.tag_name)" rounded="xl" variant="tonal"
color="primary" size="x-small">{{
t('core.header.updateDialog.table.view') }}</v-btn>
</template>
<template v-slot:item.switch="{ item }: { item: { tag_name: string } }">
<v-btn @click="switchVersion(item.tag_name)" rounded="xl" variant="plain" color="primary">
@@ -456,7 +468,7 @@ commonStore.getStartTime();
<div class="mb-4">
<small>{{ t('core.header.updateDialog.manualInput.hint') }}</small>
<br>
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>{{
<a href="https://github.com/AstrBotDevs/AstrBot/commits/master"><small>{{
t('core.header.updateDialog.manualInput.linkText') }}</small></a>
</div>
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
@@ -498,6 +510,25 @@ commonStore.getStartTime();
</v-card>
</v-dialog>
<!-- Release Notes Modal -->
<v-dialog v-model="releaseNotesDialog" max-width="800">
<v-card>
<v-card-title class="text-h5">
{{ t('core.header.updateDialog.releaseNotes.title') }}: {{ selectedReleaseTag }}
</v-card-title>
<v-card-text
style="font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="md.render(selectedReleaseNotes)" class="markdown-content">
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="releaseNotesDialog = false">
{{ t('core.common.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 账户对话框 -->
<v-dialog v-model="dialog" persistent :max-width="$vuetify.display.xs ? '90%' : '500'">
<template v-slot:activator="{ props }">
@@ -1,15 +1,39 @@
<script setup>
import { ref, shallowRef } from 'vue';
import { ref, shallowRef, onMounted, onUnmounted } from 'vue';
import { useCustomizerStore } from '../../../stores/customizer';
import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
const { t } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
// Apply customization on mount and listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'astrbot_sidebar_customization') {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
}
};
const handleCustomEvent = () => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
};
onMounted(() => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
window.addEventListener('storage', handleStorageChange);
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('sidebar-customization-changed', handleCustomEvent);
});
const showIframe = ref(false);
const starCount = ref(null);
@@ -45,8 +45,8 @@ const sidebarItem: menu[] = [
},
{
title: 'core.navigation.knowledgeBase',
icon: 'mdi-text-box-search',
to: '/alkaid/knowledge-base',
icon: 'mdi-book-open-variant',
to: '/knowledge-base',
},
{
title: 'core.navigation.chat',
+16 -2
View File
@@ -18,24 +18,38 @@ setupI18n().then(() => {
const app = createApp(App);
app.use(router);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
});
}).catch(error => {
console.error('❌ 新i18n系统初始化失败:', error);
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
const app = createApp(App);
app.use(router);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
const customizer = useCustomizerStore(pinia);
vuetify.theme.global.name.value = customizer.uiTheme;
});
});
+31 -5
View File
@@ -66,6 +66,37 @@ 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: 'KnowledgeBase',
path: '/alkaid/knowledge-base',
component: () => import('@/views/alkaid/KnowledgeBase.vue'),
},
// {
// name: 'Alkaid',
// path: '/alkaid',
@@ -88,11 +119,6 @@ const MainRoutes = {
// }
// ]
// },
{
name: 'KnowledgeBase',
path: '/alkaid/knowledge-base',
component: () => import('@/views/alkaid/KnowledgeBase.vue')
},
{
name: 'Chat',
path: '/chat',
+1 -1
View File
@@ -20,7 +20,7 @@ html {
.page-wrapper {
min-height: calc(100vh - 100px);
padding: 15px;
padding: 8px;
border-radius: $border-radius-root;
background: rgb(var(--v-theme-containerBg));
}
+3 -2
View File
@@ -159,10 +159,10 @@ export const useCommonStore = defineStore({
if (!force && this.pluginMarketData.length > 0) {
return Promise.resolve(this.pluginMarketData);
}
// 如果是强制刷新,添加 force_refresh 参数
const url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
return axios.get(url)
.then((res) => {
let data = []
@@ -180,6 +180,7 @@ export const useCommonStore = defineStore({
"pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false,
"stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0,
"updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "",
"display_name": res.data.data[key]?.display_name ? res.data.data[key].display_name : "",
})
}
this.pluginMarketData = data;
+1 -1
View File
@@ -42,7 +42,7 @@ const PurpleTheme: ThemeTypes = {
preBg: 'rgb(249, 249, 249)',
code: 'rgb(13, 13, 13)',
chatMessageBubble: '#e7ebf4',
mcpCardBg: '#f7f2f9',
mcpCardBg: '#ecf2faff',
}
};
+1
View File
@@ -31,6 +31,7 @@ export function getProviderIcon(type) {
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
};
return icons[type] || '';
}
@@ -0,0 +1,99 @@
// Utility for managing sidebar customization in localStorage
const STORAGE_KEY = 'astrbot_sidebar_customization';
/**
* Get the customized sidebar configuration from localStorage
* @returns {Object|null} The customization config or null if not set
*/
export function getSidebarCustomization() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error('Error reading sidebar customization:', error);
return null;
}
}
/**
* Save the sidebar customization to localStorage
* @param {Object} config - The customization configuration
* @param {Array} config.mainItems - Array of item titles for main sidebar
* @param {Array} config.moreItems - Array of item titles for "More Features" group
*/
export function setSidebarCustomization(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
} catch (error) {
console.error('Error saving sidebar customization:', error);
}
}
/**
* Clear the sidebar customization (reset to default)
*/
export function clearSidebarCustomization() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('Error clearing sidebar customization:', error);
}
}
/**
* Apply customization to sidebar items
* @param {Array} defaultItems - Default sidebar items array
* @returns {Array} Customized sidebar items array (new array, doesn't mutate input)
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
if (!customization) {
return defaultItems;
}
const { mainItems, moreItems } = customization;
// Create a map of all items by title for quick lookup
// Deep clone items to avoid mutating originals
const allItemsMap = new Map();
defaultItems.forEach(item => {
if (item.children) {
// If it's the "More" group, add children to map
item.children.forEach(child => {
allItemsMap.set(child.title, { ...child });
});
} else {
allItemsMap.set(item.title, { ...item });
}
});
const customizedItems = [];
// Add main items in custom order
mainItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
customizedItems.push(item);
}
});
// If there are items in moreItems, create the "More Features" group
if (moreItems && moreItems.length > 0) {
const moreGroup = {
title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal',
children: []
};
moreItems.forEach(title => {
const item = allItemsMap.get(title);
if (item) {
moreGroup.children.push(item);
}
});
customizedItems.push(moreGroup);
}
return customizedItems;
}
+2 -2
View File
@@ -5,11 +5,11 @@
<h1 class="font-weight-bold">{{ tm('hero.title') }}</h1>
<p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">{{ tm('hero.subtitle') }}</p>
<div style="margin-top: 20px; display: flex; justify-content: center;">
<v-btn @click="open('https://github.com/Soulter/AstrBot')" color="primary" variant="tonal"
<v-btn @click="open('https://github.com/AstrBotDevs/AstrBot')" color="primary" variant="tonal"
prepend-icon="mdi-star">
{{ tm('hero.starButton') }}
</v-btn>
<v-btn class="ml-4" @click="open('https://github.com/Soulter/AstrBot/issues')" color="secondary"
<v-btn class="ml-4" @click="open('https://github.com/AstrBotDevs/AstrBot/issues')" color="secondary"
variant="tonal" prepend-icon="mdi-comment-question">
{{ tm('hero.issueButton') }}
</v-btn>
+3 -3
View File
@@ -7,9 +7,9 @@
<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') ? '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 +21,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') }}
+1 -1
View File
@@ -10,6 +10,6 @@ import Chat from '@/components/chat/Chat.vue'
<style scoped>
.chat-container {
height: calc(100vh - 88px)
height: calc(100vh - 60px)
}
</style>
+45 -18
View File
@@ -8,7 +8,7 @@
<div class="d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; justify-content: space-between; width: 100%;">
<div class="d-flex flex-row align-center" style="gap: 12px;">
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name"
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" label="选择配置文件" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
@@ -27,24 +27,26 @@
</v-btn-toggle>
</div>
<v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<div v-if="(selectedConfigID || isSystemConfig) && fetched" style="width: 100%;">
<!-- 可视化编辑 -->
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
/>
<v-slide-y-transition mode="out-in">
<div v-if="(selectedConfigID || isSystemConfig) && fetched" :key="configContentKey" class="config-content" style="width: 100%;">
<!-- 可视化编辑 -->
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
/>
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary" @click="updateConfig">
</v-btn>
<v-btn icon="mdi-content-save" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary" @click="updateConfig">
</v-btn>
<v-btn icon="mdi-code-json" size="x-large" style="position: fixed; right: 52px; bottom: 124px;" color="primary"
@click="configToString(); codeEditorDialog = true">
</v-btn>
<v-btn icon="mdi-code-json" size="x-large" style="position: fixed; right: 52px; bottom: 124px;" color="primary"
@click="configToString(); codeEditorDialog = true">
</v-btn>
</div>
</div>
</v-slide-y-transition>
</div>
</div>
@@ -150,6 +152,12 @@ export default {
VueMonacoEditor,
WaitingForRestart
},
props: {
initialConfigId: {
type: String,
default: null
}
},
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/config');
@@ -187,8 +195,16 @@ export default {
},
},
watch: {
config_data_str: function (val) {
config_data_str(val) {
this.config_data_has_changed = true;
},
initialConfigId(newVal) {
if (!newVal) {
return;
}
if (this.selectedConfigID !== newVal) {
this.getConfigInfoList(newVal);
}
}
},
data() {
@@ -207,6 +223,7 @@ export default {
save_message_snack: false,
save_message: "",
save_message_success: "",
configContentKey: 0,
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
@@ -224,7 +241,8 @@ export default {
}
},
mounted() {
this.getConfigInfoList("default");
const targetConfigId = this.initialConfigId || 'default';
this.getConfigInfoList(targetConfigId);
// 初始化配置类型状态
this.configType = this.isSystemConfig ? 'system' : 'normal';
},
@@ -235,13 +253,21 @@ export default {
this.configInfoList = res.data.data.info_list;
if (abconf_id) {
let matched = false;
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id
this.selectedConfigID = this.configInfoList[i].id;
this.getConfig(abconf_id);
matched = true;
break;
}
}
if (!matched && this.configInfoList.length) {
// 当找不到目标配置时,默认展示列表中的第一个配置
this.selectedConfigID = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
}).catch((err) => {
this.save_message = this.messages.loadError;
@@ -265,6 +291,7 @@ export default {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
+287 -80
View File
@@ -4,12 +4,13 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import UninstallConfirmDialog from '@/components/shared/UninstallConfirmDialog.vue';
import axios from 'axios';
import { pinyin } from 'pinyin-pro';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { ref, computed, onMounted, reactive } from 'vue';
import { ref, computed, onMounted, reactive, inject, watch } from 'vue';
const commonStore = useCommonStore();
@@ -52,10 +53,18 @@ const isListView = ref(false);
const pluginSearch = ref("");
const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
const itemsPerPage = ref(6); // 每页显示6个卡片 (2行 x 3列,避免滚动)
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const pluginToUninstall = ref(null);
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
@@ -63,8 +72,11 @@ const upload_file = ref(null);
const uploadTab = ref('file');
const showPluginFullName = ref(false);
const marketSearch = ref("");
const debouncedMarketSearch = ref("");
const filterKeys = ['name', 'desc', 'author'];
const refreshingMarket = ref(false);
const sortBy = ref('default'); // default, stars, author, updated
const sortOrder = ref('desc'); // desc (降序) or asc (升序)
// 插件市场拼音搜索
const normalizeStr = (s) => (s ?? '').toString().toLowerCase().trim();
@@ -148,6 +160,71 @@ const pinnedPlugins = computed(() => {
return pluginMarketData.value.filter(plugin => plugin?.pinned);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
if (!debouncedMarketSearch.value) {
return pluginMarketData.value;
}
const search = debouncedMarketSearch.value.toLowerCase();
return pluginMarketData.value.filter(plugin => {
// 使用自定义过滤器
return marketCustomFilter(plugin.name, search, plugin) ||
marketCustomFilter(plugin.desc, search, plugin) ||
marketCustomFilter(plugin.author, search, plugin);
});
});
// 所有插件列表,推荐插件排在前面
const sortedPlugins = computed(() => {
let plugins = [...filteredMarketPlugins.value];
// 根据排序选项排序
if (sortBy.value === 'stars') {
// 按 star 数排序
plugins.sort((a, b) => {
const starsA = a.stars ?? 0;
const starsB = b.stars ?? 0;
return sortOrder.value === 'desc' ? starsB - starsA : starsA - starsB;
});
} else if (sortBy.value === 'author') {
// 按作者名字典序排序
plugins.sort((a, b) => {
const authorA = (a.author ?? '').toLowerCase();
const authorB = (b.author ?? '').toLowerCase();
const result = authorA.localeCompare(authorB);
return sortOrder.value === 'desc' ? -result : result;
});
} else if (sortBy.value === 'updated') {
// 按更新时间排序
plugins.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortOrder.value === 'desc' ? dateB - dateA : dateA - dateB;
});
} else {
// default: 推荐插件排在前面
const pinned = plugins.filter(plugin => plugin?.pinned);
const notPinned = plugins.filter(plugin => !plugin?.pinned);
return [...pinned, ...notPinned];
}
return plugins;
});
// 分页计算属性
const displayItemsPerPage = 9; // 固定每页显示6个卡片(2行)
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
});
const paginatedPlugins = computed(() => {
const start = (currentPage.value - 1) * displayItemsPerPage;
const end = start + displayItemsPerPage;
return sortedPlugins.value.slice(start, end);
});
// 方法
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
@@ -210,14 +287,38 @@ const checkUpdate = () => {
} else {
extension.has_update = false;
}
extension.logo = matchedPlugin?.logo;
});
};
const uninstallExtension = async (extension_name) => {
const uninstallExtension = async (extension_name, optionsOrSkipConfirm = false) => {
let deleteConfig = false;
let deleteData = false;
let skipConfirm = false;
// 处理参数:可能是布尔值(旧的 skipConfirm)或对象(新的选项)
if (typeof optionsOrSkipConfirm === 'boolean') {
skipConfirm = optionsOrSkipConfirm;
} else if (typeof optionsOrSkipConfirm === 'object' && optionsOrSkipConfirm !== null) {
deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
deleteData = optionsOrSkipConfirm.deleteData || false;
skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
}
// 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
if (!skipConfirm) {
pluginToUninstall.value = extension_name;
showUninstallDialog.value = true;
return; // 等待对话框回调
}
// 执行卸载
toast(tm('messages.uninstalling') + " " + extension_name, "primary");
try {
const res = await axios.post('/api/plugin/uninstall', { name: extension_name });
const res = await axios.post('/api/plugin/uninstall', {
name: extension_name,
delete_config: deleteConfig,
delete_data: deleteData,
});
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
@@ -230,6 +331,14 @@ const uninstallExtension = async (extension_name) => {
}
};
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = (options) => {
if (pluginToUninstall.value) {
uninstallExtension(pluginToUninstall.value, options);
pluginToUninstall.value = null;
}
};
const updateExtension = async (extension_name) => {
loadingDialog.title = tm('status.loading');
loadingDialog.show = true;
@@ -497,6 +606,7 @@ const refreshPluginMarket = async () => {
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
currentPage.value = 1; // 重置到第一页
toast(tm('messages.refreshSuccess'), "success");
} catch (err) {
@@ -538,15 +648,29 @@ onMounted(async () => {
}
});
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedMarketSearch.value = newVal;
// 搜索时重置到第一页
currentPage.value = 1;
}, 300); // 300ms 防抖延迟
});
</script>
<template>
<v-row>
<v-col cols="12" md="12">
<v-card variant="flat">
<v-card variant="flat" style="background-color: transparent">
<!-- 标签页 -->
<v-card-text>
<v-card-text style="padding: 0px 12px;">
<!-- 标签栏和搜索栏 - 响应式布局 -->
<div class="mb-4 d-flex flex-wrap">
<!-- 标签栏 -->
@@ -741,7 +865,7 @@ onMounted(async () => {
<!-- 卡片视图 -->
<div v-else>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-8">
<v-col cols="12" class="pa-2">
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
<div class="text-h5 mb-2">{{ tm('empty.noPlugins') }}</div>
<div class="text-body-1 mb-4">{{ tm('empty.noPluginsDesc') }}</div>
@@ -749,10 +873,12 @@ onMounted(async () => {
</v-row>
<v-row>
<v-col cols="12" md="6" lg="6" v-for="extension in filteredPlugins" :key="extension.name"
class="pb-4">
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name"
class="pb-2">
<ExtensionCard :extension="extension" class="rounded-lg"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
style="background-color: rgb(var(--v-theme-mcpCardBg));"
@configure="openExtensionConfig(extension.name)"
@uninstall="(ext, options) => uninstallExtension(ext.name, options)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
@@ -772,81 +898,158 @@ onMounted(async () => {
@click="dialog = true" color="darkprimary">
</v-btn>
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>{{ tm('market.recommended') }}</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins" :key="plugin.name">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg" market-mode="true" :highlight="true"
@install="handleInstallPlugin(plugin)" @view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
</div>
<div class="mt-4">
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
<h2>{{ tm('market.allPlugins') }}</h2>
<div class="d-flex align-center">
<v-btn variant="tonal" size="small" @click="refreshPluginMarket" :loading="refreshingMarket"
class="mr-2">
<div class="d-flex align-center mb-2" style="justify-content: space-between; flex-wrap: wrap; gap: 8px;">
<div class="d-flex align-center" style="gap: 6px;">
<h2>{{ tm('market.allPlugins') }}({{ filteredMarketPlugins.length }})</h2>
<v-btn icon variant="text" @click="refreshPluginMarket" :loading="refreshingMarket">
<v-icon>mdi-refresh</v-icon>
{{ tm('buttons.refresh') }}
</v-btn>
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" />
</div>
<div class="d-flex align-center" style="gap: 8px; flex-wrap: wrap;">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="5" size="small"
density="comfortable"></v-pagination>
<!-- 排序选择器 -->
<v-select v-model="sortBy" :items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' }
]" density="compact" variant="outlined" hide-details style="max-width: 150px;">
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn icon v-if="sortBy !== 'default'" @click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text" density="compact">
<v-icon>{{ sortOrder === 'desc' ? 'mdi-sort-descending' : 'mdi-sort-ascending'
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ sortOrder === 'desc' ? tm('sort.descending') : tm('sort.ascending') }}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div>
</div>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys" :custom-filter="marketCustomFilter">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center"
style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
<img v-if="item.logo" :src="item.logo"
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
showPluginFullName ? item.name : item.trimmedName }}</a></span>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
</div>
</template>
<v-row style="min-height: 26rem;">
<v-col v-for="plugin in paginatedPlugins" :key="plugin.name" cols="12" md="6" lg="4">
<v-card class="rounded-lg d-flex flex-column" elevation="0"
style=" height: 12rem; position: relative;">
<template v-slot:item.desc="{ item }">
<div style="font-size: 13px;">
{{ item.desc }}
</div>
</template>
<template v-slot:item.author="{ item }">
<div style="font-size: 12px;">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span>
<span v-else>{{ item.author }}</span>
</div>
</template>
<template v-slot:item.stars="{ item }">
<span>{{ item.stars }}</span>
</template>
<template v-slot:item.updated_at="{ item }">
<span>{{ new Date(item.updated_at).toLocaleString() }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'"
size="x-small" v-show="tag !== 'danger'" class="ma-1">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" variant="flat"
@click="handleInstallPlugin(item)">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
disabled><v-icon>mdi-check</v-icon></v-btn>
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
@click="open(item.repo)"><v-icon>mdi-help</v-icon></v-btn>
</template>
</v-data-table>
</v-col>
<!-- 推荐标记 -->
<v-chip v-if="plugin?.pinned" color="warning" size="x-small" label
style="position: absolute; right: 8px; top: 8px; z-index: 10; height: 20px; font-weight: bold;">
🥳 推荐
</v-chip>
<v-card-text
style="padding: 12px; padding-bottom: 8px; display: flex; gap: 12px; width: 100%; flex: 1; overflow: hidden;">
<div v-if="plugin?.logo" style="flex-shrink: 0;">
<img :src="plugin.logo" :alt="plugin.name"
style="height: 75px; width: 75px; border-radius: 8px; object-fit: cover;" />
</div>
<div style="flex: 1; overflow: hidden; display: flex; flex-direction: column;">
<!-- Display Name -->
<div class="font-weight-bold"
style="margin-bottom: 4px; line-height: 1.3; font-size: 1.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span style="overflow: hidden; text-overflow: ellipsis;">
{{ plugin.display_name?.length ? plugin.display_name :
(showPluginFullName ? plugin.name : plugin.trimmedName) }}
</span>
</div>
<!-- Author with link -->
<div class="d-flex align-center" style="gap: 4px; margin-bottom: 6px;">
<v-icon icon="mdi-account" size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5);"></v-icon>
<a v-if="plugin?.social_link" :href="plugin.social_link" target="_blank"
class="text-subtitle-2 font-weight-medium"
style="text-decoration: none; color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</a>
<span v-else class="text-subtitle-2 font-weight-medium"
style="color: rgb(var(--v-theme-primary)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ plugin.author }}
</span>
<div class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-source-branch" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ plugin.version }}</span>
</div>
</div>
<!-- Description -->
<div class="text-caption"
style="overflow: scroll; color: rgba(var(--v-theme-on-surface), 0.6); line-height: 1.3; margin-bottom: 6px; flex: 1;">
{{ plugin.desc }}
</div>
<!-- Stats: Stars & Updated & Version -->
<div class="d-flex align-center" style="gap: 8px; margin-top: auto;">
<div v-if="plugin.stars !== undefined" class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-star" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div v-if="plugin.updated_at" class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7);">
<v-icon icon="mdi-clock-outline" size="x-small" style="margin-right: 2px;"></v-icon>
<span>{{ new Date(plugin.updated_at).toLocaleString() }}</span>
</div>
</div>
</div>
</v-card-text>
<!-- Actions -->
<v-card-actions style="gap: 6px; padding: 8px 12px; padding-top: 0;">
<v-chip v-for="tag in plugin.tags?.slice(0, 2)" :key="tag"
:color="tag === 'danger' ? 'error' : 'primary'" label size="x-small" style="height: 20px;">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
<v-menu v-if="plugin.tags && plugin.tags.length > 2" open-on-hover offset-y>
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" color="grey" label size="x-small"
style="height: 20px; cursor: pointer;">
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item v-for="tag in plugin.tags.slice(2)" :key="tag">
<v-chip :color="tag === 'danger' ? 'error' : 'primary'" label size="small">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn v-if="plugin?.repo" color="secondary" size="x-small" variant="tonal" :href="plugin.repo"
target="_blank" style="height: 24px;">
<v-icon icon="mdi-github" start size="x-small"></v-icon>
仓库
</v-btn>
<v-btn v-if="!plugin?.installed" color="primary" size="x-small"
@click="handleInstallPlugin(plugin)" variant="flat" style="height: 24px;">
{{ tm('buttons.install') }}
</v-btn>
<v-chip v-else color="success" size="x-small" label style="height: 20px;">
{{ tm('status.installed') }}
</v-chip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 底部分页控件 -->
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination v-model="currentPage" :length="totalPages" :total-visible="7" size="small"></v-pagination>
</div>
</div>
</v-tab-item>
@@ -859,9 +1062,10 @@ onMounted(async () => {
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" cols="12" md="12">
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> |
<small> <a href="https://github.com/Soulter/AstrBot_Plugins_Collection">{{ tm('market.submitRepo') }}</a></small>
<small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo')
}}</a></small>
</v-col>
</v-row>
@@ -954,6 +1158,9 @@ onMounted(async () => {
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" />
<!-- 卸载插件确认对话框列表模式用 -->
<UninstallConfirmDialog v-model="showUninstallDialog" @confirm="handleUninstallConfirm" />
<!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card>
+41 -52
View File
@@ -12,7 +12,8 @@
</p>
</div>
<div>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true" rounded="xl" size="x-large">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
rounded="xl" size="x-large">
{{ tm('providers.addProvider') }}
</v-btn>
</div>
@@ -60,30 +61,16 @@
<v-row v-else>
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card
:item="provider"
title-field="id"
enabled-field="enable"
:loading="isProviderTesting(provider.id)"
@toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
:show-copy-button="true">
<template #actions="{ item }">
<v-btn
style="z-index: 100000;"
variant="tonal"
color="info"
rounded="xl"
size="small"
:loading="isProviderTesting(item.id)"
@click="testSingleProvider(item)"
>
{{ tm('availability.test') }}
</v-btn>
</template>
<item-card :item="provider" title-field="id" enabled-field="enable"
:loading="isProviderTesting(provider.id)" @toggle-enabled="providerStatusChange"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
{{ tm('availability.test') }}
</v-btn>
</template>
<template v-slot:details="{ item }">
</template>
</item-card>
@@ -119,16 +106,12 @@
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card" :class="`status-${status.status}`">
<v-card-item>
<v-icon v-if="status.status === 'available'" color="success" class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error" class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular
v-else-if="status.status === 'pending'"
indeterminate
color="primary"
size="20"
width="2"
class="me-2"
></v-progress-circular>
<v-icon v-if="status.status === 'available'" color="success"
class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error"
class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular v-else-if="status.status === 'pending'" indeterminate color="primary"
size="20" width="2" class="me-2"></v-progress-circular>
<span class="font-weight-bold">{{ status.id }}</span>
@@ -169,22 +152,16 @@
</v-container>
<!-- 添加提供商对话框 -->
<AddNewProvider
v-model:show="showAddProviderDialog"
:metadata="metadata"
@select-template="selectProviderTemplate"
/>
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="metadata"
@select-template="selectProviderTemplate" />
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card :title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
<v-card
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
<v-card-text class="py-4">
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata"
metadataKey="provider"
:is-editing="updatingMode"
/>
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="metadata['provider_group']?.metadata"
metadataKey="provider" :is-editing="updatingMode" />
</v-card-text>
<v-divider></v-divider>
@@ -472,7 +449,7 @@ export default {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
target[key] = Array.isArray(source[key]) ? [...source[key]] : { ...source[key] };
} else {
target[key] = source[key];
}
@@ -533,7 +510,14 @@ export default {
id: this.newSelectedProviderName,
config: this.newSelectedProviderConfig
});
if (res.data.status === 'error') {
this.showError(res.data.message || "更新失败!");
return
}
this.showSuccess(res.data.message || "更新成功!");
if (wasUpdating) {
this.updatingMode = false;
}
} else {
// ID
const existingProvider = this.config_data.provider?.find(p => p.id === this.newSelectedProviderConfig.id);
@@ -546,17 +530,18 @@ export default {
}
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
if (res.data.status === 'error') {
this.showError(res.data.message || "添加失败!");
return
}
this.showSuccess(res.data.message || "添加成功!");
}
this.showProviderCfg = false;
this.getConfig();
} catch (err) {
this.showError(err.response?.data?.message || err.message);
} finally {
this.loading = false;
if (wasUpdating) {
this.updatingMode = false;
}
this.getConfig();
}
},
@@ -612,6 +597,10 @@ export default {
id: provider.id,
config: provider
}).then((res) => {
if (res.data.status === 'error') {
this.showError(res.data.message)
return
}
this.getConfig();
this.showSuccess(res.data.message || this.messages.success.statusUpdate);
}).catch((err) => {
+321 -2
View File
@@ -142,6 +142,14 @@
</v-checkbox>
</template>
<!-- 知识库配置 -->
<template v-slot:item.knowledge_base="{ item }">
<v-btn size="x-small" variant="tonal" color="info" @click="openKBManager(item)"
:loading="item.loadingKB" :disabled="!item.session_enabled">
{{ tm('knowledgeBase.configure') }}
</v-btn>
</template>
<!-- 插件管理 -->
<template v-slot:item.plugins="{ item }">
<v-btn size="x-small" variant="tonal" color="primary" @click="openPluginManager(item)"
@@ -335,6 +343,125 @@
</v-card>
</v-dialog>
<!-- 知识库配置对话框 -->
<v-dialog v-model="kbDialog" max-width="800" min-height="60%" @update:model-value="(val) => { if (!val) closeKBDialog(); }">
<v-card v-if="selectedSessionForKB">
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
<span>{{ tm('knowledgeBase.title') }} - {{ selectedSessionForKB.session_name }}</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" color="white" @click="closeKBDialog()">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text v-if="!loadingKBConfig">
<div style="padding: 16px;">
<v-alert type="info" variant="tonal" class="mb-4">
{{ tm('knowledgeBase.description') }}
</v-alert>
<!-- 知识库选择区域 -->
<div class="mb-4">
<div class="text-subtitle-2 mb-2">{{ tm('knowledgeBase.selectKB') }}</div>
<v-card variant="outlined" class="pa-3">
<div v-if="availableKBs.length === 0" class="text-body-2 text-medium-emphasis">
{{ tm('knowledgeBase.noKBAvailable') || '暂无可用知识库' }}
</div>
<div v-else class="kb-selector-list">
<v-checkbox
v-for="kb in availableKBs"
:key="kb.kb_id"
:value="kb.kb_id"
v-model="sessionKBConfig.kb_ids"
hide-details
density="compact"
class="mb-1"
>
<template v-slot:label>
<div class="d-flex align-center">
<span style="font-size: 18px; margin-right: 8px;">{{ kb.emoji }}</span>
<div>
<div class="text-body-2">{{ kb.kb_name }}</div>
<div class="text-caption text-medium-emphasis">
{{ kb.description || tm('knowledgeBase.noKBDesc') }} - {{ kb.doc_count }} {{ tm('list.documents', { count: kb.doc_count }) }}
</div>
</div>
</div>
</template>
</v-checkbox>
</div>
</v-card>
<div class="text-caption text-medium-emphasis mt-2">
{{ tm('knowledgeBase.selectMultiple') }}
</div>
</div>
<!-- 高级配置 -->
<v-expansion-panels class="mb-4">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="mr-2">mdi-cog</v-icon>
{{ tm('knowledgeBase.advancedSettings') }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="sessionKBConfig.top_k"
:label="tm('knowledgeBase.topK')"
type="number"
variant="outlined"
density="comfortable"
:hint="tm('knowledgeBase.topKHint')"
persistent-hint
/>
</v-col>
<v-col cols="12" md="6">
<v-checkbox
v-model="sessionKBConfig.enable_rerank"
:label="tm('knowledgeBase.enableRerank')"
color="primary"
:hint="tm('knowledgeBase.enableRerankHint')"
persistent-hint
/>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<div v-if="availableKBs.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-2">mdi-database-off</v-icon>
<p class="mt-4 text-medium-emphasis">{{ tm('knowledgeBase.noKBAvailable') }}</p>
<v-btn color="primary" variant="tonal" class="mt-2" @click="goToKBPage">
{{ tm('knowledgeBase.createKB') }}
</v-btn>
</div>
</div>
</v-card-text>
<v-card-text v-else class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="48"></v-progress-circular>
<div class="text-body-1 mt-4">{{ tm('knowledgeBase.loading') }}</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-btn variant="text" @click="clearKBConfig" :disabled="savingKBConfig || loadingKBConfig">
{{ tm('knowledgeBase.clearConfig') }}
</v-btn>
<v-spacer />
<v-btn variant="text" @click="closeKBDialog()" :disabled="savingKBConfig">
{{ tm('knowledgeBase.cancel') }}
</v-btn>
<v-btn color="primary" variant="tonal" @click="saveKBConfig" :loading="savingKBConfig">
{{ tm('knowledgeBase.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 提示信息 -->
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
{{ snackbarText }}
@@ -399,6 +526,18 @@ export default {
newSessionName: '',
nameEditLoading: false,
//
kbDialog: false,
selectedSessionForKB: null,
sessionKBConfig: {
kb_ids: [],
top_k: 5,
enable_rerank: true
},
availableKBs: [],
loadingKBConfig: false,
savingKBConfig: false,
//
snackbar: false,
snackbarText: '',
@@ -432,6 +571,7 @@ export default {
{ title: this.tm('table.headers.ttsProvider'), key: 'tts_provider', sortable: false, minWidth: '200px' },
{ title: this.tm('table.headers.llmStatus'), key: 'llm_enabled', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.ttsStatus'), key: 'tts_enabled', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.knowledgeBase'), key: 'knowledge_base', sortable: false, minWidth: '150px' },
{ title: this.tm('table.headers.pluginManagement'), key: 'plugins', sortable: false, minWidth: '120px' },
{ title: this.tm('table.headers.actions'), key: 'actions', sortable: false, minWidth: '100px' },
]
@@ -503,7 +643,8 @@ export default {
...session,
updating: false, //
loadingPlugins: false, //
deleting: false //
deleting: false, //
loadingKB: false //
}));
this.availablePersonas = data.available_personas;
this.availableChatProviders = data.available_chat_providers;
@@ -964,15 +1105,193 @@ export default {
this.currentPage = 1; //
this.loadSessions();
},
//
async openKBManager(session) {
this.selectedSessionForKB = session;
//
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.kbDialog = true;
this.loadingKBConfig = true;
try {
//
const kbListResponse = await axios.get('/api/kb/list');
if (kbListResponse.data.status === 'ok') {
this.availableKBs = kbListResponse.data.data.items;
}
//
const configResponse = await axios.get('/api/kb/session/config/get', {
params: { session_id: session.session_id }
});
if (configResponse.data.status === 'ok') {
const config = configResponse.data.data;
//
this.sessionKBConfig = {
kb_ids: [],
top_k: config.top_k || 5,
enable_rerank: config.enable_rerank !== false
};
// kb_ids
if (config.kb_ids && Array.isArray(config.kb_ids)) {
this.sessionKBConfig.kb_ids = config.kb_ids.filter(id => id != null && id !== '');
}
} else {
//
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
}
} catch (error) {
console.error('加载知识库配置失败:', error);
this.showError(this.tm('knowledgeBase.loadFailed'));
} finally {
this.loadingKBConfig = false;
}
},
async saveKBConfig() {
if (!this.selectedSessionForKB) return;
// kb_ids
const cleanKbIds = Array.isArray(this.sessionKBConfig.kb_ids)
? this.sessionKBConfig.kb_ids.filter(id => id != null && id !== '')
: [];
this.savingKBConfig = true;
try {
const payload = {
scope: 'session',
scope_id: this.selectedSessionForKB.session_id,
kb_ids: cleanKbIds, // 使
top_k: this.sessionKBConfig.top_k,
enable_rerank: this.sessionKBConfig.enable_rerank
};
const response = await axios.post('/api/kb/session/config/set', payload);
if (response.data.status === 'ok') {
this.showSuccess(this.tm('knowledgeBase.saveSuccess'));
this.kbDialog = false;
//
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.selectedSessionForKB = null;
} else {
this.showError(response.data.message || this.tm('knowledgeBase.saveFailed'));
}
} catch (error) {
console.error('保存知识库配置失败:', error);
this.showError(error.response?.data?.message || this.tm('knowledgeBase.saveFailed'));
} finally {
this.savingKBConfig = false;
}
},
//
closeKBDialog() {
this.kbDialog = false;
//
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
this.selectedSessionForKB = null;
this.availableKBs = [];
},
async clearKBConfig() {
if (!this.selectedSessionForKB) return;
if (!confirm(this.tm('knowledgeBase.clearConfirm'))) {
return;
}
this.savingKBConfig = true;
try {
const response = await axios.post('/api/kb/session/config/delete', {
scope: 'session',
scope_id: this.selectedSessionForKB.session_id
});
if (response.data.status === 'ok') {
this.showSuccess(this.tm('knowledgeBase.clearSuccess'));
this.sessionKBConfig = {
kb_ids: [],
top_k: 5,
enable_rerank: true
};
} else {
this.showError(response.data.message || this.tm('knowledgeBase.clearFailed'));
}
} catch (error) {
console.error('清除知识库配置失败:', error);
this.showError(error.response?.data?.message || this.tm('knowledgeBase.clearFailed'));
} finally {
this.savingKBConfig = false;
}
},
goToKBPage() {
this.$router.push('/knowledge-base');
},
},
}
</script>
<style scoped>
.v-data-table>>>.v-data-table__td {
.v-data-table :deep(.v-data-table__td) {
padding: 8px 16px !important;
vertical-align: middle !important;
}
/* 知识库选择列表滚动条样式 */
.kb-selector-list {
max-height: 150px;
overflow-y: auto;
padding-right: 8px;
}
/* 自定义滚动条样式 - Webkit浏览器 */
.kb-selector-list::-webkit-scrollbar {
width: 8px;
}
.kb-selector-list::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.kb-selector-list::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.kb-selector-list::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Firefox滚动条样式 */
.kb-selector-list {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
}
</style>
+7
View File
@@ -9,6 +9,12 @@
<ProxySelector></ProxySelector>
</v-list-item>
<v-list-subheader>{{ tm('sidebar.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('sidebar.customize.subtitle')" :title="tm('sidebar.customize.title')">
<SidebarCustomizer></SidebarCustomizer>
</v-list-item>
<v-list-subheader>{{ tm('system.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('system.restart.subtitle')" :title="tm('system.restart.title')">
@@ -33,6 +39,7 @@ import axios from 'axios';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/settings');
+11 -6
View File
@@ -1,6 +1,11 @@
<template>
<div class="flex-grow-1" style="display: flex; flex-direction: column; height: 100%;">
<div style="flex-grow: 1; width: 100%; border: 1px solid #eee; border-radius: 8px; padding: 16px">
<v-banner lines="one">
<template v-slot:text>
建议您更换使用新版知识库功能
</template>
</v-banner>
<!-- knowledge card -->
<div v-if="!installed" class="d-flex align-center justify-center flex-column"
style="flex-grow: 1; width: 100%; height: 100%;">
@@ -105,9 +110,9 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="error" variant="text" @click="showCreateDialog = false">{{ tm('createDialog.cancel')
}}</v-btn>
}}</v-btn>
<v-btn color="primary" variant="text" @click="submitCreateForm">{{ tm('createDialog.create')
}}</v-btn>
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -132,7 +137,7 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="showEmojiPicker = false">{{ tm('emojiPicker.close')
}}</v-btn>
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -159,8 +164,8 @@
<v-chip v-if="currentKB.rerank_provider_id" color="tertiary" variant="tonal" size="small"
rounded="sm">
<v-icon start size="small">mdi-sort-variant</v-icon>
重排序模型: {{ rerankProviderConfigs.
find(provider => provider.id === currentKB.rerank_provider_id)?.rerank_model || '未设置' }}
重排序模型: {{rerankProviderConfigs.
find(provider => provider.id === currentKB.rerank_provider_id)?.rerank_model || '未设置'}}
</v-chip>
<small style="margin-left: 8px;">💡 使用方式: 在聊天页中输入 "/kb use {{ currentKB.collection_name }}"</small>
</div>
@@ -411,7 +416,7 @@
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" variant="text" @click="showDeleteDialog = false">{{
tm('deleteDialog.cancel')
}}</v-btn>
}}</v-btn>
<v-btn color="error" variant="text" @click="deleteKnowledgeBase" :loading="deleting">{{
tm('deleteDialog.delete') }}</v-btn>
</v-card-actions>
@@ -1,24 +1,25 @@
<script setup lang="ts">
import AuthLogin from '../authForms/AuthLogin.vue';
import Logo from '@/components/shared/Logo.vue';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
import {useCustomizerStore} from "@/stores/customizer";
import { useCustomizerStore } from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
const cardVisible = ref(false);
const router = useRouter();
const authStore = useAuthStore();
const customizer = useCustomizerStore();
const { tm: t } = useModuleI18n('features/auth');
const theme = useTheme();
//
function toggleTheme() {
customizer.SET_UI_THEME(
customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark'
);
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
customizer.SET_UI_THEME(newTheme);
theme.global.name.value = newTheme;
}
onMounted(() => {
@@ -27,7 +28,7 @@ onMounted(() => {
router.push(authStore.returnUrl || '/');
return;
}
//
setTimeout(() => {
cardVisible.value = true;
@@ -36,139 +37,17 @@ onMounted(() => {
</script>
<template>
<div v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="login-page-container">
<div class="login-background"></div>
<div class="login-container">
<!-- 桌面端卡片样式 -->
<v-card
v-if="!$vuetify.display.xs"
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
<!-- 移动端全屏样式 -->
<div
v-else
class="mobile-login-container"
:class="{ 'mobile-visible': cardVisible }"
>
<div class="mobile-content">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</div>
</div>
<!-- 悬浮式圆角工具栏 -->
<v-card
class="floating-toolbar"
:class="{ 'toolbar-visible': cardVisible }"
elevation="8"
rounded="xl"
>
<v-card-text class="pa-2">
<div class="login-page-container">
<v-card class="login-card" elevation="1">
<v-card-title>
<div class="d-flex justify-space-between align-center w-100">
<img width="80" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.7 !important; align-self: center !important; border-color: rgba(94, 53, 177, 0.4) !important;"></v-divider>
<v-btn
@click="toggleTheme"
class="theme-toggle-btn"
icon
variant="text"
size="small"
>
<v-icon
size="18"
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
>
mdi-weather-night
</v-icon>
<v-tooltip activator="parent" location="top">
{{ t('theme.switchToDark') }}
</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</div>
<div v-else class="login-page-container-dark">
<div class="login-background-dark"></div>
<div class="login-container">
<!-- 桌面端卡片样式 -->
<v-card
v-if="!$vuetify.display.xs"
variant="outlined"
class="login-card"
:class="{ 'card-visible': cardVisible }"
>
<v-card-text class="pa-10">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</v-card-text>
</v-card>
<!-- 移动端全屏样式 -->
<div
v-else
class="mobile-login-container"
:class="{ 'mobile-visible': cardVisible }"
>
<div class="mobile-content">
<div class="logo-wrapper">
<Logo :title="t('logo.title')" :subtitle="t('logo.subtitle')" />
</div>
<div class="divider-container">
<v-divider class="custom-divider"></v-divider>
</div>
<AuthLogin />
</div>
</div>
<!-- 悬浮式圆角工具栏 -->
<v-card
class="floating-toolbar"
:class="{ 'toolbar-visible': cardVisible }"
elevation="8"
rounded="xl"
>
<v-card-text class="pa-2">
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1" style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
<v-btn
@click="toggleTheme"
class="theme-toggle-btn"
icon
variant="text"
size="small"
>
<v-icon
size="18"
:color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"
>
<v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'">
mdi-white-balance-sunny
</v-icon>
<v-tooltip activator="parent" location="top">
@@ -176,288 +55,31 @@ onMounted(() => {
</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
</div>
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
</v-card-title>
<v-card-text>
<AuthLogin />
</v-card-text>
</v-card>
</div>
</template>
<style lang="scss">
.login-page-container {
background-color: rgb(var(--v-theme-containerBg));
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background:
linear-gradient(-45deg,
#faf9f7 0%,
#f9f2f1 25%,
#f1f9f9 50%,
#f9f3f7 75%,
#faf9f7 100%
);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
overflow: hidden;
}
.login-page-container-dark {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: relative;
background:
linear-gradient(-45deg,
#1e1f21 0%,
#221e25 25%,
#1e2225 50%,
#221f23 75%,
#1e1f21 100%
);
background-size: 400% 400%;
animation: gradientShift 15s ease infinite;
overflow: hidden;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.login-background {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: radial-gradient(circle, rgba(94, 53, 177, 0.02) 0%, rgba(94, 53, 177, 0.03) 70%);
z-index: 0;
animation: rotate 60s linear infinite;
}
.login-background-dark {
position: absolute;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
background: radial-gradient(circle, rgba(114, 46, 209, 0.03) 0%, rgba(114, 46, 209, 0.04) 70%);
z-index: 0;
animation: rotate 60s linear infinite;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.login-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.floating-toolbar {
background: #f8f6fc !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
backdrop-filter: blur(10px);
transform: translateY(20px);
opacity: 0;
transition: transform 0.6s ease 0.2s, opacity 0.6s ease 0.2s, border-color 0.3s ease, box-shadow 0.3s ease;
min-width: auto !important;
width: fit-content;
&.toolbar-visible {
transform: translateY(0);
opacity: 1;
}
&:hover {
transform: translateY(-2px);
border-color: rgba(158, 126, 222, 0.99) !important;
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
}
}
.login-page-container-dark .floating-toolbar {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
&:hover {
border-color: rgba(160, 118, 219, 0.782) !important;
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
}
}
.theme-toggle-btn {
transition: all 0.3s ease;
border-radius: 50% !important;
min-width: 32px !important;
width: 32px !important;
height: 32px !important;
&:hover {
transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important;
}
}
.login-page-container-dark .theme-toggle-btn:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
.login-card {
max-width: 520px;
width: 90%;
position: relative;
color: var(--v-theme-primaryText) !important;
border-radius: 16px !important;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
transform: translateY(20px);
opacity: 0;
transition: transform 0.5s ease, opacity 0.5s ease, border-color 0.3s ease, box-shadow 0.3s ease;
z-index: 1;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: calc(100% + 4px);
height: calc(100% + 4px);
transform: translate(-50%, -50%);
border-radius: 18px;
border: 2px solid rgba(94, 53, 177, 0);
transition: border-color 0.3s ease;
pointer-events: none;
z-index: -1;
}
&.card-visible {
transform: translateY(0);
opacity: 1;
}
&:hover {
border-color: rgba(158, 126, 222, 0.99) !important;
box-shadow: 0 12px 40px rgba(175, 145, 230, 0.741) !important;
transform: translateY(-2px);
&::before {
border-color: rgba(156, 114, 239, 0.907);
}
}
}
.login-page-container-dark .login-card {
border: 1px solid rgba(110, 60, 180, 0.692) !important;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3) !important;
background: #2a2733 !important;
&::before {
border: 2px solid rgba(114, 46, 209, 0);
}
&:hover {
border-color: rgba(160, 118, 219, 0.782) !important;
box-shadow: 0 12px 40px rgba(99, 44, 175, 0.462) !important;
transform: translateY(-2px);
&::before {
border-color: rgba(114, 46, 209, 0.15);
}
}
}
.logo-wrapper {
margin-bottom: 10px;
}
.divider-container {
margin: 20px 0;
}
.custom-divider {
border-color: rgba(94, 53, 177, 0.3) !important;
opacity: 1;
}
.login-page-container-dark .custom-divider {
border-color: rgba(180, 148, 246, 0.4) !important;
}
.loginBox {
max-width: 475px;
margin: 0 auto;
}
/* 移动端全屏登录样式 */
.mobile-login-container {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
transform: translateY(20px);
opacity: 0;
transition: transform 0.5s ease, opacity 0.5s ease;
z-index: 1;
&.mobile-visible {
transform: translateY(0);
opacity: 1;
}
}
.mobile-content {
width: 100%;
max-width: 400px;
padding: 40px 20px;
}
/* 移动端调整工具栏位置 */
@media (max-width: 599px) {
.floating-toolbar {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
z-index: 1000;
&.toolbar-visible {
transform: translateX(-50%) translateY(0);
}
&:hover {
transform: translateX(-50%) translateY(-2px);
}
}
.login-container {
gap: 0;
}
width: 400px;
padding: 8px;
}
</style>
@@ -1,9 +1,8 @@
<script setup lang="ts">
import {ref, useCssModule} from 'vue';
import { ref, useCssModule } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { Form } from 'vee-validate';
import md5 from 'js-md5';
import {useCustomizerStore} from "@/stores/customizer";
import { useModuleI18n } from '@/i18n/composables';
const { tm: t } = useModuleI18n('features/auth');
@@ -17,7 +16,7 @@ const loading = ref(false);
/* eslint-disable @typescript-eslint/no-explicit-any */
async function validate(values: any, { setErrors }: any) {
loading.value = true;
// md5
let password_ = password.value;
if (password.value != '') {
@@ -41,58 +40,25 @@ async function validate(values: any, { setErrors }: any) {
<template>
<Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
<v-text-field
v-model="username"
:label="t('username')"
class="mb-6 input-field"
required
density="comfortable"
hide-details="auto"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
prepend-inner-icon="mdi-account"
:disabled="loading"
></v-text-field>
<v-text-field
v-model="password"
:label="t('password')"
required
density="comfortable"
variant="outlined"
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000dd' : '#ffffff'}"
hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'"
:type="show1 ? 'text' : 'password'"
@click:append="show1 = !show1"
class="pwd-input"
prepend-inner-icon="mdi-lock"
:disabled="loading"
></v-text-field>
<v-btn
color="secondary"
:loading="isSubmitting || loading"
block
class="login-btn mt-8"
variant="flat"
size="large"
:disabled="valid"
type="submit"
elevation="2"
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
>
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
@click:append="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>
<div class="mt-2">
<small style="color: grey;">{{ t('defaultHint') }}</small>
</div>
<v-btn color="secondary" :loading="isSubmitting || loading" block class="login-btn mt-8" variant="flat" size="large"
:disabled="valid" type="submit">
<span class="login-btn-text">{{ t('login') }}</span>
</v-btn>
<div v-if="errors.apiError" class="mt-4 error-container">
<v-alert
color="error"
variant="tonal"
density="comfortable"
icon="mdi-alert-circle"
border="start"
>
<v-alert color="error" variant="tonal" icon="mdi-alert-circle" border="start">
{{ errors.apiError }}
</v-alert>
</div>
@@ -105,24 +71,25 @@ async function validate(values: any, { setErrors }: any) {
font-weight: 500;
}
.input-field, .pwd-input {
.input-field,
.pwd-input {
.v-field__field {
padding-top: 5px;
padding-bottom: 5px;
}
.v-field__outline {
opacity: 0.7;
}
&:hover .v-field__outline {
opacity: 0.9;
}
.v-field--focused .v-field__outline {
opacity: 1;
}
.v-field__prepend-inner {
padding-right: 8px;
opacity: 0.7;
@@ -138,36 +105,36 @@ async function validate(values: any, { setErrors }: any) {
top: 50%;
transform: translateY(-50%);
opacity: 0.7;
&:hover {
opacity: 1;
}
}
}
.login-btn {
margin-top: 12px;
height: 48px;
transition: all 0.3s ease;
letter-spacing: 0.5px;
border-radius: 8px !important;
&:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(94, 53, 177, 0.2) !important;
}
.login-btn-text {
font-size: 1.05rem;
font-weight: 500;
}
}
.hint-text {
color: var(--v-theme-secondaryText);
padding-left: 5px;
}
.error-container {
.v-alert {
border-left-width: 4px !important;
@@ -0,0 +1,512 @@
<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">
{{ totalChunks }} {{ 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="pageSize"
hide-default-footer
>
<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-delete"
variant="text"
size="small"
color="error"
@click="deleteChunk(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>
<!-- 自定义分页器 -->
<div v-if="!searchQuery && totalChunks > 0" class="pa-4 d-flex align-center justify-space-between">
<div class="text-caption text-medium-emphasis">
{{ t('chunks.showing') }} {{ (page - 1) * pageSize + 1 }} - {{ Math.min(page * pageSize, totalChunks) }} / {{ totalChunks }}
</div>
<div class="d-flex align-center gap-2">
<v-select
v-model="pageSize"
:items="[10, 25, 50, 100]"
density="compact"
variant="outlined"
hide-details
style="width: 100px"
@update:model-value="handlePageSizeChange"
/>
<v-pagination
v-model="page"
:length="Math.ceil(totalChunks / pageSize)"
:total-visible="5"
@update:model-value="handlePageChange"
/>
</div>
</div>
</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?.chunk_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-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 document = ref<any>({})
const chunks = ref<any[]>([])
const searchQuery = ref('')
const showViewDialog = ref(false)
const selectedChunk = ref<any>(null)
//
const page = ref(1)
const pageSize = ref(10)
const totalChunks = ref(0)
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 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, 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, kb_id: kbId.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,
kb_id: kbId.value,
page: page.value,
page_size: pageSize.value
}
})
if (response.data.status === 'ok') {
chunks.value = response.data.data.items || []
totalChunks.value = response.data.data.total || 0
}
} catch (error) {
console.error('Failed to load chunks:', error)
showSnackbar('加载分块列表失败', 'error')
} finally {
loadingChunks.value = false
}
}
//
const handlePageChange = (newPage: number) => {
page.value = newPage
loadChunks()
}
const handlePageSizeChange = (newPageSize: number) => {
pageSize.value = newPageSize
page.value = 1
loadChunks()
}
//
const viewChunk = (chunk: any) => {
selectedChunk.value = chunk
showViewDialog.value = true
}
//
const deleteChunk = async (chunk: any) => {
if (!confirm(t('chunks.deleteConfirm'))) return
try {
const response = await axios.post('/api/kb/chunk/delete', {
chunk_id: chunk.chunk_id,
doc_id: docId.value,
kb_id: kbId.value
})
if (response.data.status === 'ok') {
showSnackbar(t('chunks.deleteSuccess'))
loadChunks()
} else {
showSnackbar(t('chunks.deleteFailed'), 'error')
}
} catch (error) {
console.error('Failed to delete chunk:', error)
showSnackbar(t('chunks.deleteFailed'), 'error')
}
}
//
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;
}
.gap-2 {
gap: 8px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.document-detail-page {
padding: 16px;
}
}
</style>
@@ -0,0 +1,351 @@
<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="retrieval">
<v-icon start>mdi-magnify</v-icon>
{{ t('tabs.retrieval') }}
</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" style="padding: 8px;">
<!-- 概览 -->
<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="retrieval">
<RetrievalTab :kb-id="kbId" :kb-name="kb.kb_name"/>
</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 RetrievalTab from './components/RetrievalTab.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 {
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.1);
transition: all 0.3s ease;
}
.stat-box:hover {
background: rgba(var(--v-theme-surface-variant), 0.5);
}
.stat-value {
font-size: 2rem;
font-weight: 600;
margin-top: 8px;
}
.stat-label {
font-size: 0.875rem;
margin-top: 4px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.kb-title {
flex-direction: column;
align-items: flex-start;
}
.kb-emoji {
font-size: 36px;
}
}
</style>
@@ -0,0 +1,615 @@
<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">
<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" hint="后续如修改知识库名称,需重新在配置文件更新。" persistent-hint />
<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" :disabled="editingKB !== null" hint="嵌入模型选择后无法修改,如需更换请创建新的知识库。" persistent-hint>
<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-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 class="position-absolute" style="bottom: 0px; right: 16px;">
<small @click="router.push('/alkaid/knowledge-base')"><a style="text-decoration: underline; cursor: pointer;">切换到旧版知识库</a></small>
</div>
</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 originalEmbeddingProvider = ref<string | null>(null)
const showEmbeddingWarning = ref(false)
const embeddingChangeDialog = ref(false)
const pendingEmbeddingProvider = ref<string | null>(null)
//
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 (refreshStats = false) => {
loading.value = true
try {
const params: any = {}
if (refreshStats) {
params.refresh_stats = 'true'
}
const response = await axios.get('/api/kb/list', { params })
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
originalEmbeddingProvider.value = kb.embedding_provider_id
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
originalEmbeddingProvider.value = null
showEmbeddingWarning.value = false
pendingEmbeddingProvider.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(true) //
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: 8px;
}
.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));
font-weight: 500;
}
.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: 0px 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,875 @@
<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)" class="mr-2">
{{ getFileIcon(item.file_type) }}
</v-icon>
<div class="flex-grow-1" style="padding: 4px 0px;">
<span class="font-weight-medium">{{ item.doc_name }}</span>
<!-- 上传进度 -->
<div v-if="item.uploading" class="mt-1">
<div class="text-caption text-medium-emphasis mb-1">
{{ getStageText(item.uploadProgress?.stage || 'waiting') }}
<span v-if="item.uploadProgress?.current">
({{ item.uploadProgress.current }} / {{ item.uploadProgress.total }})
</span>
</div>
<v-progress-linear :model-value="getUploadPercentage(item)" color="primary" height="4" rounded
striped />
</div>
</div>
</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="650px" persistent @after-enter="initUploadSettings">
<v-card>
<v-card-title class="pa-4 d-flex align-center">
<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-tabs v-model="uploadMode" grow class="mb-4">
<v-tab value="file">{{ t('upload.fileUpload') }}</v-tab>
<v-tab value="url">
{{ t('upload.fromUrl') }}
<v-badge color="warning" :content="t('upload.beta')" inline class="ml-2" />
</v-tab>
</v-tabs>
<v-card-text class="pa-6 pt-2">
<v-window v-model="uploadMode">
<!-- 文件上传 -->
<v-window-item value="file">
<!-- 文件选择 -->
<div class="upload-dropzone" :class="{ 'dragover': isDragging }" @drop.prevent="handleDrop"
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="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') }}.txt, .md, .pdf,
.docx,
.xls, .xlsx</p>
<p class="text-caption text-medium-emphasis">{{ t('upload.maxSize') }}</p>
<p class="text-caption text-medium-emphasis">最多可上传 10 个文件</p>
<input ref="fileInput" type="file" multiple hidden accept=".txt,.md,.pdf,.docx,.xls,.xlsx"
@change="handleFileSelect" />
</div>
<div v-if="selectedFiles.length > 0" class="mt-4">
<div class="d-flex align-center justify-space-between mb-2">
<span class="text-subtitle-2">已选择 {{ selectedFiles.length }} 个文件</span>
<v-btn variant="text" size="small" @click="selectedFiles = []">清空</v-btn>
</div>
<div class="files-list">
<div v-for="(file, index) in selectedFiles" :key="index"
class="file-item pa-3 mb-2 rounded bg-surface-variant">
<div class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<v-icon>{{ getFileIcon(file.name) }}</v-icon>
<div>
<div class="font-weight-medium">{{ file.name }}</div>
<div class="text-caption">{{ formatFileSize(file.size) }}</div>
</div>
</div>
<v-btn icon="mdi-close" variant="text" size="small" @click="removeFile(index)" />
</div>
</div>
</div>
</div>
</v-window-item>
<!-- URL上传 -->
<v-window-item value="url" class="pt-2">
<!-- Tavily Key 快速配置 -->
<div v-if="tavilyConfigStatus === 'not_configured' || tavilyConfigStatus === 'error'" class="mb-4">
<v-alert :type="tavilyConfigStatus === 'error' ? 'error' : 'info'" variant="tonal" density="compact">
<div class="d-flex align-center justify-space-between">
<span>
{{ tavilyConfigStatus === 'error' ? '检查网页搜索配置失败' : '使用此功能需要配置 Tavily Key' }}
</span>
<v-btn size="small" variant="flat" @click="showTavilyDialog = true">
配置
</v-btn>
</div>
</v-alert>
</div>
<v-text-field v-model="uploadUrl" :label="t('upload.urlPlaceholder')" variant="outlined" clearable :disabled="tavilyConfigStatus === 'not_configured'"
autofocus :hint="t('upload.urlHint', { supported: 'HTML' })" persistent-hint />
</v-window-item>
</v-window>
<!-- 清洗设置 (仅在URL模式下显示) -->
<div v-if="uploadMode === 'url'" class="mt-6">
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('upload.cleaningSettings') }}</h3>
</div>
<v-row>
<v-col cols="12" sm="4">
<v-switch v-model="uploadSettings.enable_cleaning" :label="t('upload.enableCleaning')" color="primary" />
</v-col>
<v-col cols="12" sm="8">
<v-select v-model="uploadSettings.cleaning_provider_id" :items="llmProviders" item-title="id"
item-value="id" :label="t('upload.cleaningProvider')" :hint="t('upload.cleaningProviderHint')"
persistent-hint variant="outlined" density="compact" :disabled="!uploadSettings.enable_cleaning" />
</v-col>
</v-row>
</div>
<!-- 分块设置 -->
<div class="mt-6">
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('upload.chunkSettings') }}</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-text-field v-model.number="uploadSettings.chunk_size" :label="t('upload.chunkSize')"
:hint="t('upload.chunkSizeHint')" persistent-hint type="number" variant="outlined" density="compact"
:placeholder="props.kb?.chunk_size?.toString() || '512'" />
</v-col>
<v-col cols="12" sm="6">
<v-text-field v-model.number="uploadSettings.chunk_overlap" :label="t('upload.chunkOverlap')"
:hint="t('upload.chunkOverlapHint')" persistent-hint type="number" variant="outlined"
density="compact" :placeholder="props.kb?.chunk_overlap?.toString() || '50'" />
</v-col>
</v-row>
</div>
<div class="mt-2">
<h3 class="text-h6 mb-4">{{ t('upload.batchSettings') }}</h3>
<v-row>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.batch_size" :label="t('upload.batchSize')" hint="每批处理的文本数量"
persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.tasks_limit" :label="t('upload.tasksLimit')"
hint="并发任务数量限制" persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
<v-col cols="12" sm="4">
<v-text-field v-model.number="uploadSettings.max_retries" :label="t('upload.maxRetries')"
hint="失败时的最大重试次数" persistent-hint type="number" variant="outlined" density="compact" />
</v-col>
</v-row>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="closeUploadDialog" :disabled="uploading">
{{ t('upload.cancel') }}
</v-btn>
<v-btn color="primary" variant="elevated" @click="startUpload" :loading="uploading"
:disabled="isUploadDisabled">
{{ 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>
<!-- Tavily Key 配置对话框 -->
<TavilyKeyDialog v-model="showTavilyDialog" @success="onTavilyKeySet" />
</div>
</template>
<script setup lang="ts">
import TavilyKeyDialog from './TavilyKeyDialog.vue'
import { ref, onMounted, onUnmounted, computed } 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 selectedFiles = ref<File[]>([])
const deleteTarget = ref<any>(null)
const isDragging = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const uploadMode = ref('file') // 'file' or 'url'
const uploadUrl = ref('')
const llmProviders = ref<any[]>([])
const uploadingTasks = ref<Map<string, any>>(new Map())
const progressPollingInterval = ref<number | null>(null)
const tavilyConfigStatus = ref('loading') // 'loading', 'configured', 'not_configured', 'error'
const showTavilyDialog = 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 as number | null,
chunk_overlap: null as number | null,
batch_size: 32,
tasks_limit: 3,
max_retries: 3,
enable_cleaning: false,
cleaning_provider_id: null as string | null
})
//
const initUploadSettings = () => {
uploadSettings.value = {
chunk_size: props.kb?.chunk_size || null,
chunk_overlap: props.kb?.chunk_overlap || null,
batch_size: 32,
tasks_limit: 3,
max_retries: 3,
enable_cleaning: false,
cleaning_provider_id: null
}
}
const isUploadDisabled = computed(() => {
if (uploading.value) {
return true
}
if (uploadMode.value === 'file') {
return selectedFiles.value.length === 0
}
if (uploadMode.value === 'url') {
if (!uploadUrl.value) {
return true
}
if (uploadSettings.value.enable_cleaning && !uploadSettings.value.cleaning_provider_id) {
return true
}
return false
}
return true
})
//
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' as const }
]
//
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.length > 0) {
const newFiles = Array.from(target.files)
addFiles(newFiles)
}
}
//
const addFiles = (files: File[]) => {
const totalFiles = selectedFiles.value.length + files.length
if (totalFiles > 10) {
showSnackbar('最多只能选择 10 个文件', 'warning')
return
}
selectedFiles.value.push(...files)
}
//
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
//
const handleDrop = (event: DragEvent) => {
isDragging.value = false
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
const newFiles = Array.from(event.dataTransfer.files)
addFiles(newFiles)
}
}
//
const startUpload = async () => {
if (uploadMode.value === 'file') {
await uploadFiles()
} else if (uploadMode.value === 'url') {
await uploadFromUrl()
}
}
//
const uploadFiles = async () => {
if (selectedFiles.value.length === 0) {
showSnackbar(t('upload.fileRequired'), 'warning')
return
}
uploading.value = true
try {
const formData = new FormData()
//
selectedFiles.value.forEach((file, index) => {
formData.append(`file${index}`, file)
})
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())
}
formData.append('batch_size', uploadSettings.value.batch_size.toString())
formData.append('tasks_limit', uploadSettings.value.tasks_limit.toString())
formData.append('max_retries', uploadSettings.value.max_retries.toString())
const response = await axios.post('/api/kb/document/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (response.data.status === 'ok') {
const result = response.data.data
const taskId = result.task_id
showSnackbar(`正在后台上传 ${result.file_count} 个文件...`, 'info')
//
const uploadingDocs = selectedFiles.value.map((file, index) => ({
doc_id: `uploading_${taskId}_${index}`,
doc_name: file.name,
file_type: file.name.split('.').pop() || '',
file_size: file.size,
chunk_count: 0,
created_at: new Date().toISOString(),
uploading: true,
taskId: taskId,
uploadProgress: {
stage: 'waiting',
current: 0,
total: 100
}
}))
//
documents.value = [...uploadingDocs, ...documents.value]
//
closeUploadDialog()
//
if (taskId) {
startProgressPolling(taskId)
}
} 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
}
}
// URL
const uploadFromUrl = async () => {
if (!uploadUrl.value) {
showSnackbar(t('upload.urlRequired'), 'warning')
return
}
uploading.value = true
try {
const payload: any = {
kb_id: props.kbId,
url: uploadUrl.value,
batch_size: uploadSettings.value.batch_size,
tasks_limit: uploadSettings.value.tasks_limit,
max_retries: uploadSettings.value.max_retries
}
if (uploadSettings.value.chunk_size) {
payload.chunk_size = uploadSettings.value.chunk_size
}
if (uploadSettings.value.chunk_overlap) {
payload.chunk_overlap = uploadSettings.value.chunk_overlap
}
if (uploadSettings.value.enable_cleaning) {
payload.enable_cleaning = true
if (uploadSettings.value.cleaning_provider_id) {
payload.cleaning_provider_id = uploadSettings.value.cleaning_provider_id
}
}
const response = await axios.post('/api/kb/document/upload/url', payload)
if (response.data.status === 'ok') {
const result = response.data.data
const taskId = result.task_id
showSnackbar(`正在从 URL 后台提取内容...`, 'info')
//
const uploadingDoc = {
doc_id: `uploading_${taskId}_0`,
doc_name: result.url,
file_type: 'url',
file_size: 0, // URL has no size
chunk_count: 0,
created_at: new Date().toISOString(),
uploading: true,
taskId: taskId,
uploadProgress: {
stage: 'waiting',
current: 0,
total: 100
}
}
documents.value = [uploadingDoc, ...documents.value]
closeUploadDialog()
if (taskId) {
startProgressPolling(taskId)
}
} else {
showSnackbar(response.data.message || t('documents.uploadFailed'), 'error')
}
} catch (error: any) {
console.error('Failed to upload from URL:', error)
const message = error.response?.data?.message || t('documents.uploadFailed')
showSnackbar(message, 'error')
} finally {
uploading.value = false
}
}
//
const startProgressPolling = (taskId: string) => {
//
if (progressPollingInterval.value) {
stopProgressPolling()
}
progressPollingInterval.value = window.setInterval(async () => {
try {
const response = await axios.get('/api/kb/document/upload/progress', {
params: { task_id: taskId }
})
if (response.data.status === 'ok') {
const data = response.data.data
const status = data.status
if (status === 'processing' && data.progress) {
//
const progress = data.progress
const fileIndex = progress.file_index || 0
//
documents.value = documents.value.map(doc => {
if (doc.taskId === taskId) {
const docIndex = parseInt(doc.doc_id.split('_').pop() || '0')
if (docIndex === fileIndex) {
return {
...doc,
uploadProgress: {
stage: progress.stage || 'waiting',
current: progress.current || 0,
total: progress.total || 100
}
}
}
}
return doc
})
} else if (status === 'completed') {
//
stopProgressPolling()
const result = data.result
const successCount = result?.success_count || 0
const failedCount = result?.failed_count || 0
//
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
//
await loadDocuments()
emit('refresh')
if (failedCount === 0) {
showSnackbar(`成功上传 ${successCount} 个文档`)
} else {
showSnackbar(`上传完成: ${successCount} 个成功, ${failedCount} 个失败`, 'warning')
}
} else if (status === 'failed') {
//
stopProgressPolling()
//
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
showSnackbar(`上传失败: ${data.error || '未知错误'}`, 'error')
}
} else {
//
stopProgressPolling()
documents.value = documents.value.filter(doc => doc.taskId !== taskId)
}
} catch (error) {
console.error('Failed to fetch progress:', error)
//
}
}, 500) // 500ms
}
//
const stopProgressPolling = () => {
if (progressPollingInterval.value) {
clearInterval(progressPollingInterval.value)
progressPollingInterval.value = null
}
}
//
const getUploadPercentage = (item: any) => {
if (!item.uploadProgress) return 0
const { current, total } = item.uploadProgress
if (!total || total === 0) return 0
return (current / total) * 100
}
//
const getStageText = (stage: string) => {
const stageMap: Record<string, string> = {
'waiting': '等待中...',
'extracting': '提取内容...',
'cleaning': '清洗内容...',
'parsing': '解析文档...',
'chunking': '文本分块...',
'embedding': '生成向量...'
}
return stageMap[stage] || stage
}
//
const closeUploadDialog = () => {
showUploadDialog.value = false
selectedFiles.value = []
uploadUrl.value = ''
uploadMode.value = 'file'
initUploadSettings()
}
//
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,
kb_id: props.kbId
})
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'
if (type.includes('url')) return 'mdi-link-variant'
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'
if (type.includes('url')) return 'primary'
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'
})
}
// LLM providers
const loadLlmProviders = async () => {
try {
const response = await axios.get('/api/config/provider/list', {
params: { provider_type: 'chat_completion' }
})
if (response.data.status === 'ok') {
llmProviders.value = response.data.data
}
} catch (error) {
console.error('Failed to load LLM providers:', error)
}
}
// Tavily Key
const checkTavilyConfig = async () => {
tavilyConfigStatus.value = 'loading'
try {
const response = await axios.get('/api/config/abconf', {
params: { id: 'default' }
})
if (response.data.status === 'ok') {
const config = response.data.data.config
const tavilyKeys = config?.provider_settings?.websearch_tavily_key
if (Array.isArray(tavilyKeys) && tavilyKeys.length > 0 && tavilyKeys.some(key => key.trim() !== '')) {
tavilyConfigStatus.value = 'configured'
} else {
tavilyConfigStatus.value = 'not_configured'
}
} else {
tavilyConfigStatus.value = 'error'
}
} catch (error) {
console.warn('Failed to check Tavily key config:', error)
tavilyConfigStatus.value = 'error'
}
}
const onTavilyKeySet = () => {
showSnackbar('Tavily API Key 配置成功', 'success')
checkTavilyConfig()
}
onMounted(() => {
loadDocuments()
loadLlmProviders()
checkTavilyConfig()
})
onUnmounted(() => {
stopProgressPolling()
})
</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);
}
.files-list {
max-height: 300px;
overflow-y: auto;
}
.file-item {
transition: all 0.2s ease;
}
.file-item:hover {
background: rgba(var(--v-theme-surface-variant), 0.8) !important;
}
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: stretch;
}
.action-bar>* {
width: 100%;
}
}
</style>
@@ -0,0 +1,248 @@
<template>
<div class="retrieval-tab">
<v-card elevation="2">
<v-card-title class="pa-4 pb-0">{{ t('retrieval.title') }}</v-card-title>
<v-card-subtitle class="pb-4 pt-2">
{{ t('retrieval.subtitle') }}
</v-card-subtitle>
<v-divider />
<v-progress-linear v-if="loading" indeterminate color="primary" height="2" />
<v-card-text class="pa-6">
<!-- 查询输入区域 -->
<v-row class="mb-4">
<v-col cols="12" md="8">
<v-textarea v-model="query" :label="t('retrieval.query')" :placeholder="t('retrieval.queryPlaceholder')"
variant="outlined" rows="3" auto-grow clearable />
<!-- debug -->
<div v-if="debugVisualize" class="mt-2">
<v-card variant="outlined">
<v-img :src="`data:image/png;base64,${debugVisualize}`" :alt="t('retrieval.tsneVisualization')" cover>
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular indeterminate color="primary" />
</div>
</template>
</v-img>
</v-card>
</div>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="pa-4">
<h4 class="text-subtitle-2 mb-3">{{ t('retrieval.settings') }}</h4>
<v-text-field v-model.number="topK" :label="t('retrieval.topK')" :hint="t('retrieval.topKHint')"
type="number" variant="outlined" density="compact" persistent-hint class="mb-3" />
<v-switch v-model="debugMode" :label="t('retrieval.debugMode')" color="primary" density="compact"
hide-details>
<template v-slot:label>
<span class="text-caption">
<v-icon size="small" class="mr-1">mdi-bug</v-icon>
Debug (t-SNE)
</span>
</template>
</v-switch>
</v-card>
</v-col>
</v-row>
<div class="d-flex justify-end mb-4">
<v-btn prepend-icon="mdi-magnify" color="primary" variant="elevated" @click="performRetrieval"
:loading="loading" :disabled="!query || query.trim() === ''">
{{ loading ? t('retrieval.searching') : t('retrieval.search') }}
</v-btn>
</div>
<!-- 检索结果 -->
<div v-if="hasSearched" class="results-section">
<v-divider class="mb-4" />
<div class="d-flex align-center mb-4">
<h3 class="text-h6">{{ t('retrieval.results') }}</h3>
<v-chip class="ml-3" color="primary" variant="tonal" size="small">
{{ results.length }} {{ t('retrieval.results') }}
</v-chip>
</div>
<!-- 结果列表 -->
<div v-if="results.length > 0" class="results-list">
<v-card v-for="(result, index) in results" :key="result.chunk_id" variant="outlined" class="mb-4">
<v-card-title class="d-flex align-center pa-2">
<v-chip size="x-small" color="primary" class="mr-2">
#{{ index + 1 }}
</v-chip>
<span class="text-subtitle-1">
{{ t('retrieval.chunk', { index: result.chunk_index }) }}
</span>
<div class="ml-4">
<v-chip size="x-small" variant="tonal" class="mr-2">
<v-icon start size="small">mdi-file-document</v-icon>
{{ result.doc_name }}
</v-chip>
<v-chip size="x-small" variant="tonal">
<v-icon start size="small">mdi-text</v-icon>
{{ t('retrieval.charCount', { count: result.char_count }) }}
</v-chip>
</div>
<v-spacer />
<v-chip size="x-small" :color="getScoreColor(result.score)">
{{ t('retrieval.score') }}: {{ result.score.toFixed(4) }}
</v-chip>
</v-card-title>
<v-divider />
<v-card-text class="pa-4">
<div class="content-box">
{{ result.content }}
</div>
</v-card-text>
</v-card>
</div>
<!-- 空结果 -->
<div v-else class="text-center py-12">
<v-icon size="80" color="grey-lighten-2">mdi-text-box-search-outline</v-icon>
<p class="text-h6 mt-4 text-medium-emphasis">{{ t('retrieval.noResults') }}</p>
<p class="text-body-2 text-medium-emphasis">{{ t('retrieval.tryDifferentQuery') }}</p>
</div>
</div>
</v-card-text>
</v-card>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color">
{{ snackbar.text }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm: t } = useModuleI18n('features/knowledge-base/detail')
const props = defineProps<{
kbId: string,
kbName: string,
}>()
//
const loading = ref(false)
const query = ref('')
const topK = ref(5)
const debugMode = ref(false)
const results = ref<any[]>([])
const hasSearched = ref(false)
const debugVisualize = ref<string | null>(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 performRetrieval = async () => {
if (!query.value || query.value.trim() === '') {
showSnackbar(t('retrieval.queryRequired'), 'warning')
return
}
loading.value = true
hasSearched.value = false
debugVisualize.value = null
try {
const response = await axios.post('/api/kb/retrieve', {
query: query.value,
kb_names: [props.kbName],
top_k: topK.value,
debug: debugMode.value
})
if (response.data.status === 'ok') {
results.value = response.data.data.results || []
hasSearched.value = true
if (debugMode.value && response.data.data.visualization) {
debugVisualize.value = response.data.data.visualization
}
showSnackbar(t('retrieval.searchSuccess', { count: results.value.length }))
} else {
showSnackbar(response.data.message || t('retrieval.searchFailed'), 'error')
}
} catch (error) {
console.error('Retrieval failed:', error)
showSnackbar(t('retrieval.searchFailed'), 'error')
} finally {
loading.value = false
}
}
//
const getScoreColor = (score: number) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'info'
if (score >= 0.4) return 'warning'
return 'error'
}
</script>
<style scoped>
.retrieval-tab {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.results-section {
animation: slideUp 0.4s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.content-box {
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 16px;
white-space: pre-wrap;
word-break: break-word;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
line-height: 1.6;
height: 120px;
overflow-y: auto;
font-size: 13px;
}
</style>
@@ -0,0 +1,316 @@
<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="6">
<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="6">
<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>
<!-- 模型设置 -->
<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"
@update:model-value="handleEmbeddingProviderChange"
:disabled="true"
/>
</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-alert type="warning" variant="tonal" class="mt-4" v-if="showEmbeddingWarning">
<strong>注意:</strong> 修改嵌入模型会导致现有的向量数据失效,建议重新上传文档不同的嵌入模型生成的向量不兼容,可能导致检索结果不准确
</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>
<!-- Embedding Provider修改确认对话框 -->
<v-dialog v-model="embeddingChangeDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-warning text-white">
<v-icon class="mr-2">mdi-alert</v-icon>
确认修改嵌入模型
</v-card-title>
<v-card-text class="pa-6">
<v-alert type="warning" variant="tonal" class="mb-4">
<strong>警告:</strong> 修改嵌入模型将导致以下影响:
</v-alert>
<ul class="text-body-2">
<li>现有的向量数据将失效</li>
<li>检索功能可能无法正常工作</li>
<li>建议删除现有文档后重新上传</li>
<li>不同嵌入模型生成的向量不兼容</li>
</ul>
<div class="mt-4 text-body-2">
您确定要将嵌入模型从 <strong>{{ originalEmbeddingProvider }}</strong> 修改为 <strong>{{ pendingEmbeddingProvider }}</strong> ?
</div>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer />
<v-btn variant="text" @click="cancelEmbeddingChange">
取消
</v-btn>
<v-btn color="warning" variant="elevated" @click="confirmEmbeddingChange">
确认修改
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</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 originalEmbeddingProvider = ref('')
const showEmbeddingWarning = ref(false)
const embeddingChangeDialog = ref(false)
const pendingEmbeddingProvider = 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({
chunk_size: 512,
chunk_overlap: 50,
top_k_dense: 50,
top_k_sparse: 50,
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,
embedding_provider_id: kb.embedding_provider_id || '',
rerank_provider_id: kb.rerank_provider_id || ''
}
// embedding provider
originalEmbeddingProvider.value = kb.embedding_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)
}
}
// embedding provider
const handleEmbeddingProviderChange = (newValue: string) => {
if (newValue && newValue !== originalEmbeddingProvider.value) {
//
showEmbeddingWarning.value = true
pendingEmbeddingProvider.value = newValue
embeddingChangeDialog.value = true
} else {
showEmbeddingWarning.value = false
}
}
// embedding provider
const confirmEmbeddingChange = () => {
formData.value.embedding_provider_id = pendingEmbeddingProvider.value
embeddingChangeDialog.value = false
showEmbeddingWarning.value = true
}
// embedding provider
const cancelEmbeddingChange = () => {
formData.value.embedding_provider_id = originalEmbeddingProvider.value
embeddingChangeDialog.value = false
showEmbeddingWarning.value = false
pendingEmbeddingProvider.value = ''
}
//
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,
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,109 @@
<template>
<v-dialog v-model="dialog" max-width="500px" persistent>
<v-card>
<v-card-title class="text-h5">
配置 Tavily API Key
</v-card-title>
<v-card-text>
<p class="mb-4 text-body-2 text-medium-emphasis">
为了使用基于网页的知识库功能需要提供 Tavily API Key您可以从 <a href="https://tavily.com/" target="_blank">Tavily 官网</a> 获取
</p>
<v-text-field
v-model="apiKey"
label="Tavily API Key"
variant="outlined"
:loading="saving"
:error-messages="errorMessage"
autofocus
clearable
placeholder="tvly-..."
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="closeDialog" :disabled="saving">
取消
</v-btn>
<v-btn color="primary" variant="elevated" @click="saveKey" :loading="saving">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import axios from 'axios'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits(['update:modelValue', 'success'])
const dialog = ref(props.modelValue)
const apiKey = ref('')
const saving = ref(false)
const errorMessage = ref('')
watch(() => props.modelValue, (val) => {
dialog.value = val
if (val) {
// Reset state when dialog opens
apiKey.value = ''
errorMessage.value = ''
saving.value = false
}
})
const closeDialog = () => {
emit('update:modelValue', false)
}
const saveKey = async () => {
if (!apiKey.value.trim()) {
errorMessage.value = 'API Key 不能为空'
return
}
errorMessage.value = ''
saving.value = true
try {
// 1.
const configResponse = await axios.get('/api/config/abconf', {
params: { id: 'default' }
})
if (configResponse.data.status !== 'ok') {
throw new Error('获取当前配置失败')
}
const currentConfig = configResponse.data.data.config
// 2.
if (!currentConfig.provider_settings) {
currentConfig.provider_settings = {}
}
currentConfig.provider_settings.websearch_tavily_key = [apiKey.value.trim()]
// tavily
currentConfig.provider_settings.websearch_provider = 'tavily'
// 3.
const saveResponse = await axios.post('/api/config/astrbot/update', {
conf_id: 'default',
config: currentConfig
})
if (saveResponse.data.status === 'ok') {
emit('success')
closeDialog()
} else {
errorMessage.value = saveResponse.data.message || '保存失败,请检查 Key 是否正确'
}
} catch (error: any) {
errorMessage.value = error.response?.data?.message || '保存失败,发生未知错误'
} finally {
saving.value = false
}
}
</script>
@@ -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>