Files
AstrBot/dashboard/src/views/SessionManagementPage.vue
Weilong Liao 0d8e8682db refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)
* refactor: migrate to fastapi

* structure refactor

* fix: pyright fix

* refactor: improve error handling and public messages in plugin services

* feat(api): refactor API client integration and enhance request handling

- Updated API client configuration to use a dedicated HTTP client.
- Introduced utility functions for generating options, queries, and form data for API requests.
- Refactored multiple API methods to utilize the new utility functions for improved consistency and readability.
- Renamed types for clarity and updated import statements accordingly.

feat(docs): add script to update OpenAPI JSON from YAML spec

- Created a Python script to convert OpenAPI YAML specification to JSON format.
- The script supports customizable input and output paths.
- Ensured the script handles directory creation for output paths and validates the YAML structure.

* fix

* feat(auth): implement rate limiting for v1 login endpoint and enhance request handling

* Refactor dashboard API routers to use legacy_router for backward compatibility

- Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime.
- Updated route definitions to ensure existing endpoints remain functional under the new router structure.
- Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins.
- Added a test case to validate the functionality of the new Quart request context handling in plugin extensions.

* chore: remove cli test

* fix: update dashboard tests for fastapi migration

* chore: satisfy ruff checks

* fix: update openapi api key scopes

* fix: sync config scope chip selection

* fix: restore quart dependency

* docs: clarify quart plugin api compatibility

* docs: update openapi scope documentation

* fix: use singular skill openapi scope

* fix: hide update service exception details

* fix: address fastapi review comments

* fix: address dashboard review findings

* docs: revert unrelated package deployment changes

* docs: update agent api generation guidance

* feat: add plugin page web api helpers

* docs: add plugin page bridge demo

* fix: type plugin upload files

* fix: stabilize plugin page uploads

* fix: type plugin web request proxy

* docs: remove plugin page docs example

* fix: authenticate plugin page SSE bridge
2026-06-14 15:03:26 +08:00

