diff --git a/astrbot/dashboard/routes/session_management.py b/astrbot/dashboard/routes/session_management.py index 0310b29e9..0a68d2579 100644 --- a/astrbot/dashboard/routes/session_management.py +++ b/astrbot/dashboard/routes/session_management.py @@ -38,6 +38,11 @@ class SessionManagementRoute(Route): "/session/list-all-with-status": ("GET", self.list_all_umos_with_status), "/session/batch-update-service": ("POST", self.batch_update_service), "/session/batch-update-provider": ("POST", self.batch_update_provider), + # 分组管理 API + "/session/groups": ("GET", self.list_groups), + "/session/group/create": ("POST", self.create_group), + "/session/group/update": ("POST", self.update_group), + "/session/group/delete": ("POST", self.delete_group), } self.conv_mgr = core_lifecycle.conversation_manager self.core_lifecycle = core_lifecycle @@ -534,7 +539,8 @@ class SessionManagementRoute(Route): 请求体: { "umos": ["平台:消息类型:会话ID", ...], // 可选,如果不传则根据 scope 筛选 - "scope": "all" | "group" | "private", // 可选,批量范围 + "scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围 + "group_id": "分组ID", // 当 scope 为 custom_group 时必填 "llm_enabled": true/false/null, // 可选,null表示不修改 "tts_enabled": true/false/null, // 可选 "session_enabled": true/false/null // 可选 @@ -544,6 +550,7 @@ class SessionManagementRoute(Route): data = await request.get_json() umos = data.get("umos", []) scope = data.get("scope", "") + group_id = data.get("group_id", "") llm_enabled = data.get("llm_enabled") tts_enabled = data.get("tts_enabled") session_enabled = data.get("session_enabled") @@ -554,19 +561,28 @@ class SessionManagementRoute(Route): # 如果指定了 scope,获取符合条件的所有 umo if scope and not umos: - async with self.db_helper.get_db() as session: - session: AsyncSession - result = await session.execute( - select(ConversationV2.user_id).distinct() - ) - all_umos = [row[0] for row in result.fetchall()] + # 如果是自定义分组 + if scope == "custom_group": + if not group_id: + return Response().error("请指定分组 ID").__dict__ + groups = self._get_groups() + if group_id not in groups: + return Response().error(f"分组 '{group_id}' 不存在").__dict__ + umos = groups[group_id].get("umos", []) + else: + async with self.db_helper.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ConversationV2.user_id).distinct() + ) + all_umos = [row[0] for row in result.fetchall()] - if scope == "group": - umos = [u for u in all_umos if ":group:" in u.lower() or ":groupmessage:" in u.lower()] - elif scope == "private": - umos = [u for u in all_umos if ":private:" in u.lower() or ":friend" in u.lower()] - elif scope == "all": - umos = all_umos + if scope == "group": + umos = [u for u in all_umos if ":group:" in u.lower() or ":groupmessage:" in u.lower()] + elif scope == "private": + umos = [u for u in all_umos if ":private:" in u.lower() or ":friend" in u.lower()] + elif scope == "all": + umos = all_umos if not umos: return Response().error("没有找到符合条件的会话").__dict__ @@ -652,20 +668,30 @@ class SessionManagementRoute(Route): provider_type_enum = provider_type_map[provider_type] # 如果指定了 scope,获取符合条件的所有 umo + group_id = data.get("group_id", "") if scope and not umos: - async with self.db_helper.get_db() as session: - session: AsyncSession - result = await session.execute( - select(ConversationV2.user_id).distinct() - ) - all_umos = [row[0] for row in result.fetchall()] + # 如果是自定义分组 + if scope == "custom_group": + if not group_id: + return Response().error("请指定分组 ID").__dict__ + groups = self._get_groups() + if group_id not in groups: + return Response().error(f"分组 '{group_id}' 不存在").__dict__ + umos = groups[group_id].get("umos", []) + else: + async with self.db_helper.get_db() as session: + session: AsyncSession + result = await session.execute( + select(ConversationV2.user_id).distinct() + ) + all_umos = [row[0] for row in result.fetchall()] - if scope == "group": - umos = [u for u in all_umos if ":group:" in u.lower() or ":groupmessage:" in u.lower()] - elif scope == "private": - umos = [u for u in all_umos if ":private:" in u.lower() or ":friend" in u.lower()] - elif scope == "all": - umos = all_umos + if scope == "group": + umos = [u for u in all_umos if ":group:" in u.lower() or ":groupmessage:" in u.lower()] + elif scope == "private": + umos = [u for u in all_umos if ":private:" in u.lower() or ":friend" in u.lower()] + elif scope == "all": + umos = all_umos if not umos: return Response().error("没有找到符合条件的会话").__dict__ @@ -700,3 +726,144 @@ class SessionManagementRoute(Route): except Exception as e: logger.error(f"批量更新 Provider 失败: {e!s}") return Response().error(f"批量更新 Provider 失败: {e!s}").__dict__ + + # ==================== 分组管理 API ==================== + + def _get_groups(self) -> dict: + """获取所有分组""" + return sp.get("session_groups", {}) + + def _save_groups(self, groups: dict) -> None: + """保存分组""" + sp.put("session_groups", groups) + + async def list_groups(self): + """获取所有分组列表""" + try: + groups = self._get_groups() + # 转换为列表格式,方便前端使用 + groups_list = [] + for group_id, group_data in groups.items(): + groups_list.append({ + "id": group_id, + "name": group_data.get("name", ""), + "umos": group_data.get("umos", []), + "umo_count": len(group_data.get("umos", [])), + }) + return Response().ok({"groups": groups_list}).__dict__ + except Exception as e: + logger.error(f"获取分组列表失败: {e!s}") + return Response().error(f"获取分组列表失败: {e!s}").__dict__ + + async def create_group(self): + """创建新分组""" + try: + data = await request.json + name = data.get("name", "").strip() + umos = data.get("umos", []) + + if not name: + return Response().error("分组名称不能为空").__dict__ + + groups = self._get_groups() + + # 生成唯一 ID + import uuid + group_id = str(uuid.uuid4())[:8] + + groups[group_id] = { + "name": name, + "umos": umos, + } + + self._save_groups(groups) + + return Response().ok({ + "message": f"分组 '{name}' 创建成功", + "group": { + "id": group_id, + "name": name, + "umos": umos, + "umo_count": len(umos), + } + }).__dict__ + except Exception as e: + logger.error(f"创建分组失败: {e!s}") + return Response().error(f"创建分组失败: {e!s}").__dict__ + + async def update_group(self): + """更新分组(改名、增删成员)""" + try: + data = await request.json + group_id = data.get("id") + name = data.get("name") + umos = data.get("umos") + add_umos = data.get("add_umos", []) + remove_umos = data.get("remove_umos", []) + + if not group_id: + return Response().error("分组 ID 不能为空").__dict__ + + groups = self._get_groups() + + if group_id not in groups: + return Response().error(f"分组 '{group_id}' 不存在").__dict__ + + group = groups[group_id] + + # 更新名称 + if name is not None: + group["name"] = name.strip() + + # 直接设置 umos 列表 + if umos is not None: + group["umos"] = umos + else: + # 增量更新 + current_umos = set(group.get("umos", [])) + if add_umos: + current_umos.update(add_umos) + if remove_umos: + current_umos.difference_update(remove_umos) + group["umos"] = list(current_umos) + + self._save_groups(groups) + + return Response().ok({ + "message": f"分组 '{group['name']}' 更新成功", + "group": { + "id": group_id, + "name": group["name"], + "umos": group["umos"], + "umo_count": len(group["umos"]), + } + }).__dict__ + except Exception as e: + logger.error(f"更新分组失败: {e!s}") + return Response().error(f"更新分组失败: {e!s}").__dict__ + + async def delete_group(self): + """删除分组""" + try: + data = await request.json + group_id = data.get("id") + + if not group_id: + return Response().error("分组 ID 不能为空").__dict__ + + groups = self._get_groups() + + if group_id not in groups: + return Response().error(f"分组 '{group_id}' 不存在").__dict__ + + group_name = groups[group_id].get("name", group_id) + del groups[group_id] + + self._save_groups(groups) + + return Response().ok({ + "message": f"分组 '{group_name}' 已删除" + }).__dict__ + except Exception as e: + logger.error(f"删除分组失败: {e!s}") + return Response().error(f"删除分组失败: {e!s}").__dict__ diff --git a/batch_operation_dist.zip b/batch_operation_dist.zip index 46551204a..bda58ba1d 100644 Binary files a/batch_operation_dist.zip and b/batch_operation_dist.zip differ diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue index 5cfbf6db3..906e4cb3b 100644 --- a/dashboard/src/views/SessionManagementPage.vue +++ b/dashboard/src/views/SessionManagementPage.vue @@ -153,6 +153,119 @@ + + + + 分组管理 + + {{ groups.length }} 个分组 + + + + mdi-folder-plus + 添加到分组 + + + + {{ g.name }} ({{ g.umo_count }}) + + + + + + 新建分组 + + + + + + +
+
+
{{ group.name }}
+
{{ group.umo_count }} 个会话
+
+
+ + mdi-pencil + + + mdi-delete + +
+
+
+
+
+
+ + 暂无分组,点击「新建分组」创建 + +
+ + + + + + {{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }} + + + + + + +
可选会话 ({{ unselectedUmos.length }})
+ + + + + {{ formatUmoShort(umo) }} + + + 无匹配项 + + + + + +
+ + + + mdi-chevron-double-right + + + mdi-chevron-double-left + + + + +
已选会话 ({{ editingGroup.umos.length }})
+ + + + + {{ formatUmoShort(umo) }} + + + 暂无成员 + + +
+
+
+ + + 取消 + 保存 + +
+
+ @@ -497,12 +610,28 @@ export default { 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: '', @@ -578,12 +707,27 @@ export default { })) }, batchScopeOptions() { - return [ + 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: '── 自定义分组 ──', value: '_divider', disabled: true }) + this.groups.forEach(g => { + options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` }) + }) + } + return options + }, + + groupOptions() { + return this.groups.map(g => ({ + label: `${g.name} (${g.umo_count} 个会话)`, + value: g.id + })) }, statusOptions() { @@ -601,6 +745,26 @@ export default { } 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: { @@ -619,6 +783,7 @@ export default { mounted() { this.loadData() + this.loadGroups() }, beforeUnmount() { @@ -1148,9 +1313,16 @@ export default { async applyBatchChanges() { this.batchUpdating = true try { - const scope = this.batchScope + 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) { @@ -1163,7 +1335,7 @@ export default { const tasks = [] if (this.batchLlmStatus !== null || this.batchTtsStatus !== null) { - const serviceData = { scope, umos } + const serviceData = { scope, umos, group_id: groupId } if (this.batchLlmStatus !== null) { serviceData.llm_enabled = this.batchLlmStatus } @@ -1177,6 +1349,7 @@ export default { tasks.push(axios.post('/api/session/batch-update-provider', { scope, umos, + group_id: groupId, provider_type: 'chat_completion', provider_id: this.batchChatProvider || null })) @@ -1186,6 +1359,7 @@ export default { tasks.push(axios.post('/api/session/batch-update-provider', { scope, umos, + group_id: groupId, provider_type: 'text_to_speech', provider_id: this.batchTtsProvider || null })) @@ -1215,6 +1389,162 @@ export default { } this.batchUpdating = false }, + + // ==================== 分组管理方法 ==================== + + async loadGroups() { + this.groupsLoading = true + try { + const response = await axios.get('/api/session/groups') + 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 axios.get('/api/session/active-umos') + if (response.data.status === 'ok') { + 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 = [] + }, + + formatUmoShort(umo) { + // 简化显示:平台:类型:ID -> 只显示ID部分 + const parts = umo.split(':') + if (parts.length >= 3) { + return `${parts[0]}:${parts[2]}` + } + return umo + }, + + async saveGroup() { + if (!this.editingGroup.name.trim()) { + this.showError('分组名称不能为空') + return + } + + try { + let response + if (this.groupDialogMode === 'create') { + response = await axios.post('/api/session/group/create', { + name: this.editingGroup.name, + umos: this.editingGroup.umos + }) + } else { + response = await axios.post('/api/session/group/update', { + id: 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 || '保存分组失败') + } + }, + + async deleteGroup(group) { + if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) return + + try { + const response = await axios.post('/api/session/group/delete', { id: 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 || '删除分组失败') + } + }, + + openGroupMemberDialog(group) { + this.groupMemberTarget = { ...group } + this.groupMemberDialog = true + }, + + async addSelectedToGroup(groupId) { + if (this.selectedItems.length === 0) { + this.showError('请先选择要添加的会话') + return + } + + try { + const response = await axios.post('/api/session/group/update', { + id: groupId, + add_umos: this.selectedItems.map(item => item.umo) + }) + if (response.data.status === 'ok') { + this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`) + await this.loadGroups() + } else { + this.showError(response.data.message) + } + } catch (error) { + this.showError(error.response?.data?.message || '添加失败') + } + }, }, } @@ -1231,4 +1561,20 @@ code { 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; +} + +.transfer-item:hover { + background-color: rgba(0, 0, 0, 0.04); +}