feat: add ConfigRouteManagerDialog component for managing routing configurations

- Implemented a new dialog component for managing routes associated with configurations, allowing users to view, delete, and save routes.
- Enhanced the ProviderSelector component with improved styling for better readability.
- Updated English and Chinese localization files to include new strings for the route manager and profile sidebar.
- Refactored ConfigPage.vue to integrate the new route management dialog and improve layout responsiveness.
- Added methods for handling route management, including fetching, saving, and removing routes.
This commit is contained in:
Soulter
2026-03-01 19:37:26 +08:00
parent b4450eb617
commit 09b31c460d
7 changed files with 1016 additions and 175 deletions
+21 -14
View File
@@ -48,18 +48,29 @@ class Group:
class AstrBotMessage:
"""AstrBot 的消息对象"""
"""Represents a message received from the platform, after parsing and normalization.
This is the main message object that will be passed to plugins and handlers."""
type: MessageType # 消息类型
self_id: str # 机器人的识别id
session_id: str # 会话id。取决于 unique_session 的设置。
message_id: str # 消息id
group: Group | None # 群组
sender: MessageMember # 发送者
message: list[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
message_str: str # 最直观的纯文本消息字符串
type: MessageType
"""GroupMessage, FriendMessage, etc"""
self_id: str
"""Bot's ID"""
session_id: str
"""Session ID, which is the last part of UMO"""
message_id: str
"""Message ID"""
group: Group | None
"""The group info, None if it's a friend message"""
sender: MessageMember
"""The sender info"""
message: list[BaseMessageComponent]
"""Sorted list of message components after parsing"""
message_str: str
"""The parsed message text after parsing, without any formatting or special components"""
raw_message: object
timestamp: int # 消息时间戳
"""The raw message object, the specific type depends on the platform"""
timestamp: int
"""The timestamp when the message is received, in seconds"""
def __init__(self) -> None:
self.timestamp = int(time.time())
@@ -70,16 +81,12 @@ class AstrBotMessage:
@property
def group_id(self) -> str:
"""向后兼容的 group_id 属性
群组id,如果为私聊,则为空
"""
if self.group:
return self.group.group_id
return ""
@group_id.setter
def group_id(self, value: str | None) -> None:
"""设置 group_id"""
if value:
if self.group:
self.group.group_id = value
@@ -0,0 +1,213 @@
<template>
<div class="config-profile-sidebar">
<div class="d-flex align-center justify-space-between mb-3">
<h3 class="text-subtitle-1 font-weight-bold mb-0">
<v-icon size="18" class="mr-1">mdi-format-list-bulleted-square</v-icon>
{{ tm('profileSidebar.title') }}
</h3>
<v-tooltip :text="tm('configManagement.manageConfigs')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn v-bind="tooltipProps" size="small" variant="text" icon="mdi-cog" :disabled="disabled"
@click="emit('manage')" />
</template>
</v-tooltip>
</div>
<div class="config-profile-list">
<v-card v-for="config in configs" :key="config.id" class="profile-card" :class="{
'profile-card--active': config.id === selectedConfigId,
'profile-card--disabled': disabled
}" variant="outlined" @click="onSelect(config.id)">
<div class="profile-card__name text-h4 d-flex align-center">
<v-icon size="24" class="mr-2">mdi-file-outline</v-icon>
{{ config.name }}
</div>
<div class="mt-3 d-flex" style="align-items: start; justify-content: center;">
<v-icon size="24" class="mr-1">mdi-routes</v-icon>
<div class="profile-card__bindings">
<template v-if="bindingsForConfig(config.id).length > 0">
<v-tooltip v-for="binding in visibleBindings(bindingsForConfig(config.id))"
:key="`${config.id}-${binding.platformId}`" location="top">
<template #activator="{ props: tooltipProps }">
<button v-bind="tooltipProps" type="button" class="binding-pill"
@click.stop="onManageRoutes(config.id)">
<v-avatar size="22" class="binding-avatar" rounded="sm">
<img v-if="getBindingIcon(binding)" :src="getBindingIcon(binding)" :alt="binding.platformId"
class="binding-avatar__img" />
<v-icon v-else size="14">mdi-robot-outline</v-icon>
</v-avatar>
<span class="binding-pill__label">
{{ binding.platformId }}
</span>
</button>
</template>
<div class="binding-tooltip-content">
<div class="text-caption font-weight-bold mb-1">
{{ tm('profileSidebar.platformId') }}: {{ binding.platformId }}
</div>
<div class="text-caption mb-1">
{{ tm('profileSidebar.umop') }}:
</div>
<div v-for="umop in binding.umops" :key="`${binding.platformId}-${umop}`" class="text-caption">
{{ umop }}
</div>
</div>
</v-tooltip>
<v-chip v-if="bindingsForConfig(config.id).length > maxVisibleBindings" size="x-small" variant="tonal"
color="primary">
+{{ bindingsForConfig(config.id).length - maxVisibleBindings }}
</v-chip>
</template>
<span v-else class="text-caption text-medium-emphasis">
{{ tm('profileSidebar.noBindings') }}
</span>
</div>
</div>
</v-card>
</div>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import { getPlatformIcon } from '@/utils/platformUtils';
interface ConfigInfo {
id: string;
name: string;
}
interface ConfigBinding {
platformId: string;
platformType?: string;
umops: string[];
}
const props = withDefaults(defineProps<{
configs: ConfigInfo[];
selectedConfigId: string | null;
bindingsByConfigId: Record<string, ConfigBinding[]>;
disabled?: boolean;
}>(), {
selectedConfigId: null,
bindingsByConfigId: () => ({}),
disabled: false
});
const emit = defineEmits<{
select: [configId: string];
manage: [];
manageRoutes: [payload: { configId: string }];
}>();
const { tm } = useModuleI18n('features/config');
const maxVisibleBindings = 6;
function onSelect(configId: string): void {
if (props.disabled) {
return;
}
emit('select', configId);
}
function onManageRoutes(configId: string): void {
if (props.disabled) {
return;
}
emit('manageRoutes', { configId });
}
function bindingsForConfig(configId: string): ConfigBinding[] {
return props.bindingsByConfigId[configId] || [];
}
function visibleBindings(bindings: ConfigBinding[]): ConfigBinding[] {
return bindings.slice(0, maxVisibleBindings);
}
function getBindingIcon(binding: ConfigBinding): string | undefined {
if (binding.platformType) {
return getPlatformIcon(binding.platformType);
}
return getPlatformIcon(binding.platformId);
}
</script>
<style scoped>
.config-profile-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: calc(100vh - 210px);
overflow-y: auto;
padding-right: 4px;
}
.profile-card {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
border-radius: 12px;
cursor: pointer;
padding: 12px;
transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
}
.profile-card--active {
background: rgba(var(--v-theme-primary), 0.08);
}
.profile-card--disabled {
cursor: not-allowed;
opacity: 0.7;
}
.profile-card__name {
line-height: 1.3;
}
.profile-card__bindings {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
}
.binding-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px 2px 4px;
border-radius: 999px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
background: rgba(var(--v-theme-surface), 1);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.binding-pill:hover {
border-color: rgba(var(--v-theme-primary), 0.45);
background: rgba(var(--v-theme-primary), 0.06);
}
.binding-pill__label {
font-size: 0.78rem;
line-height: 1.1;
white-space: nowrap;
color: rgba(var(--v-theme-on-surface), 0.8);
}
.binding-avatar__img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.binding-tooltip-content {
max-width: 380px;
word-break: break-all;
}
</style>
@@ -0,0 +1,236 @@
<template>
<v-dialog v-model="dialogVisible" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<div>
<div class="text-h3 pa-2">{{ props.configName }} {{ tm('routeManager.title') }}</div>
</div>
<v-btn icon="mdi-close" variant="text" @click="dialogVisible = false"></v-btn>
</v-card-title>
<v-card-text>
<div v-if="loading" class="d-flex justify-center py-4">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else>
<div class="text-caption text-medium-emphasis mb-4">
{{ tm('routeManager.hint') }}
</div>
<div v-if="groupedRoutes.length === 0" class="text-center py-4 text-medium-emphasis">
{{ tm('routeManager.empty') }}
</div>
<div v-for="(group, groupIndex) in groupedRoutes" :key="group.platformId">
<v-divider v-if="groupIndex > 0" class="my-3" />
<div class="route-group">
<div class="route-group-platform">
<v-avatar size="22" rounded="sm" class="route-platform-avatar">
<img
v-if="getRoutePlatformIcon(group.platformId)"
:src="getRoutePlatformIcon(group.platformId)"
:alt="group.platformId"
class="route-platform-avatar__img"
/>
<v-icon v-else size="14">mdi-robot-outline</v-icon>
</v-avatar>
<span class="text-body-2 font-weight-medium">{{ group.platformId }}</span>
<v-chip size="x-small" variant="tonal" color="primary">
{{ group.routes.length }}
</v-chip>
</div>
<div class="route-group-umops">
<div
v-for="route in group.routes"
:key="route.id"
class="route-umop-row"
:class="{ 'route-umop-row--all': isAllSessionsRoute(route.umop) }"
>
<span class="text-body-2 route-umop-row__text">
{{ isAllSessionsRoute(route.umop) ? tm('routeManager.allSessions') : route.umop }}
</span>
<div class="route-umop-row__actions">
<v-tooltip :text="tm('routeManager.delete')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
icon="mdi-delete-outline"
variant="text"
color="error"
size="small"
@click="emit('removeRoute', route.id)"
/>
</template>
</v-tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="dialogVisible = false">
{{ tm('buttons.cancel') }}
</v-btn>
<v-btn color="primary" :loading="saving" @click="emit('save')">
{{ tm('actions.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { getPlatformIcon } from '@/utils/platformUtils';
interface RouteItem {
id: string;
platformId: string;
umop: string;
}
const props = withDefaults(defineProps<{
modelValue: boolean;
configId: string;
configName: string;
loading: boolean;
saving: boolean;
items: RouteItem[];
platformTypeMap: Record<string, string>;
}>(), {
modelValue: false,
configId: '',
configName: '',
loading: false,
saving: false,
items: () => [],
platformTypeMap: () => ({})
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
removeRoute: [routeId: string];
save: [];
}>();
const { tm } = useModuleI18n('features/config');
const dialogVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
});
const groupedRoutes = computed(() => {
const groups: Record<string, RouteItem[]> = {};
for (const item of props.items) {
const platformId = String(item.platformId || '').trim();
if (!platformId) {
continue;
}
if (!groups[platformId]) {
groups[platformId] = [];
}
groups[platformId].push(item);
}
return Object.entries(groups)
.map(([platformId, routes]) => ({
platformId,
routes: (() => {
const sortedRoutes = routes.sort((a, b) => a.umop.localeCompare(b.umop));
const allSessionsRoute = sortedRoutes.find((route) => isAllSessionsRoute(route.umop));
if (allSessionsRoute) {
return [allSessionsRoute];
}
return sortedRoutes;
})()
}))
.sort((a, b) => a.platformId.localeCompare(b.platformId));
});
function getRoutePlatformIcon(platformId: string): string | undefined {
const platformType = props.platformTypeMap[platformId] || platformId;
return getPlatformIcon(platformType);
}
function isAllSessionsRoute(umop: string): boolean {
return String(umop || '').endsWith(':*:*');
}
</script>
<style scoped>
.route-group-platform {
display: flex;
align-items: center;
gap: 8px;
min-height: 24px;
}
.route-group-umops {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.route-umop-row {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 6px;
padding: 2px 4px 2px 10px;
gap: 10px;
background: rgba(var(--v-theme-on-surface), 0.03);
}
.route-umop-row--all {
background: rgba(var(--v-theme-primary), 0.08);
}
.route-umop-row__text {
min-width: 0;
word-break: break-all;
}
.route-umop-row__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.route-platform-avatar {
background: rgba(var(--v-theme-surface), 1);
}
.route-platform-avatar__img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 2px;
}
.route-group {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
@media (max-width: 767px) {
.route-group {
grid-template-columns: minmax(0, 1fr);
}
.route-group-platform {
margin-bottom: 2px;
}
.route-umop-row {
align-items: flex-start;
}
}
</style>
@@ -372,6 +372,7 @@ function closeProviderDrawer() {
white-space: nowrap;
max-width: calc(100% - 80px);
display: inline-block;
font-size: 13px;
}
.selected-preview {
@@ -69,6 +69,26 @@
"normalConfig": "Basic",
"systemConfig": "System"
},
"profileSidebar": {
"title": "Configuration Profiles",
"platformId": "Platform ID",
"umop": "Bound UMOP",
"noBindings": "No platform bindings"
},
"routeManager": {
"title": "Route Manager",
"targetConfig": "Config: {config}",
"hint": "AstrBot supports multiple config files, and routing decides which session uses which config. This dialog shows all routes handled by the current config: platform on the left and UMOP on the right; click Save after deleting routes.",
"empty": "No routes available to manage.",
"platform": "Platform",
"umop": "UMOP",
"allSessions": "All Sessions",
"delete": "Delete Route",
"loadFailed": "Failed to load routes",
"saveSuccess": "Routes saved",
"saveFailed": "Failed to save routes",
"routeOccupied": "This route is already occupied by another config: {umop}"
},
"search": {
"placeholder": "Search config items (key/description/hint)",
"noResult": "No matching config items found"
@@ -69,6 +69,26 @@
"normalConfig": "普通",
"systemConfig": "系统"
},
"profileSidebar": {
"title": "配置文件列表",
"platformId": "平台 ID",
"umop": "绑定 UMOP",
"noBindings": "暂无平台绑定"
},
"routeManager": {
"title": "路由管理",
"targetConfig": "配置:{config}",
"hint": "AstrBot 支持多配置文件,路由用于决定“哪个会话用哪个配置”。这里展示的是当前配置文件接管的全部路由:左侧是机器人 ID、右侧是匹配的消息会话来源。",
"empty": "暂无可管理的路由。",
"platform": "平台",
"umop": "UMOP",
"allSessions": "全部会话",
"delete": "删除路由",
"loadFailed": "加载路由失败",
"saveSuccess": "路由已保存",
"saveFailed": "保存路由失败",
"routeOccupied": "该路由已被其他配置占用:{umop}"
},
"search": {
"placeholder": "搜索配置项(字段名/描述/提示)",
"noResult": "未找到匹配的配置项"
+428 -84
View File
@@ -1,16 +1,58 @@
<template>
<div style="display: flex; flex-direction: column; align-items: center;">
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel"
style="display: flex; flex-direction: column; align-items: start;">
<div class="config-page-wrap">
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel">
<div class="config-workbench" :class="{ 'config-workbench--system': isSystemConfig || !!initialConfigId }">
<aside v-if="!isSystemConfig && !initialConfigId" class="config-sidebar">
<ConfigProfileSidebar
:configs="configInfoList"
:selected-config-id="selectedConfigID"
:bindings-by-config-id="configBindingsById"
:disabled="initialConfigId !== null"
@select="onConfigSelect"
@manage="openConfigManageDialog"
@manage-routes="openRouteManageDialog"
/>
</aside>
<div class="config-toolbar d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" :model-value="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
<section class="config-main">
<div class="config-toolbar d-flex flex-row">
<div class="config-toolbar-controls d-flex flex-row align-center">
<div v-if="!isSystemConfig" class="config-current-title">
<h2 class="config-current-title__name">
{{ selectedConfigInfo.name || selectedConfigID }}
</h2>
<div class="config-current-title__id text-caption text-medium-emphasis">
ID: {{ selectedConfigID }}
</div>
</div>
<v-select
v-if="!isSystemConfig && !initialConfigId"
class="config-select config-select--mobile"
:model-value="selectedConfigID"
:items="configSelectItems"
item-title="name"
:disabled="initialConfigId !== null"
item-value="id"
:label="tm('configSelection.selectConfig')"
hide-details
density="compact"
rounded="md"
variant="outlined"
@update:model-value="onConfigSelect"
/>
<v-tooltip v-if="!isSystemConfig && !initialConfigId" :text="tm('configManagement.manageConfigs')" location="top">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
class="config-manage-mobile"
variant="text"
icon="mdi-cog"
:disabled="initialConfigId !== null"
@click="openConfigManageDialog"
/>
</template>
</v-tooltip>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
@@ -20,14 +62,12 @@
density="compact"
rounded="md"
variant="outlined"
style="min-width: 280px;"
/>
<!-- <a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a> -->
</div>
</div>
</div>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-fade-transition>
<div v-if="fetched && hasUnsavedChanges && !isLoadingConfig" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
@@ -36,12 +76,10 @@
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
</v-fade-transition>
<v-slide-y-transition mode="out-in">
<div v-if="(selectedConfigID || isSystemConfig) && fetched" :key="configContentKey" class="config-content" style="width: 100%;">
<!-- 可视化编辑 -->
<v-fade-transition mode="out-in">
<div v-if="(selectedConfigID || isSystemConfig) && fetched" :key="configContentKey" class="config-content">
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
@@ -72,10 +110,10 @@
</v-btn>
</template>
</v-tooltip>
</div>
</v-slide-y-transition>
</v-fade-transition>
</section>
</div>
</div>
</div>
@@ -158,6 +196,18 @@
</v-card>
</v-dialog>
<ConfigRouteManagerDialog
v-model="routeManageDialog"
:config-id="routeManageConfigId"
:config-name="routeManageConfigName"
:loading="routeManageLoading"
:saving="routeManageSaving"
:items="routeManageItems"
:platform-type-map="routeManagePlatformTypeMap"
@remove-route="removeRouteItem"
@save="saveRouteManageDialog"
/>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
</v-snackbar>
@@ -201,6 +251,8 @@
<script>
import axios from 'axios';
import AstrBotCoreConfigWrapper from '@/components/config/AstrBotCoreConfigWrapper.vue';
import ConfigProfileSidebar from '@/components/config/ConfigProfileSidebar.vue';
import ConfigRouteManagerDialog from '@/components/config/ConfigRouteManagerDialog.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import StandaloneChat from '@/components/chat/StandaloneChat.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
@@ -216,6 +268,8 @@ export default {
name: 'ConfigPage',
components: {
AstrBotCoreConfigWrapper,
ConfigProfileSidebar,
ConfigRouteManagerDialog,
VueMonacoEditor,
WaitingForRestart,
StandaloneChat,
@@ -295,19 +349,7 @@ export default {
return this.configInfoList.find(info => info.id === this.selectedConfigID) || {};
},
configSelectItems() {
const items = [...this.configInfoList];
items.push({
id: '_%manage%_',
name: this.tm('configManagement.manageConfigs'),
umop: []
});
return items;
},
hasUnsavedChanges() {
if (!this.fetched) {
return false;
}
return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
return [...this.configInfoList];
}
},
watch: {
@@ -317,7 +359,7 @@ export default {
config_data: {
deep: true,
handler() {
if (this.fetched) {
if (this.fetched && !this.isLoadingConfig) {
this.hasUnsavedChanges = this.configHasChanges;
}
}
@@ -338,6 +380,13 @@ export default {
return {
codeEditorDialog: false,
configManageDialog: false,
routeManageDialog: false,
routeManageLoading: false,
routeManageSaving: false,
routeManageConfigId: '',
routeManageConfigName: '',
routeManageItems: [],
routeManagePlatformTypeMap: {},
showConfigForm: false,
isEditingConfig: false,
config_data_has_changed: false,
@@ -345,13 +394,13 @@ export default {
config_data: {
config: {}
},
isLoadingConfig: false,
fetched: false,
metadata: {},
save_message_snack: false,
save_message: "",
save_message_success: "",
configContentKey: 0,
lastSavedConfigSnapshot: '',
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
@@ -364,6 +413,7 @@ export default {
selectedConfigID: null, // 用于存储当前选中的配置项信息
currentConfigId: null, // 跟踪当前正在编辑的配置id
configInfoList: [],
configBindingsById: {},
configFormData: {
name: '',
},
@@ -409,16 +459,12 @@ export default {
methods: {
// 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
handleLocaleChange() {
// 重新加载当前配置
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
} else if (this.isSystemConfig) {
this.getConfig();
}
},
},
methods: {
extractConfigTypeFromHash(hash) {
const rawHash = String(hash || '');
const lastHashIndex = rawHash.lastIndexOf('#');
@@ -438,10 +484,232 @@ export default {
await this.onConfigTypeToggle();
return true;
},
openConfigManageDialog() {
this.configManageDialog = true;
},
parseUmop(umop) {
const raw = String(umop || '');
const parts = raw.split(':');
if (parts.length < 3) {
return {
platformId: raw || '*',
messageType: '*',
sessionId: '*'
};
}
return {
platformId: parts[0] || '*',
messageType: parts[1] || '*',
sessionId: parts.slice(2).join(':') || '*'
};
},
createRouteItem(umop) {
const parsed = this.parseUmop(umop);
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
platformId: parsed.platformId,
umop
};
},
isRouteEntryForConfig(umop, confId, targetConfigId) {
if (String(confId || '') !== String(targetConfigId || '')) {
return false;
}
const parsed = this.parseUmop(umop);
return parsed.platformId !== 'webchat';
},
async openRouteManageDialog(payload) {
const configId = payload?.configId;
if (!configId) {
return;
}
this.routeManageDialog = true;
this.routeManageLoading = true;
this.routeManageConfigId = configId;
this.routeManageConfigName = this.configInfoList.find((item) => item.id === configId)?.name || configId;
this.routeManageItems = [];
this.routeManagePlatformTypeMap = {};
try {
const [routeRes, platformRes] = await Promise.all([
axios.get('/api/config/umo_abconf_routes'),
axios.get('/api/config/platform/list')
]);
const routing = routeRes?.data?.data?.routing || {};
const platforms = platformRes?.data?.data?.platforms || [];
const typeMap = {};
for (const platform of platforms) {
const pid = String(platform?.id || '').trim();
if (!pid) {
continue;
}
typeMap[pid] = platform.platform_type || platform.type || pid;
}
this.routeManagePlatformTypeMap = typeMap;
const matched = [];
for (const [umop, conf] of Object.entries(routing)) {
if (!this.isRouteEntryForConfig(umop, conf, configId)) {
continue;
}
matched.push(this.createRouteItem(umop));
}
this.routeManageItems = matched.sort((a, b) => {
const platformCompare = a.platformId.localeCompare(b.platformId);
if (platformCompare !== 0) {
return platformCompare;
}
return a.umop.localeCompare(b.umop);
});
} catch (err) {
console.error('Failed to load routes for route manager:', err);
this.save_message = this.tm('routeManager.loadFailed');
this.save_message_snack = true;
this.save_message_success = "error";
this.routeManageItems = [];
} finally {
this.routeManageLoading = false;
}
},
removeRouteItem(entryId) {
this.routeManageItems = this.routeManageItems.filter((item) => item.id !== entryId);
},
async saveRouteManageDialog() {
if (!this.routeManageConfigId) {
return;
}
this.routeManageSaving = true;
try {
const res = await axios.get('/api/config/umo_abconf_routes');
const routing = res?.data?.data?.routing || {};
const entries = Object.entries(routing);
const nonTargetEntries = [];
const nonTargetUmopSet = new Set();
let firstTargetIndex = -1;
entries.forEach(([umop, confId], index) => {
if (this.isRouteEntryForConfig(umop, confId, this.routeManageConfigId)) {
if (firstTargetIndex === -1) {
firstTargetIndex = index;
}
return;
}
nonTargetEntries.push([umop, confId]);
nonTargetUmopSet.add(umop);
});
const targetEntries = [];
for (const item of this.routeManageItems) {
const umop = String(item.umop || '').trim();
if (!umop) {
continue;
}
if (nonTargetUmopSet.has(umop)) {
this.save_message = this.tm('routeManager.routeOccupied', { umop });
this.save_message_snack = true;
this.save_message_success = "error";
this.routeManageSaving = false;
return;
}
targetEntries.push([umop, this.routeManageConfigId]);
}
const insertIndex = firstTargetIndex === -1 ? nonTargetEntries.length : Math.min(firstTargetIndex, nonTargetEntries.length);
const mergedEntries = [
...nonTargetEntries.slice(0, insertIndex),
...targetEntries,
...nonTargetEntries.slice(insertIndex)
];
const mergedRouting = Object.fromEntries(mergedEntries);
await axios.post('/api/config/umo_abconf_route/update_all', {
routing: mergedRouting
});
this.routeManageDialog = false;
this.save_message = this.tm('routeManager.saveSuccess');
this.save_message_snack = true;
this.save_message_success = "success";
await this.refreshConfigBindings();
} catch (err) {
console.error('Failed to save routes for route manager:', err);
this.save_message = this.tm('routeManager.saveFailed');
this.save_message_snack = true;
this.save_message_success = "error";
} finally {
this.routeManageSaving = false;
}
},
buildConfigBindingMap(routingTable, platforms) {
const platformTypeMap = {};
for (const platform of platforms || []) {
if (!platform?.id) {
continue;
}
platformTypeMap[platform.id] = platform.platform_type || platform.type || platform.id;
}
const grouped = {};
for (const [umop, confId] of Object.entries(routingTable || {})) {
const resolvedConfigId = String(confId || 'default');
const parsed = this.parseUmop(umop);
const platformId = parsed.platformId || '*';
if (platformId === 'webchat') {
continue;
}
if (!grouped[resolvedConfigId]) {
grouped[resolvedConfigId] = {};
}
if (!grouped[resolvedConfigId][platformId]) {
grouped[resolvedConfigId][platformId] = {
platformId,
platformType: platformTypeMap[platformId] || platformId,
umops: []
};
}
grouped[resolvedConfigId][platformId].umops.push(umop);
}
const bindingMap = {};
for (const [confId, platformsById] of Object.entries(grouped)) {
bindingMap[confId] = Object.values(platformsById).sort((a, b) => {
return a.platformId.localeCompare(b.platformId);
});
}
return bindingMap;
},
async refreshConfigBindings() {
try {
const [routesRes, platformsRes] = await Promise.all([
axios.get('/api/config/umo_abconf_routes'),
axios.get('/api/config/platform/list')
]);
const routing = routesRes?.data?.data?.routing || {};
const platforms = platformsRes?.data?.data?.platforms || [];
this.configBindingsById = this.buildConfigBindingMap(routing, platforms);
} catch (err) {
console.error('Failed to load config bindings:', err);
this.configBindingsById = {};
}
},
getConfigInfoList(abconf_id) {
// 获取配置列表
axios.get('/api/config/abconfs').then((res) => {
this.configInfoList = res.data.data.info_list;
const infoList = Array.isArray(res.data?.data?.info_list) ? res.data.data.info_list : [];
this.configInfoList = [...infoList].sort((a, b) => {
if (a.id === 'default' && b.id !== 'default') {
return -1;
}
if (a.id !== 'default' && b.id === 'default') {
return 1;
}
return 0;
});
this.refreshConfigBindings();
if (abconf_id) {
let matched = false;
@@ -466,9 +734,12 @@ export default {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
this.save_message_success = "error";
this.configBindingsById = {};
});
},
getConfig(abconf_id) {
this.isLoadingConfig = true;
this.hasUnsavedChanges = false;
this.fetched = false
const params = {};
@@ -482,22 +753,20 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
// 获取配置后更新
this.$nextTick(() => {
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
this.hasUnsavedChanges = false;
this.configContentKey += 1;
if (!this.isSystemConfig) {
this.currentConfigId = abconf_id || this.selectedConfigID;
}
});
this.fetched = true;
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
this.save_message_success = "error";
}).finally(() => {
this.isLoadingConfig = false;
});
},
updateConfig() {
@@ -515,7 +784,6 @@ export default {
return axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
@@ -584,17 +852,10 @@ export default {
});
},
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
// 重置选择到之前的值
this.$nextTick(() => {
this.selectedConfigID = this.selectedConfigInfo.id || 'default';
this.getConfig(this.selectedConfigID);
});
} else {
// 检查是否有未保存的更改
if (!value || value === this.selectedConfigID) {
return;
}
if (this.hasUnsavedChanges) {
// 获取之前正在编辑的配置id
const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
const message = this.tm('unsavedChangesWarning.switchConfig');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
@@ -604,14 +865,11 @@ export default {
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
// 关闭弹窗不切换
if (saveAndSwitch === 'close') {
return;
}
if (saveAndSwitch) {
// 设置临时变量保存切换后的id
const currentSelectedId = this.selectedConfigID;
// 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
this.selectedConfigID = prevConfigId;
const result = await this.updateConfig();
this.selectedConfigID = currentSelectedId;
@@ -620,17 +878,13 @@ export default {
this.getConfig(value);
}
return;
} else {
// 取消保存并切换配置
}
this.selectedConfigID = value;
this.getConfig(value);
}
} else {
// 无未保存更改直接切换
this.selectedConfigID = value;
this.getConfig(value);
}
}
},
startCreateConfig() {
this.showConfigForm = true;
@@ -758,6 +1012,7 @@ export default {
// 切换到系统配置
this.getConfig();
} else {
this.refreshConfigBindings();
// 切换回普通配置,如果有选中的配置文件则加载,否则加载default
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
@@ -785,9 +1040,6 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
},
getConfigSnapshot(config) {
return JSON.stringify(config ?? {});
}
},
}
@@ -799,6 +1051,80 @@ export default {
text-transform: none !important;
}
.config-page-wrap {
display: flex;
justify-content: center;
}
.config-panel {
width: min(1160px, calc(100vw - 48px));
}
.config-workbench {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.config-workbench--system {
grid-template-columns: minmax(0, 1fr);
}
.config-sidebar {
position: sticky;
top: calc(var(--v-layout-top, 64px) + 16px);
}
.config-main {
min-width: 0;
}
.config-current-title {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 0;
}
.config-current-title__name {
font-family: inherit;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.config-current-title__id {
line-height: 1.2;
}
.config-toolbar {
margin-bottom: 16px;
align-items: center;
width: 100%;
}
.config-toolbar-controls {
width: 100%;
gap: 12px;
}
.config-search-input {
min-width: 180px;
max-width: 300px;
width: 100%;
margin-left: auto;
}
.config-select--mobile,
.config-manage-mobile {
display: none;
}
.unsaved-changes-banner {
border-radius: 8px;
}
@@ -852,35 +1178,53 @@ export default {
font-style: italic;
}
@media (min-width: 768px) {
.config-panel {
width: 750px;
@media (max-width: 959px) {
.config-workbench {
grid-template-columns: minmax(0, 1fr);
}
.config-sidebar {
display: none;
}
.config-select--mobile,
.config-manage-mobile {
display: inline-flex;
}
.config-select--mobile {
min-width: 180px;
max-width: 280px;
}
}
@media (max-width: 767px) {
.v-container {
padding: 4px;
}
.config-panel {
width: 100%;
margin-top: 0 !important;
}
.config-toolbar {
padding-right: 0 !important;
.config-page-wrap {
padding: 0 8px;
}
.config-toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
.config-select,
.config-select--mobile,
.config-search-input {
width: 100%;
min-width: 0 !important;
max-width: 100%;
min-width: 0;
}
.config-manage-mobile {
width: auto;
max-width: none;
min-width: auto;
}
}
/* 测试聊天抽屉样式 */