1899 lines
64 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="session-management-page">
<v-container fluid class="pa-0">
<v-card flat>
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h4">{{ tm('customRules.title') }}</span>
<v-btn icon="mdi-information-outline" size="small" variant="text" href="https://docs.astrbot.app/use/custom-rules.html" target="_blank"></v-btn>
<v-chip size="small" class="ml-1">{{ totalItems }} {{ tm('customRules.rulesCount') }}</v-chip>
<v-row class="me-4 ms-4" dense>
<v-text-field
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
clearable
variant="solo-filled"
flat
class="me-4"
density="compact"
></v-text-field>
</v-row>
<v-btn v-if="selectedItems.length > 0" color="error" prepend-icon="mdi-delete" variant="tonal" @click="confirmBatchDelete" class="mr-2" size="small">
{{ tm('buttons.batchDelete') }} ({{ selectedItems.length }})
</v-btn>
<v-btn color="success" prepend-icon="mdi-plus" variant="tonal" @click="openAddRuleDialog" class="mr-2" size="small">
{{ tm('buttons.addRule') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="refreshData" :loading="loading" size="small">
{{ tm('buttons.refresh') }}
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="pa-0">
<v-data-table-server
:headers="headers"
:items="filteredRulesList"
:loading="loading"
:items-length="totalItems"
v-model:items-per-page="itemsPerPage"
v-model:page="currentPage"
@update:options="onTableOptionsUpdate"
class="elevation-0"
style="font-size: 12px"
v-model="selectedItems"
show-select
item-value="umo"
return-object
>
<!-- UMO 信息 -->
<template v-slot:item.umo_info="{ item }">
<UmoDisplay
:umo="item.umo"
:platform="item.platform"
:message-type="item.message_type"
:session-id="item.session_id"
:auto-name="item.auto_name"
:user-alias="item.user_alias"
:custom-name="item.rules?.session_service_config?.custom_name"
editable
:edit-tooltip="tm('buttons.editCustomName')"
@edit="openQuickEditName(item)"
/>
</template>
<!-- 规则概览 -->
<template v-slot:item.rules_overview="{ item }">
<div class="d-flex flex-wrap ga-1">
<v-chip v-if="item.rules.session_service_config" size="x-small" color="primary" variant="outlined">
{{ tm('customRules.serviceConfig') }}
</v-chip>
<v-chip v-if="item.rules.session_plugin_config" size="x-small" color="secondary" variant="outlined">
{{ tm('customRules.pluginConfig') }}
</v-chip>
<v-chip v-if="item.rules.kb_config" size="x-small" color="info" variant="outlined">
{{ tm('customRules.kbConfig') }}
</v-chip>
<v-chip v-if="hasProviderConfig(item.rules)" size="x-small" color="warning" variant="outlined">
{{ tm('customRules.providerConfig') }}
</v-chip>
</div>
</template>
<!-- 操作按钮 -->
<template v-slot:item.actions="{ item }">
<v-btn size="small" variant="tonal" color="primary" @click="openRuleEditor(item)" class="mr-1">
<v-icon>mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('buttons.editRule') }}</v-tooltip>
</v-btn>
<v-btn size="small" variant="tonal" color="error" @click="confirmDeleteRules(item)">
<v-icon>mdi-delete</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('buttons.deleteAllRules') }}</v-tooltip>
</v-btn>
</template>
<!-- 空状态 -->
<template v-slot:no-data>
<div class="text-center py-8">
<v-icon size="64" color="grey-400">mdi-file-document-edit-outline</v-icon>
<div class="text-h6 mt-4 text-grey-600">
{{ tm('customRules.noRules') }}
</div>
<div class="text-body-2 text-grey-500">
{{ tm('customRules.noRulesDesc') }}
</div>
<v-btn color="primary" variant="tonal" class="mt-4" @click="openAddRuleDialog">
<v-icon start>mdi-plus</v-icon>
{{ tm('buttons.addRule') }}
</v-btn>
</div>
</template>
</v-data-table-server>
</v-card-text>
</v-card>
<!-- 批量操作面板 -->
<v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">{{ tm('batchOperations.title') }}</span>
<v-chip size="small" class="ml-2" color="info" variant="outlined">
{{ tm('batchOperations.hint') }}
</v-chip>
</v-card-title>
<v-card-text>
<v-row dense>
<v-col cols="12" md="6" lg="3">
<v-select
v-model="batchScope"
:items="batchScopeOptions"
item-title="label"
item-value="value"
:label="tm('batchOperations.scope')"
hide-details
variant="solo-filled"
flat
density="comfortable"
>
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select
v-model="batchLlmStatus"
:items="statusOptions"
item-title="label"
item-value="value"
:label="tm('batchOperations.llmStatus')"
hide-details
clearable
variant="solo-filled"
flat
density="comfortable"
>
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select
v-model="batchTtsStatus"
:items="statusOptions"
item-title="label"
item-value="value"
:label="tm('batchOperations.ttsStatus')"
hide-details
clearable
variant="solo-filled"
flat
density="comfortable"
>
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select
v-model="batchChatProvider"
:items="batchChatProviderOptions"
item-title="label"
item-value="value"
:label="tm('batchOperations.chatProvider')"
hide-details
clearable
variant="solo-filled"
flat
density="comfortable"
>
</v-select>
</v-col>
</v-row>
<v-row dense class="mt-3">
<v-col cols="12" class="d-flex justify-end">
<v-btn color="primary" variant="tonal" size="large" @click="applyBatchChanges" :disabled="!canApplyBatch" :loading="batchUpdating" prepend-icon="mdi-check-all">
{{ tm('batchOperations.apply') }}
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 分组管理面板 -->
<v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">{{ tm('groups.title') }}</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ tm('groups.count', { count: groups.length }) }}
</v-chip>
<v-spacer></v-spacer>
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
<v-icon start>mdi-folder-plus</v-icon>
{{ tm('groups.addToGroup') }}
<v-menu activator="parent">
<v-list density="compact">
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
<v-list-item-title>{{
tm('groups.customGroupOption', {
name: g.name,
count: g.umo_count,
})
}}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
{{ tm('groups.create') }}
</v-btn>
</v-card-title>
<v-card-text v-if="groups.length > 0">
<v-row dense>
<v-col v-for="group in groups" :key="group.id" cols="12" sm="6" md="4" lg="3">
<v-card variant="outlined" class="pa-3">
<div class="d-flex align-center justify-space-between">
<div>
<div class="font-weight-bold">{{ group.name }}</div>
<div class="text-caption text-grey">
{{ tm('groups.sessionsCount', { count: group.umo_count }) }}
</div>
</div>
<div>
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
<v-icon size="small">mdi-pencil</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" color="error" @click="deleteGroup(group)">
<v-icon size="small">mdi-delete</v-icon>
</v-btn>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else class="text-center text-grey py-6">
{{ tm('groups.empty') }}
</v-card-text>
</v-card>
<!-- 分组编辑对话框 -->
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
<v-card>
<v-card-title class="py-3 px-4">
{{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }}
</v-card-title>
<v-card-text>
<v-text-field v-model="editingGroup.name" :label="tm('groups.name')" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-row dense>
<!-- 左侧可选会话 -->
<v-col cols="5">
<div class="text-subtitle-2 mb-2">
{{
tm('groups.availableSessions', {
count: unselectedUmos.length,
})
}}
</div>
<v-text-field
v-model="groupMemberSearch"
:placeholder="tm('groups.searchPlaceholder')"
variant="outlined"
density="compact"
hide-details
class="mb-2"
clearable
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-list density="compact" class="transfer-list">
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
<template v-slot:prepend>
<v-icon size="small" color="grey">mdi-plus</v-icon>
</template>
<v-list-item-title>
<UmoDisplay v-bind="getAvailableUmoDisplayProps(umo)" compact :show-info="false" :show-platform="false" />
</v-list-item-title>
<template v-slot:append>
<v-chip v-if="getAvailableUmoInfo(umo).platform" size="x-small" :color="getPlatformColor(getAvailableUmoInfo(umo).platform)" class="umo-list-platform">
{{ getAvailableUmoInfo(umo).platform }}
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="loadingUmos">
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
</v-list-item>
</v-list>
</v-col>
<!-- 中间操作按钮 -->
<v-col cols="2" class="d-flex flex-column align-center justify-center">
<v-btn icon size="small" variant="tonal" color="primary" class="mb-2" @click="addAllToGroup" :disabled="unselectedUmos.length === 0">
<v-icon>mdi-chevron-double-right</v-icon>
</v-btn>
<v-btn icon size="small" variant="tonal" color="error" @click="removeAllFromGroup" :disabled="editingGroup.umos.length === 0">
<v-icon>mdi-chevron-double-left</v-icon>
</v-btn>
</v-col>
<!-- 右侧已选会话 -->
<v-col cols="5">
<div class="text-subtitle-2 mb-2">
{{
tm('groups.selectedSessions', {
count: editingGroup.umos.length,
})
}}
</div>
<v-text-field
v-model="groupSelectedSearch"
:placeholder="tm('groups.searchPlaceholder')"
variant="outlined"
density="compact"
hide-details
class="mb-2"
clearable
prepend-inner-icon="mdi-magnify"
></v-text-field>
<v-list density="compact" class="transfer-list">
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-minus</v-icon>
</template>
<v-list-item-title>
<UmoDisplay v-bind="getAvailableUmoDisplayProps(umo)" compact :show-info="false" :show-platform="false" />
</v-list-item-title>
<template v-slot:append>
<v-chip v-if="getAvailableUmoInfo(umo).platform" size="x-small" :color="getPlatformColor(getAvailableUmoInfo(umo).platform)" class="umo-list-platform">
{{ getAvailableUmoInfo(umo).platform }}
</v-chip>
</template>
</v-list-item>
<v-list-item v-if="editingGroup.umos.length === 0">
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 添加规则对话框 - 选择 UMO -->
<v-dialog v-model="addRuleDialog" max-width="600">
<v-card>
<v-card-title class="py-3 px-4" style="display: flex; align-items: center">
<span>{{ tm('addRule.title') }}</span>
<v-spacer></v-spacer>
<v-btn icon variant="text" @click="addRuleDialog = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4">
<v-alert type="info" variant="tonal" class="mb-4">
{{ tm('addRule.description') }}
</v-alert>
<v-autocomplete v-model="selectedNewUmo" :items="availableUmos" :loading="loadingUmos" :label="tm('addRule.selectUmo')" variant="outlined" clearable :no-data-text="tm('addRule.noUmos')">
<template v-slot:item="{ props, item }">
<v-list-item v-bind="props">
<template v-slot:title>
<UmoDisplay v-bind="getAvailableUmoDisplayProps(item.raw)" compact :show-info="false" :show-platform="false" />
</template>
<template v-slot:append>
<v-chip v-if="getAvailableUmoInfo(item.raw).platform" size="x-small" :color="getPlatformColor(getAvailableUmoInfo(item.raw).platform)" class="umo-list-platform">
{{ getAvailableUmoInfo(item.raw).platform }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-slot:selection="{ item }">
<v-chip v-if="item && getUmoSelectionText(item.raw)" size="small" variant="tonal" color="primary" class="umo-selection-chip">
{{ getUmoSelectionText(item.raw) }}
</v-chip>
</template>
</v-autocomplete>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="addRuleDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="createNewRule" :disabled="!selectedNewUmo">
{{ tm('buttons.next') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 规则编辑对话框 -->
<v-dialog v-model="ruleDialog" max-width="550" scrollable>
<v-card v-if="selectedUmo" class="d-flex flex-column" height="600">
<v-card-title class="py-3 px-6 d-flex align-center border-b">
<span>{{ tm('ruleEditor.title') }}</span>
<v-chip size="x-small" class="ml-2 font-weight-regular" variant="outlined">
{{ selectedUmo.umo }}
</v-chip>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" @click="closeRuleEditor"></v-btn>
</v-card-title>
<v-card-text class="pa-0 overflow-y-auto">
<div class="px-6 py-4">
<!-- Service Config Section -->
<div class="d-flex align-center mb-4">
<h3 class="font-weight-bold mb-0">
{{ tm('ruleEditor.serviceConfig.title') }}
</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-checkbox v-model="serviceConfig.session_enabled" :label="tm('ruleEditor.serviceConfig.sessionEnabled')" color="success" hide-details class="mb-2" />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="serviceConfig.llm_enabled" :label="tm('ruleEditor.serviceConfig.llmEnabled')" color="primary" hide-details />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="serviceConfig.tts_enabled" :label="tm('ruleEditor.serviceConfig.ttsEnabled')" color="secondary" hide-details />
</v-col>
<v-col cols="12" class="mt-2">
<v-text-field v-model="serviceConfig.custom_name" :label="tm('ruleEditor.serviceConfig.customName')" variant="outlined" hide-details clearable />
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="saveServiceConfig" :loading="saving" prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- Provider Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">
{{ tm('ruleEditor.providerConfig.title') }}
</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select
v-model="providerConfig.chat_completion"
:items="chatProviderOptions"
item-title="label"
item-value="value"
:label="tm('ruleEditor.providerConfig.chatProvider')"
variant="outlined"
hide-details
class="mb-2"
/>
</v-col>
<v-col cols="12">
<v-select
v-model="providerConfig.speech_to_text"
:items="sttProviderOptions"
item-title="label"
item-value="value"
:label="tm('ruleEditor.providerConfig.sttProvider')"
variant="outlined"
hide-details
:disabled="availableSttProviders.length === 0"
class="mb-2"
/>
</v-col>
<v-col cols="12">
<v-select
v-model="providerConfig.text_to_speech"
:items="ttsProviderOptions"
item-title="label"
item-value="value"
:label="tm('ruleEditor.providerConfig.ttsProvider')"
variant="outlined"
hide-details
:disabled="availableTtsProviders.length === 0"
/>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="saveProviderConfig" :loading="saving" prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- Persona Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">
{{ tm('ruleEditor.personaConfig.title') }}
</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select
v-model="serviceConfig.persona_id"
:items="personaOptions"
item-title="label"
item-value="value"
:label="tm('ruleEditor.personaConfig.selectPersona')"
variant="outlined"
hide-details
clearable
/>
</v-col>
<v-col cols="12">
<v-alert type="info" variant="tonal" class="mt-2" icon="mdi-information-outline">
{{ tm('ruleEditor.personaConfig.hint') }}
</v-alert>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="saveServiceConfig" :loading="saving" prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- Plugin Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">
{{ tm('ruleEditor.pluginConfig.title') }}
</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select
v-model="pluginConfig.disabled_plugins"
:items="pluginOptions"
item-title="label"
item-value="value"
:label="tm('ruleEditor.pluginConfig.disabledPlugins')"
variant="outlined"
hide-details
multiple
chips
closable-chips
clearable
/>
</v-col>
<v-col cols="12">
<v-alert type="info" variant="tonal" class="mt-2" icon="mdi-information-outline">
{{ tm('ruleEditor.pluginConfig.hint') }}
</v-alert>
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="savePluginConfig" :loading="saving" prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
<!-- KB Config Section -->
<div class="d-flex align-center mb-4 mt-4">
<h3 class="font-weight-bold mb-0">
{{ tm('ruleEditor.kbConfig.title') }}
</h3>
</div>
<v-row dense>
<v-col cols="12">
<v-select
v-model="kbConfig.kb_ids"
:items="kbOptions"
item-title="label"
item-value="value"
:disabled="availableKbs.length === 0"
:label="tm('ruleEditor.kbConfig.selectKbs')"
variant="outlined"
hide-details
multiple
chips
closable-chips
clearable
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model.number="kbConfig.top_k" :label="tm('ruleEditor.kbConfig.topK')" variant="outlined" hide-details type="number" min="1" max="20" class="mt-3" />
</v-col>
<v-col cols="12" md="6">
<v-checkbox v-model="kbConfig.enable_rerank" :label="tm('ruleEditor.kbConfig.enableRerank')" color="primary" hide-details class="mt-3" />
</v-col>
</v-row>
<div class="d-flex justify-end mt-4">
<v-btn color="primary" variant="tonal" size="small" @click="saveKbConfig" :loading="saving" prepend-icon="mdi-content-save">
{{ tm('buttons.save') }}
</v-btn>
</div>
</div>
</v-card-text>
</v-card>
</v-dialog>
<!-- 确认删除对话框 -->
<v-dialog v-model="deleteDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">{{ tm('deleteConfirm.title') }}</v-card-title>
<v-card-text>
{{ tm('deleteConfirm.message') }}
<br /><br />
<code>{{ deleteTarget?.umo }}</code>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="deleteDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="error" variant="tonal" @click="deleteAllRules" :loading="deleting">{{ tm('buttons.delete') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 批量删除确认对话框 -->
<v-dialog v-model="batchDeleteDialog" max-width="500">
<v-card>
<v-card-title class="text-h6">{{ tm('batchDeleteConfirm.title') }}</v-card-title>
<v-card-text>
{{ tm('batchDeleteConfirm.message', { count: selectedItems.length }) }}
<div class="mt-3" style="max-height: 200px; overflow-y: auto">
<v-chip v-for="item in selectedItems" :key="item.umo" size="small" class="ma-1" variant="outlined">
{{ getUmoDisplayText(item) }}
</v-chip>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="batchDeleteDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="error" variant="tonal" @click="batchDeleteRules" :loading="deleting">
{{ tm('buttons.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 提示信息 -->
<v-snackbar v-model="snackbar" :timeout="3000" elevation="24" :color="snackbarColor" location="top">
{{ snackbarText }}
</v-snackbar>
<!-- 快速编辑备注名对话框 -->
<v-dialog v-model="quickEditNameDialog" max-width="400">
<v-card>
<v-card-title class="py-3 px-4">{{ tm('quickEditName.title') }}</v-card-title>
<v-card-text class="pa-4">
<v-text-field v-model="quickEditNameValue" :label="tm('ruleEditor.serviceConfig.customName')" variant="outlined" hide-details clearable autofocus @keyup.enter="saveQuickEditName" />
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="quickEditNameDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="saveQuickEditName" :loading="saving">
{{ tm('buttons.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</div>
</template>
<script>
import { sessionApi } from '@/api/v1'
import UmoDisplay from '@/components/shared/UmoDisplay.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
const FOLLOW_CONFIG_VALUE = '__astrbot_follow_config__'
export default {
name: 'SessionManagementPage',
components: {
UmoDisplay,
},
setup() {
const { t } = useI18n()
const { tm } = useModuleI18n('features/session-management')
const confirmDialog = useConfirmDialog()
return {
t,
tm,
confirmDialog,
}
},
data() {
return {
loading: false,
saving: false,
deleting: false,
loadingUmos: false,
rulesList: [],
searchQuery: '',
// 分页
currentPage: 1,
itemsPerPage: 10,
totalItems: 0,
searchTimeout: null,
// 可用选项
availablePersonas: [],
availableChatProviders: [],
availableSttProviders: [],
availableTtsProviders: [],
availablePlugins: [],
availableKbs: [],
// 添加规则
addRuleDialog: false,
availableUmos: [],
availableUmoInfoMap: {},
selectedNewUmo: null,
// 规则编辑
ruleDialog: false,
selectedUmo: null,
editingRules: {},
// 服务配置
serviceConfig: {
session_enabled: true,
llm_enabled: true,
tts_enabled: true,
custom_name: '',
persona_id: null,
},
// Provider 配置
providerConfig: {
chat_completion: FOLLOW_CONFIG_VALUE,
speech_to_text: FOLLOW_CONFIG_VALUE,
text_to_speech: FOLLOW_CONFIG_VALUE,
},
// 插件配置
pluginConfig: {
enabled_plugins: [],
disabled_plugins: [],
},
// 知识库配置
kbConfig: {
kb_ids: [],
top_k: 5,
enable_rerank: true,
},
// 删除确认
deleteDialog: false,
deleteTarget: null,
// 批量选择和删除
selectedItems: [],
batchDeleteDialog: false,
// 快速编辑备注名
quickEditNameDialog: false,
quickEditNameTarget: null,
quickEditNameValue: '',
// 批量操作
batchScope: 'selected',
batchGroupId: null,
batchLlmStatus: null,
batchTtsStatus: null,
batchChatProvider: null,
batchTtsProvider: null,
batchUpdating: false,
// 分组管理
groups: [],
groupsLoading: false,
groupDialog: false,
groupDialogMode: 'create',
editingGroup: {
id: null,
name: '',
umos: [],
},
groupMemberDialog: false,
groupMemberTarget: null,
groupMemberSearch: '',
groupSelectedSearch: '',
// 提示信息
snackbar: false,
snackbarText: '',
snackbarColor: 'success',
}
},
computed: {
headers() {
return [
{
title: this.tm('table.headers.umoInfo'),
key: 'umo_info',
sortable: false,
minWidth: '300px',
},
{
title: this.tm('table.headers.rulesOverview'),
key: 'rules_overview',
sortable: false,
minWidth: '250px',
},
{
title: this.tm('table.headers.actions'),
key: 'actions',
sortable: false,
minWidth: '150px',
},
]
},
filteredRulesList() {
// 搜索已移至服务端,直接返回 rulesList
return this.rulesList
},
personaOptions() {
return [
{ label: this.tm('persona.none'), value: null },
...this.availablePersonas.map((p) => ({
label: p.name,
value: p.name,
})),
]
},
chatProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableChatProviders.map((p) => ({
label: `${p.name} (${p.model})`,
value: p.id,
})),
]
},
sttProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableSttProviders.map((p) => ({
label: `${p.name} (${p.model})`,
value: p.id,
})),
]
},
ttsProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableTtsProviders.map((p) => ({
label: `${p.name} (${p.model})`,
value: p.id,
})),
]
},
batchChatProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableChatProviders.map((p) => ({
label: `${p.name} (${p.model})`,
value: p.id,
})),
]
},
batchTtsProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableTtsProviders.map((p) => ({
label: `${p.name} (${p.model})`,
value: p.id,
})),
]
},
pluginOptions() {
return this.availablePlugins.map((p) => ({
label: p.display_name || p.name,
value: p.name,
}))
},
kbOptions() {
return this.availableKbs.map((kb) => ({
label: `${kb.emoji || '📚'} ${kb.kb_name}`,
value: kb.kb_id,
}))
},
batchScopeOptions() {
const options = [
{ label: this.tm('batchOperations.scopeSelected'), value: 'selected' },
{ label: this.tm('batchOperations.scopeAll'), value: 'all' },
{ label: this.tm('batchOperations.scopeGroup'), value: 'group' },
{ label: this.tm('batchOperations.scopePrivate'), value: 'private' },
]
// 添加自定义分组选项
if (this.groups.length > 0) {
options.push({
label: this.tm('groups.customGroupDivider'),
value: '_divider',
disabled: true,
})
this.groups.forEach((g) => {
options.push({
label: this.tm('groups.customGroupOption', {
name: g.name,
count: g.umo_count,
}),
value: `custom_group:${g.id}`,
})
})
}
return options
},
groupOptions() {
return this.groups.map((g) => ({
label: this.tm('groups.groupOption', {
name: g.name,
count: g.umo_count,
}),
value: g.id,
}))
},
statusOptions() {
return [
{ label: this.tm('status.enabled'), value: true },
{ label: this.tm('status.disabled'), value: false },
]
},
canApplyBatch() {
const hasChanges = this.batchLlmStatus !== null || this.batchTtsStatus !== null || this.batchChatProvider !== null || this.batchTtsProvider !== null
if (this.batchScope === 'selected') {
return hasChanges && this.selectedItems.length > 0
}
return hasChanges
},
// 穿梭框未选中的UMO列表
unselectedUmos() {
const selected = new Set(this.editingGroup.umos || [])
return this.availableUmos.filter((u) => !selected.has(u))
},
// 穿梭框:过滤后的未选中列表
filteredUnselectedUmos() {
if (!this.groupMemberSearch) return this.unselectedUmos
const search = this.groupMemberSearch.toLowerCase()
return this.unselectedUmos.filter((u) => u.toLowerCase().includes(search))
},
// 穿梭框:过滤后的已选中列表
filteredSelectedUmos() {
if (!this.groupSelectedSearch) return this.editingGroup.umos || []
const search = this.groupSelectedSearch.toLowerCase()
return (this.editingGroup.umos || []).filter((u) => u.toLowerCase().includes(search))
},
},
watch: {
searchQuery: {
handler() {
// 使用 debounce 延迟搜索
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
this.searchTimeout = setTimeout(() => {
this.onSearchChange()
}, 300)
},
},
},
mounted() {
this.loadData()
this.loadGroups()
},
beforeUnmount() {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout)
}
},
methods: {
async loadData() {
this.loading = true
try {
const response = await sessionApi.listRules({
page: this.currentPage,
page_size: this.itemsPerPage,
search: this.searchQuery || '',
})
if (response.data.status === 'ok') {
const data = response.data.data
this.rulesList = data.rules
this.mergeUmoInfos(data.rules)
this.totalItems = data.total
this.availablePersonas = data.available_personas
this.availableChatProviders = data.available_chat_providers
this.availableSttProviders = data.available_stt_providers
this.availableTtsProviders = data.available_tts_providers
this.availablePlugins = data.available_plugins || []
this.availableKbs = data.available_kbs || []
} else {
this.showError(response.data.message || this.tm('messages.loadError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.loadError'))
}
this.loading = false
},
onTableOptionsUpdate(options) {
// 当分页参数变化时重新加载数据
this.currentPage = options.page
this.itemsPerPage = options.itemsPerPage
this.loadData()
},
onSearchChange() {
// 搜索时重置到第一页
this.currentPage = 1
this.loadData()
},
async loadUmos() {
this.loadingUmos = true
try {
const response = await sessionApi.activeUmos()
if (response.data.status === 'ok') {
this.mergeUmoInfos(response.data.data.umo_infos || [])
// 过滤掉已有规则的 umo
const existingUmos = new Set(this.rulesList.map((r) => r.umo))
this.availableUmos = response.data.data.umos.filter((umo) => !existingUmos.has(umo))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.loadError'))
}
this.loadingUmos = false
},
async refreshData() {
await this.loadData()
this.showSuccess(this.tm('messages.refreshSuccess'))
},
hasProviderConfig(rules) {
return rules && (rules['provider_perf_chat_completion'] || rules['provider_perf_speech_to_text'] || rules['provider_perf_text_to_speech'])
},
parseUmoInfo(umo) {
const parts = umo.split(':')
return {
umo,
platform: parts[0] || '',
message_type: parts[1] || '',
session_id: parts.slice(2).join(':') || umo,
auto_name: '',
user_alias: '',
display_name: umo,
}
},
mergeUmoInfos(infos = []) {
const next = { ...this.availableUmoInfoMap }
for (const info of infos) {
if (info?.umo) {
next[info.umo] = { ...(next[info.umo] || {}), ...info }
}
}
this.availableUmoInfoMap = next
},
getAvailableUmoInfo(umo) {
return this.availableUmoInfoMap[umo] || this.parseUmoInfo(umo)
},
getAvailableUmoDisplayProps(umo) {
const info = this.getAvailableUmoInfo(umo)
return {
umo,
platform: info.platform,
messageType: info.message_type,
sessionId: info.session_id,
autoName: info.auto_name,
userAlias: info.user_alias,
}
},
getPlatformColor(platform) {
const colors = {
aiocqhttp: 'blue',
qq_official: 'purple',
telegram: 'light-blue',
discord: 'indigo',
webchat: 'orange',
}
return colors[platform] || 'grey'
},
getUmoDisplayText(value) {
const item = typeof value === 'string' ? this.getAvailableUmoInfo(value) : value
if (!item) return ''
const umo = item.umo || (typeof value === 'string' ? value : '')
const aliasName = item.user_alias || item.rules?.session_service_config?.custom_name || ''
const autoName = item.auto_name || ''
let displayName = ''
if (aliasName && autoName && aliasName !== autoName) {
displayName = `${aliasName}${autoName}`
} else {
displayName = aliasName || autoName
}
if (displayName && umo) {
return `${displayName} (UMO: ${umo})`
}
return displayName || (umo ? `UMO: ${umo}` : item.display_name || '')
},
getUmoSelectionText(value) {
const item = typeof value === 'string' ? this.getAvailableUmoInfo(value) : value
if (!item) return ''
const umo = item.umo || (typeof value === 'string' ? value : '')
const aliasName = item.user_alias || item.rules?.session_service_config?.custom_name || ''
const autoName = item.auto_name || ''
if (aliasName && autoName && aliasName !== autoName) {
return `${aliasName}${autoName}`
}
return aliasName || autoName || umo || item.display_name || ''
},
buildUmoItem(umo, rules = {}) {
return {
...this.getAvailableUmoInfo(umo),
umo,
rules,
}
},
async openAddRuleDialog() {
this.addRuleDialog = true
this.selectedNewUmo = null
await this.loadUmos()
},
createNewRule() {
if (!this.selectedNewUmo) return
// 创建一个新的规则项并打开编辑器
const newItem = this.buildUmoItem(this.selectedNewUmo)
this.addRuleDialog = false
this.openRuleEditor(newItem)
},
openRuleEditor(item) {
this.selectedUmo = item
this.editingRules = item.rules || {}
// 初始化服务配置
const svcConfig = this.editingRules.session_service_config || {}
this.serviceConfig = {
session_enabled: svcConfig.session_enabled !== false,
llm_enabled: svcConfig.llm_enabled !== false,
tts_enabled: svcConfig.tts_enabled !== false,
custom_name: svcConfig.custom_name || '',
persona_id: svcConfig.persona_id || null,
}
// 初始化 Provider 配置
this.providerConfig = {
chat_completion: this.editingRules['provider_perf_chat_completion'] || FOLLOW_CONFIG_VALUE,
speech_to_text: this.editingRules['provider_perf_speech_to_text'] || FOLLOW_CONFIG_VALUE,
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || FOLLOW_CONFIG_VALUE,
}
// 初始化插件配置
const pluginCfg = this.editingRules.session_plugin_config || {}
this.pluginConfig = {
enabled_plugins: pluginCfg.enabled_plugins || [],
disabled_plugins: pluginCfg.disabled_plugins || [],
}
// 初始化知识库配置
const kbCfg = this.editingRules.kb_config || {}
this.kbConfig = {
kb_ids: kbCfg.kb_ids || [],
top_k: kbCfg.top_k ?? 5,
enable_rerank: kbCfg.enable_rerank !== false,
}
this.ruleDialog = true
},
closeRuleEditor() {
this.ruleDialog = false
this.selectedUmo = null
this.editingRules = {}
},
async saveServiceConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const config = { ...this.serviceConfig }
// 清理空值
if (!config.custom_name) delete config.custom_name
if (config.persona_id === null) delete config.persona_id
const response = await sessionApi.upsertRule({
umo: this.selectedUmo.umo,
rule_key: 'session_service_config',
rule_value: config,
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
this.editingRules.session_service_config = config
// 更新或添加到列表
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (item) {
item.rules = { ...item.rules, session_service_config: config }
} else {
// 新规则,添加到列表
this.rulesList.push(
this.buildUmoItem(this.selectedUmo.umo, {
session_service_config: config,
}),
)
}
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
async saveProviderConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const updateTasks = []
const deleteTasks = []
const providerTypes = ['chat_completion', 'speech_to_text', 'text_to_speech']
for (const type of providerTypes) {
const value = this.providerConfig[type]
if (value && value !== FOLLOW_CONFIG_VALUE) {
// 有值时更新
updateTasks.push(
sessionApi.upsertRule({
umo: this.selectedUmo.umo,
rule_key: `provider_perf_${type}`,
rule_value: value,
}),
)
} else if (this.editingRules[`provider_perf_${type}`]) {
// 选择了"跟随配置文件" (__astrbot_follow_config__) 且之前有配置,则删除
deleteTasks.push(
sessionApi.deleteRules({
umo: this.selectedUmo.umo,
rule_key: `provider_perf_${type}`,
}),
)
}
}
const allTasks = [...updateTasks, ...deleteTasks]
if (allTasks.length > 0) {
await Promise.all(allTasks)
this.showSuccess(this.tm('messages.saveSuccess'))
// 更新或添加到列表
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (!item) {
item = this.buildUmoItem(this.selectedUmo.umo)
this.rulesList.push(item)
}
for (const type of providerTypes) {
const val = this.providerConfig[type]
if (val && val !== FOLLOW_CONFIG_VALUE) {
item.rules[`provider_perf_${type}`] = val
this.editingRules[`provider_perf_${type}`] = val
} else {
// 删除本地数据
delete item.rules[`provider_perf_${type}`]
delete this.editingRules[`provider_perf_${type}`]
}
}
} else {
this.showSuccess(this.tm('messages.noChanges'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
async savePluginConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const config = {
enabled_plugins: this.pluginConfig.enabled_plugins,
disabled_plugins: this.pluginConfig.disabled_plugins,
}
// 如果两个列表都为空,删除配置
if (config.enabled_plugins.length === 0 && config.disabled_plugins.length === 0) {
if (this.editingRules.session_plugin_config) {
await sessionApi.deleteRules({
umo: this.selectedUmo.umo,
rule_key: 'session_plugin_config',
})
delete this.editingRules.session_plugin_config
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (item) delete item.rules.session_plugin_config
}
this.showSuccess(this.tm('messages.saveSuccess'))
} else {
const response = await sessionApi.upsertRule({
umo: this.selectedUmo.umo,
rule_key: 'session_plugin_config',
rule_value: config,
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
this.editingRules.session_plugin_config = config
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (item) {
item.rules.session_plugin_config = config
} else {
this.rulesList.push(
this.buildUmoItem(this.selectedUmo.umo, {
session_plugin_config: config,
}),
)
}
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
async saveKbConfig() {
if (!this.selectedUmo) return
this.saving = true
try {
const config = {
kb_ids: this.kbConfig.kb_ids,
top_k: this.kbConfig.top_k,
enable_rerank: this.kbConfig.enable_rerank,
}
// 如果 kb_ids 为空,删除配置
if (config.kb_ids.length === 0) {
if (this.editingRules.kb_config) {
await sessionApi.deleteRules({
umo: this.selectedUmo.umo,
rule_key: 'kb_config',
})
delete this.editingRules.kb_config
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (item) delete item.rules.kb_config
}
this.showSuccess(this.tm('messages.saveSuccess'))
} else {
const response = await sessionApi.upsertRule({
umo: this.selectedUmo.umo,
rule_key: 'kb_config',
rule_value: config,
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
this.editingRules.kb_config = config
let item = this.rulesList.find((u) => u.umo === this.selectedUmo.umo)
if (item) {
item.rules.kb_config = config
} else {
this.rulesList.push(
this.buildUmoItem(this.selectedUmo.umo, {
kb_config: config,
}),
)
}
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
confirmDeleteRules(item) {
this.deleteTarget = item
this.deleteDialog = true
},
async deleteAllRules() {
if (!this.deleteTarget) return
this.deleting = true
try {
const response = await sessionApi.deleteRules({
umo: this.deleteTarget.umo,
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.deleteSuccess'))
// 从列表中移除
const index = this.rulesList.findIndex((u) => u.umo === this.deleteTarget.umo)
if (index > -1) {
this.rulesList.splice(index, 1)
}
this.deleteDialog = false
this.deleteTarget = null
// 重新加载数据以更新 totalItems
await this.loadData()
} else {
this.showError(response.data.message || this.tm('messages.deleteError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.deleteError'))
}
this.deleting = false
},
confirmBatchDelete() {
if (this.selectedItems.length === 0) return
this.batchDeleteDialog = true
},
async batchDeleteRules() {
if (this.selectedItems.length === 0) return
this.deleting = true
try {
const umos = this.selectedItems.map((item) => item.umo)
const response = await sessionApi.deleteRules({
umos: umos,
})
if (response.data.status === 'ok') {
const data = response.data.data
this.showSuccess(data.message || this.tm('messages.batchDeleteSuccess'))
this.batchDeleteDialog = false
this.selectedItems = []
// 重新加载数据
await this.loadData()
} else {
this.showError(response.data.message || this.tm('messages.batchDeleteError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.batchDeleteError'))
}
this.deleting = false
},
showSuccess(message) {
this.snackbarText = message
this.snackbarColor = 'success'
this.snackbar = true
},
showError(message) {
this.snackbarText = message
this.snackbarColor = 'error'
this.snackbar = true
},
openQuickEditName(item) {
this.quickEditNameTarget = item
this.quickEditNameValue = item.rules?.session_service_config?.custom_name || ''
this.quickEditNameDialog = true
},
async saveQuickEditName() {
if (!this.quickEditNameTarget) return
this.saving = true
try {
// 获取现有的 session_service_config 或创建新的
const existingConfig = this.quickEditNameTarget.rules?.session_service_config || {}
const config = {
session_enabled: existingConfig.session_enabled !== false,
llm_enabled: existingConfig.llm_enabled !== false,
tts_enabled: existingConfig.tts_enabled !== false,
...existingConfig,
}
// 更新 custom_name
if (this.quickEditNameValue) {
config.custom_name = this.quickEditNameValue
} else {
delete config.custom_name
}
const response = await sessionApi.upsertRule({
umo: this.quickEditNameTarget.umo,
rule_key: 'session_service_config',
rule_value: config,
})
if (response.data.status === 'ok') {
this.showSuccess(this.tm('messages.saveSuccess'))
// 更新或添加到列表
let item = this.rulesList.find((u) => u.umo === this.quickEditNameTarget.umo)
if (item) {
if (!item.rules) item.rules = {}
item.rules.session_service_config = config
} else {
// 新规则,添加到列表
this.rulesList.push(
this.buildUmoItem(this.quickEditNameTarget.umo, {
session_service_config: config,
}),
)
}
this.quickEditNameDialog = false
this.quickEditNameTarget = null
this.quickEditNameValue = ''
} else {
this.showError(response.data.message || this.tm('messages.saveError'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveError'))
}
this.saving = false
},
async applyBatchChanges() {
this.batchUpdating = true
try {
let scope = this.batchScope
let groupId = null
let umos = []
// 处理自定义分组
if (scope.startsWith('custom_group:')) {
groupId = scope.split(':')[1]
scope = 'custom_group'
}
if (scope === 'selected') {
umos = this.selectedItems.map((item) => item.umo)
if (umos.length === 0) {
this.showError(this.tm('messages.selectSessionsFirst'))
this.batchUpdating = false
return
}
}
const tasks = []
if (this.batchLlmStatus !== null || this.batchTtsStatus !== null) {
const serviceData = { scope, umos, group_id: groupId }
if (this.batchLlmStatus !== null) {
serviceData.llm_enabled = this.batchLlmStatus
}
if (this.batchTtsStatus !== null) {
serviceData.tts_enabled = this.batchTtsStatus
}
tasks.push(sessionApi.batchUpdateService(serviceData))
}
if (this.batchChatProvider !== null) {
if (this.batchChatProvider === FOLLOW_CONFIG_VALUE) {
tasks.push(
sessionApi.deleteRules({
scope,
umos,
group_id: groupId,
rule_key: 'provider_perf_chat_completion',
}),
)
} else {
tasks.push(
sessionApi.batchUpdateProvider({
scope,
umos,
group_id: groupId,
provider_type: 'chat_completion',
provider_id: this.batchChatProvider,
}),
)
}
}
if (this.batchTtsProvider !== null) {
if (this.batchTtsProvider === FOLLOW_CONFIG_VALUE) {
tasks.push(
sessionApi.deleteRules({
scope,
umos,
group_id: groupId,
rule_key: 'provider_perf_text_to_speech',
}),
)
} else {
tasks.push(
sessionApi.batchUpdateProvider({
scope,
umos,
group_id: groupId,
provider_type: 'text_to_speech',
provider_id: this.batchTtsProvider,
}),
)
}
}
if (tasks.length === 0) {
this.showError(this.tm('messages.selectAtLeastOneConfig'))
this.batchUpdating = false
return
}
const results = await Promise.all(tasks)
const allOk = results.every((r) => r.data.status === 'ok')
if (allOk) {
this.showSuccess(this.tm('messages.batchUpdateSuccess'))
this.batchLlmStatus = null
this.batchTtsStatus = null
this.batchChatProvider = null
this.batchTtsProvider = null
await this.loadData()
} else {
this.showError(this.tm('messages.partialUpdateFailed'))
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError'))
}
this.batchUpdating = false
},
// ==================== 分组管理方法 ====================
async loadGroups() {
this.groupsLoading = true
try {
const response = await sessionApi.listGroups()
if (response.data.status === 'ok') {
this.groups = response.data.data.groups || []
}
} catch (error) {
console.error('加载分组失败:', error)
}
this.groupsLoading = false
},
async loadAvailableUmos() {
if (this.availableUmos.length > 0) return
this.loadingUmos = true
try {
const response = await sessionApi.activeUmos()
if (response.data.status === 'ok') {
this.mergeUmoInfos(response.data.data.umo_infos || [])
this.availableUmos = response.data.data.umos || []
}
} catch (error) {
console.error('加载会话列表失败:', error)
}
this.loadingUmos = false
},
openCreateGroupDialog() {
this.groupDialogMode = 'create'
this.editingGroup = { id: null, name: '', umos: [] }
this.groupMemberSearch = ''
this.groupSelectedSearch = ''
this.groupDialog = true
},
openEditGroupDialog(group) {
this.groupDialogMode = 'edit'
this.editingGroup = { ...group, umos: [...(group.umos || [])] }
this.groupMemberSearch = ''
this.groupSelectedSearch = ''
this.groupDialog = true
},
// 穿梭框操作方法
addToGroup(umo) {
if (!this.editingGroup.umos.includes(umo)) {
this.editingGroup.umos.push(umo)
}
},
removeFromGroup(umo) {
const idx = this.editingGroup.umos.indexOf(umo)
if (idx > -1) {
this.editingGroup.umos.splice(idx, 1)
}
},
addAllToGroup() {
this.unselectedUmos.forEach((umo) => {
if (!this.editingGroup.umos.includes(umo)) {
this.editingGroup.umos.push(umo)
}
})
},
removeAllFromGroup() {
this.editingGroup.umos = []
},
async saveGroup() {
if (!this.editingGroup.name.trim()) {
this.showError(this.tm('messages.groupNameRequired'))
return
}
try {
let response
if (this.groupDialogMode === 'create') {
response = await sessionApi.createGroup({
name: this.editingGroup.name,
umos: this.editingGroup.umos,
})
} else {
response = await sessionApi.updateGroup(this.editingGroup.id, {
name: this.editingGroup.name,
umos: this.editingGroup.umos,
})
}
if (response.data.status === 'ok') {
this.showSuccess(response.data.data.message)
this.groupDialog = false
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.saveGroupError'))
}
},
async deleteGroup(group) {
const message = this.tm('groups.deleteConfirm', { name: group.name })
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
try {
const response = await sessionApi.deleteGroup(group.id)
if (response.data.status === 'ok') {
this.showSuccess(response.data.data.message)
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError'))
}
},
openGroupMemberDialog(group) {
this.groupMemberTarget = { ...group }
this.groupMemberDialog = true
},
async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) {
this.showError(this.tm('messages.selectSessionsToAddFirst'))
return
}
try {
const response = await sessionApi.updateGroup(groupId, {
add_umos: this.selectedItems.map((item) => item.umo),
})
if (response.data.status === 'ok') {
this.showSuccess(
this.tm('messages.addToGroupSuccess', {
count: this.selectedItems.length,
}),
)
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || this.tm('messages.addToGroupError'))
}
},
},
}
</script>
<style scoped>
.v-data-table :deep(.v-data-table__td) {
padding: 8px 16px !important;
vertical-align: middle !important;
}
code {
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.transfer-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
}
.transfer-item {
cursor: pointer;
transition: background-color 0.15s;
min-height: 44px !important;
padding-top: 3px !important;
padding-bottom: 3px !important;
}
.transfer-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
.transfer-item :deep(.v-list-item__append) {
align-self: center;
margin-inline-start: auto;
padding-inline-start: 12px;
}
.transfer-item :deep(.v-list-item__prepend) {
align-self: center;
}
.transfer-item :deep(.v-list-item__content) {
min-width: 0;
padding-inline-end: 12px;
}
.transfer-item :deep(.v-list-item-title) {
line-height: 1.2;
}
.umo-list-platform {
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
}
.umo-selection-chip {
max-width: 100%;
}
.umo-selection-chip :deep(.v-chip__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>