From 1cee741914e72a8cba14b00031a92f9bfba3bb88 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Tue, 9 Sep 2025 20:27:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(conversation):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E4=BC=9A=E8=AF=9D=E6=96=B9=E6=B3=95=E4=BB=A5?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=BB=E5=8A=A1ID=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/backend/hermes/client.py | 51 ++++++++++++++++----- src/backend/hermes/services/conversation.py | 17 +++++-- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index d74c8a3..6f7b307 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -49,6 +49,9 @@ class HermesChatClient(LLMClientBase): # 当前选择的智能体ID self._current_agent_id: str = "" + # 当前正在运行的任务ID(用于停止请求) + self._current_task_id: str = "" + # MCP 事件处理器(可选) self._mcp_handler: MCPEventHandler | None = None @@ -298,34 +301,32 @@ class HermesChatClient(LLMClientBase): """处理流式响应事件""" has_content = False event_count = 0 - has_error_message = False # 标记是否已经产生错误消息 + has_error_message = False self.logger.info("开始处理流式响应事件") try: async for line in response.aiter_lines(): - stripped_line = line.strip() - if not stripped_line: - continue - - self.logger.debug("收到 SSE 行: %s", stripped_line) - event = HermesStreamEvent.from_line(stripped_line) + event = self._parse_stream_line(line) if event is None: - self.logger.warning("无法解析 SSE 事件") continue event_count += 1 self.logger.info("解析到事件 #%d - 类型: %s", event_count, event.event_type) + # 处理任务ID + self._handle_task_id(event) + # 处理特殊事件类型 should_break, break_message = self.stream_processor.handle_special_events(event) if should_break: + self._cleanup_task_id("回答结束") if break_message: - has_error_message = True # 标记已产生错误消息 + has_error_message = True yield break_message break - # 处理各种事件内容 + # 处理事件内容 content_yielded = False async for content in self._handle_event_content(event): has_content = True @@ -339,12 +340,38 @@ class HermesChatClient(LLMClientBase): except Exception: self.logger.exception("处理流式响应事件时出错") + self._cleanup_task_id("发生异常") raise # 只有在没有内容且没有错误消息的情况下才显示无内容消息 if not has_content and not has_error_message: yield self.stream_processor.get_no_content_message(event_count) + def _parse_stream_line(self, line: str) -> HermesStreamEvent | None: + """解析单行流式响应""" + stripped_line = line.strip() + if not stripped_line: + return None + + self.logger.debug("收到 SSE 行: %s", stripped_line) + event = HermesStreamEvent.from_line(stripped_line) + if event is None: + self.logger.warning("无法解析 SSE 事件") + return event + + def _handle_task_id(self, event: HermesStreamEvent) -> None: + """处理事件中的任务ID""" + task_id = event.get_task_id() + if task_id and not self._current_task_id: + self._current_task_id = task_id + self.logger.debug("设置当前任务ID: %s", task_id) + + def _cleanup_task_id(self, context: str) -> None: + """清理任务ID""" + if self._current_task_id: + self.logger.debug("%s清理任务ID: %s", context, self._current_task_id) + self._current_task_id = "" + async def _handle_event_content(self, event: HermesStreamEvent) -> AsyncGenerator[str, None]: """处理单个事件的内容""" # 处理 MCP 状态信息 @@ -372,7 +399,9 @@ class HermesChatClient(LLMClientBase): async def _stop(self) -> None: """停止当前会话""" if self._conversation_manager is not None: - await self._conversation_manager.stop_conversation() + await self._conversation_manager.stop_conversation(self._current_task_id) + # 停止后清理任务ID + self._cleanup_task_id("手动停止") async def __aenter__(self) -> Self: """异步上下文管理器入口""" diff --git a/src/backend/hermes/services/conversation.py b/src/backend/hermes/services/conversation.py index c9d1bb8..86c2aec 100644 --- a/src/backend/hermes/services/conversation.py +++ b/src/backend/hermes/services/conversation.py @@ -72,8 +72,14 @@ class HermesConversationManager: return self._conversation_id - async def stop_conversation(self) -> None: - """停止当前会话""" + async def stop_conversation(self, task_id: str = "") -> None: + """ + 停止当前会话 + + Args: + task_id: 可选的任务ID,如果提供且非空,则作为查询参数发送 + + """ if self.http_manager.client is None or self.http_manager.client.is_closed: return @@ -81,7 +87,12 @@ class HermesConversationManager: stop_url = urljoin(self.http_manager.base_url, "/api/stop") headers = self.http_manager.build_headers() - response = await self.http_manager.client.post(stop_url, headers=headers) + # 构建请求参数 + params = {} + if task_id: + params["taskId"] = task_id + + response = await self.http_manager.client.post(stop_url, headers=headers, params=params) if response.status_code != HTTP_OK: error_text = await response.aread() -- Gitee From 2349eeaad148743495257aee11feaed39b28a304 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Tue, 9 Sep 2025 20:30:58 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(hermes):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=8E=B7=E5=8F=96=E5=92=8C=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=89=A7=E8=A1=8C=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/backend/hermes/client.py | 59 ++++++- src/backend/hermes/constants.py | 6 +- src/backend/hermes/services/__init__.py | 15 ++ src/backend/hermes/services/user.py | 198 ++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 7 deletions(-) create mode 100644 src/backend/hermes/services/__init__.py create mode 100644 src/backend/hermes/services/user.py diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index 6f7b307..85b532c 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -15,10 +15,13 @@ from log.manager import get_logger, log_exception from .constants import HTTP_OK from .exceptions import HermesAPIError from .models import HermesApp, HermesChatRequest, HermesFeatures -from .services.agent import HermesAgentManager -from .services.conversation import HermesConversationManager -from .services.http import HermesHttpManager -from .services.model import HermesModelManager +from .services import ( + HermesAgentManager, + HermesConversationManager, + HermesHttpManager, + HermesModelManager, + HermesUserManager, +) from .stream import HermesStreamEvent, HermesStreamProcessor if TYPE_CHECKING: @@ -41,6 +44,7 @@ class HermesChatClient(LLMClientBase): self.http_manager = HermesHttpManager(base_url, auth_token) # 延迟初始化的管理器 + self._user_manager: HermesUserManager | None = None self._model_manager: HermesModelManager | None = None self._agent_manager: HermesAgentManager | None = None self._conversation_manager: HermesConversationManager | None = None @@ -57,6 +61,13 @@ class HermesChatClient(LLMClientBase): self.logger.info("Hermes 客户端初始化成功 - URL: %s", base_url) + @property + def user_manager(self) -> HermesUserManager: + """获取用户管理器(延迟初始化)""" + if self._user_manager is None: + self._user_manager = HermesUserManager(self.http_manager) + return self._user_manager + @property def model_manager(self) -> HermesModelManager: """获取模型管理器(延迟初始化)""" @@ -235,6 +246,46 @@ class HermesChatClient(LLMClientBase): log_exception(self.logger, "MCP 响应请求失败", e) raise + async def get_auto_execute_status(self) -> bool: + """ + 获取用户自动执行状态 + + 这是一个便捷方法,从用户信息中提取自动执行状态。 + 默认情况下返回 False。 + + Returns: + bool: 自动执行状态,默认为 False + + """ + user_info = await self.user_manager.get_user_info() + if user_info is None: + self.logger.warning("无法获取用户信息,自动执行状态默认为 False") + return False + + auto_execute = user_info.get("auto_execute", False) + self.logger.debug("当前自动执行状态: %s", auto_execute) + return auto_execute + + async def enable_auto_execute(self) -> None: + """ + 启用自动执行 + + Returns: + bool: 更新是否成功 + + """ + await self.user_manager.update_auto_execute(auto_execute=True) + + async def disable_auto_execute(self) -> None: + """ + 禁用自动执行 + + Returns: + bool: 更新是否成功 + + """ + await self.user_manager.update_auto_execute(auto_execute=False) + async def close(self) -> None: """关闭 HTTP 客户端""" # 如果有未完成的会话,先停止它 diff --git a/src/backend/hermes/constants.py b/src/backend/hermes/constants.py index 48a8a13..8e787d5 100644 --- a/src/backend/hermes/constants.py +++ b/src/backend/hermes/constants.py @@ -1,8 +1,8 @@ """Hermes 常量定义""" # HTTP 状态码常量 -HTTP_OK = 200 +HTTP_OK: int = 200 # 分页常量 -ITEMS_PER_PAGE = 16 # 每页最多16项 -MAX_PAGES = 100 # 最多请求100页 +ITEMS_PER_PAGE: int = 16 # 每页最多16项 +MAX_PAGES: int = 100 # 最多请求100页 diff --git a/src/backend/hermes/services/__init__.py b/src/backend/hermes/services/__init__.py new file mode 100644 index 0000000..cc14289 --- /dev/null +++ b/src/backend/hermes/services/__init__.py @@ -0,0 +1,15 @@ +"""Hermes 服务模块""" + +from .agent import HermesAgentManager +from .conversation import HermesConversationManager +from .http import HermesHttpManager +from .model import HermesModelManager +from .user import HermesUserManager + +__all__ = [ + "HermesAgentManager", + "HermesConversationManager", + "HermesHttpManager", + "HermesModelManager", + "HermesUserManager", +] diff --git a/src/backend/hermes/services/user.py b/src/backend/hermes/services/user.py new file mode 100644 index 0000000..0db67b5 --- /dev/null +++ b/src/backend/hermes/services/user.py @@ -0,0 +1,198 @@ +"""Hermes 用户管理器""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING +from urllib.parse import urljoin + +import httpx + +from backend.hermes.constants import HTTP_OK +from log.manager import get_logger, log_api_request, log_exception + +if TYPE_CHECKING: + from typing import Any + + from .http import HermesHttpManager + + +class HermesUserManager: + """Hermes 用户管理器""" + + def __init__(self, http_manager: HermesHttpManager) -> None: + """初始化用户管理器""" + self.logger = get_logger(__name__) + self.http_manager = http_manager + + async def get_user_info(self) -> dict[str, Any] | None: + """ + 获取用户信息 + + 通过调用 GET /api/auth/user 接口获取当前用户信息, + 包括用户标识、权限、自动执行设置等。 + + Returns: + dict[str, Any] | None: 用户信息字典,如果请求失败返回 None + 返回数据格式: + { + "user_sub": str, # 用户标识 + "revision": bool, # 权限标识 + "is_admin": bool, # 是否管理员 + "auto_execute": bool # 是否自动执行 + } + + """ + start_time = time.time() + self.logger.info("开始请求 Hermes 用户信息 API") + + try: + client = await self.http_manager.get_client() + user_url = urljoin(self.http_manager.base_url, "/api/auth/user") + headers = self.http_manager.build_headers() + + response = await client.get(user_url, headers=headers) + + duration = time.time() - start_time + log_api_request( + self.logger, + "GET", + user_url, + response.status_code, + duration, + ) + + # 处理HTTP错误状态 + if response.status_code != HTTP_OK: + error_msg = f"API 调用失败,状态码: {response.status_code}" + self.logger.warning("获取用户信息失败: %s", error_msg) + return None + + # 解析响应数据 + try: + data = response.json() + except json.JSONDecodeError: + error_msg = "响应 JSON 格式无效" + self.logger.warning("获取用户信息失败: %s", error_msg) + return None + + # 验证响应结构 + if not self._validate_user_response(data): + return None + + user_info = data["result"] + self.logger.info( + "获取用户信息成功 - 用户: %s, 自动执行: %s, 管理员: %s", + user_info.get("user_sub", "未知"), + user_info.get("auto_execute", False), + user_info.get("is_admin", False), + ) + + except (httpx.HTTPError, httpx.InvalidURL) as e: + # 网络请求异常 + duration = time.time() - start_time + log_exception(self.logger, "Hermes 用户信息 API 请求异常", e) + log_api_request( + self.logger, + "GET", + f"{self.http_manager.base_url}/api/auth/user", + 500, + duration, + error=str(e), + ) + self.logger.warning("Hermes 用户信息 API 请求异常,返回 None") + return None + else: + return user_info + + async def update_auto_execute(self, *, auto_execute: bool) -> None: + """ + 更新用户自动执行设置 + + 通过调用 POST /api/user 接口更新当前用户的自动执行设置。 + + Args: + auto_execute: 是否启用自动执行 + + Returns: + bool: 更新是否成功 + + """ + start_time = time.time() + self.logger.info("开始请求 Hermes 用户设置更新 API - auto_execute: %s", auto_execute) + + try: + client = await self.http_manager.get_client() + user_url = urljoin(self.http_manager.base_url, "/api/user") + headers = self.http_manager.build_headers( + { + "Content-Type": "application/json", + }, + ) + + # 构建请求体 + request_data = { + "autoExecute": auto_execute, + } + + response = await client.post(user_url, headers=headers, json=request_data) + + duration = time.time() - start_time + log_api_request( + self.logger, + "POST", + user_url, + response.status_code, + duration, + ) + + # 处理HTTP错误状态 + if response.status_code != HTTP_OK: + error_msg = f"API 调用失败,状态码: {response.status_code}" + self.logger.warning("更新用户设置失败: %s", error_msg) + return + + self.logger.info("更新用户设置成功") + + except (httpx.HTTPError, httpx.InvalidURL) as e: + # 网络请求异常 + duration = time.time() - start_time + log_exception(self.logger, "Hermes 用户设置更新 API 请求异常", e) + log_api_request( + self.logger, + "POST", + f"{self.http_manager.base_url}/api/user", + 500, + duration, + error=str(e), + ) + self.logger.warning("Hermes 用户设置更新 API 请求异常") + return + + def _validate_user_response(self, data: dict[str, Any]) -> bool: + """验证用户信息 API 响应结构""" + if not isinstance(data, dict): + self.logger.warning("用户信息响应格式无效:不是字典") + return False + + # 检查基本响应结构 + code = int(data.get("code", 400)) + if code != HTTP_OK: + self.logger.warning("用户信息 API 返回错误代码: %s", code) + return False + + # 检查 result 字段 + result = data.get("result") + if not isinstance(result, dict): + self.logger.warning("用户信息 result 字段不是对象") + return False + + # 检查必要字段是否存在 + required_fields = ["user_sub", "auto_execute"] + for field in required_fields: + if field not in result: + self.logger.warning("用户信息缺少必要字段: %s", field) + return False + + return True -- Gitee From 0d18bb8b073ffdd64306d37178ec3d980a5a9fc6 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Wed, 10 Sep 2025 10:00:26 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(settings):=20=E6=B7=BB=E5=8A=A0=20MCP?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E6=8E=88=E6=9D=83=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/css/styles.tcss | 4 +- src/app/settings.py | 147 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index 43004c1..1b2fd80 100644 --- a/src/app/css/styles.tcss +++ b/src/app/css/styles.tcss @@ -96,16 +96,18 @@ SettingsScreen { } /* 设置值样式 */ -.settings-value { +.settings-input { width: 80%; content-align: left middle; } /* 设置按钮样式 */ .settings-button { + content-align: left middle; color: #4963b1; border: solid #4963b1; width: 40%; + margin-left: 1; } #save-btn, #cancel-btn { diff --git a/src/app/settings.py b/src/app/settings.py index d865323..58a4646 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -38,6 +38,10 @@ class SettingsScreen(ModalScreen): # 添加保存任务的集合 self.background_tasks: set[asyncio.Task] = set() + # MCP 工具授权相关状态 + self.auto_execute_status = False # 默认为手动确认 + self.mcp_status_loaded = False # 是否已成功加载状态 + # 验证相关状态 self.is_validated = False self.validation_message = "" @@ -58,7 +62,7 @@ class SettingsScreen(ModalScreen): Button( f"{self.backend.get_display_name()}", id="backend-btn", - classes="settings-value settings-button", + classes="settings-button", ), classes="settings-option", ), @@ -69,6 +73,7 @@ class SettingsScreen(ModalScreen): value=self.config_manager.get_base_url() if self.backend == Backend.OPENAI else self.config_manager.get_eulerintelli_url(), + classes="settings-input", id="base-url", ), classes="settings-option", @@ -80,6 +85,7 @@ class SettingsScreen(ModalScreen): value=self.config_manager.get_api_key() if self.backend == Backend.OPENAI else self.config_manager.get_eulerintelli_key(), + classes="settings-input", id="api-key", ), classes="settings-option", @@ -89,13 +95,25 @@ class SettingsScreen(ModalScreen): [ Horizontal( Label("模型:", classes="settings-label"), - Button(f"{self.selected_model}", id="model-btn", classes="settings-value settings-button"), + Button(f"{self.selected_model}", id="model-btn", classes="settings-button"), id="model-section", classes="settings-option", ), ] if self.backend == Backend.OPENAI - else [] + else [ + Horizontal( + Label("MCP 工具授权:", classes="settings-label"), + Button( + "自动执行" if self.auto_execute_status else "手动确认", + id="mcp-btn", + classes="settings-button", + disabled=not self.mcp_status_loaded, + ), + id="mcp-section", + classes="settings-option", + ), + ] ), # 添加一个空白区域,确保操作按钮始终可见 Static("", id="spacer"), @@ -118,6 +136,11 @@ class SettingsScreen(ModalScreen): # 保存任务引用 self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + else: # EULERINTELLI + task = asyncio.create_task(self.load_mcp_status()) + # 保存任务引用 + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) # 启动配置验证 self._schedule_validation() @@ -152,6 +175,33 @@ class SettingsScreen(ModalScreen): model_btn = self.query_one("#model-btn", Button) model_btn.label = "暂无可用模型" + async def load_mcp_status(self) -> None: + """异步加载 MCP 工具授权状态""" + try: + # 只有 EULERINTELLI 后端才支持 MCP 状态 + if self.backend != Backend.EULERINTELLI: + return + + # 从 Hermes 客户端获取自动执行状态 + if hasattr(self.llm_client, "get_auto_execute_status"): + self.auto_execute_status = await self.llm_client.get_auto_execute_status() # type: ignore[attr-defined] + self.mcp_status_loaded = True + else: + self.auto_execute_status = False + self.mcp_status_loaded = False + + # 更新 MCP 按钮文本和状态 + mcp_btn = self.query_one("#mcp-btn", Button) + mcp_btn.label = "自动执行" if self.auto_execute_status else "手动确认" + mcp_btn.disabled = not self.mcp_status_loaded + + except (OSError, ValueError, RuntimeError): + self.auto_execute_status = False + self.mcp_status_loaded = False + mcp_btn = self.query_one("#mcp-btn", Button) + mcp_btn.label = "手动确认" + mcp_btn.disabled = True + @on(Input.Changed, "#base-url, #api-key") def on_config_changed(self) -> None: """当 Base URL 或 API Key 改变时更新客户端并验证配置""" @@ -161,6 +211,12 @@ class SettingsScreen(ModalScreen): task = asyncio.create_task(self.load_models()) self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + else: # EULERINTELLI + self._update_llm_client() + # 重新加载 MCP 状态 + task = asyncio.create_task(self.load_mcp_status()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) # 重新验证配置 self._schedule_validation() @@ -187,13 +243,18 @@ class SettingsScreen(ModalScreen): # 创建新的 OpenAI 客户端 self._update_llm_client() + # 移除 MCP 工具授权部分 + mcp_section = self.query("#mcp-section") + if mcp_section: + mcp_section[0].remove() + # 添加模型选择部分 if not self.query("#model-section"): container = self.query_one("#settings-container") spacer = self.query_one("#spacer") model_section = Horizontal( Label("模型:", classes="settings-label"), - Button(self.selected_model, id="model-btn", classes="settings-value settings-button"), + Button(self.selected_model, id="model-btn", classes="settings-button"), id="model-section", classes="settings-option", ) @@ -221,6 +282,34 @@ class SettingsScreen(ModalScreen): if model_section: model_section[0].remove() + # 添加 MCP 工具授权部分 + if not self.query("#mcp-section"): + container = self.query_one("#settings-container") + spacer = self.query_one("#spacer") + mcp_section = Horizontal( + Label("MCP 工具授权:", classes="settings-label"), + Button( + "自动执行" if self.auto_execute_status else "手动确认", + id="mcp-btn", + classes="settings-button", + disabled=not self.mcp_status_loaded, + ), + id="mcp-section", + classes="settings-option", + ) + + # 在spacer前面添加mcp_section + if spacer: + container.mount(mcp_section, before=spacer) + else: + container.mount(mcp_section) + + # 重新加载 MCP 状态 + task = asyncio.create_task(self.load_mcp_status()) + # 保存任务引用 + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + # 确保按钮可见 self._ensure_buttons_visible() @@ -255,6 +344,17 @@ class SettingsScreen(ModalScreen): model_btn = self.query_one("#model-btn", Button) model_btn.label = self.selected_model + @on(Button.Pressed, "#mcp-btn") + def toggle_mcp_authorization(self) -> None: + """切换 MCP 工具授权模式""" + if not self.mcp_status_loaded: + return + + # 创建切换任务 + task = asyncio.create_task(self._toggle_mcp_authorization_async()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + @on(Button.Pressed, "#save-btn") def save_settings(self) -> None: """保存设置""" @@ -412,3 +512,42 @@ class SettingsScreen(ModalScreen): base_url=base_url_input.value, auth_token=api_key_input.value, ) + + async def _toggle_mcp_authorization_async(self) -> None: + """异步切换 MCP 工具授权模式""" + try: + # 检查客户端是否支持 MCP 操作 + if ( + not hasattr(self.llm_client, "enable_auto_execute") + or not hasattr(self.llm_client, "disable_auto_execute") + ): + return + + # 先禁用按钮防止重复点击 + mcp_btn = self.query_one("#mcp-btn", Button) + mcp_btn.disabled = True + mcp_btn.label = "切换中..." + + # 根据当前状态调用相应的方法 + if self.auto_execute_status: + # 当前是自动执行,切换为手动确认 + await self.llm_client.disable_auto_execute() # type: ignore[attr-defined] + else: + # 当前是手动确认,切换为自动执行 + await self.llm_client.enable_auto_execute() # type: ignore[attr-defined] + + # 重新获取状态以确保同步 + if hasattr(self.llm_client, "get_auto_execute_status"): + self.auto_execute_status = await self.llm_client.get_auto_execute_status() # type: ignore[attr-defined] + + # 更新按钮状态 + mcp_btn.label = "自动执行" if self.auto_execute_status else "手动确认" + mcp_btn.disabled = False + + except (OSError, ValueError, RuntimeError) as e: + # 发生错误时恢复按钮状态 + mcp_btn = self.query_one("#mcp-btn", Button) + mcp_btn.label = "自动执行" if self.auto_execute_status else "手动确认" + mcp_btn.disabled = False + # 可以考虑显示错误消息 + self.notify(f"切换 MCP 工具授权模式失败: {e!s}", severity="error") -- Gitee From 36b6158583b4410d199822b31aa137d562f74dc2 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Wed, 10 Sep 2025 11:42:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(agent):=20=E9=87=8D=E6=96=B0=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=99=BA=E8=83=BD=E4=BD=93=E5=88=97=E8=A1=A8=E5=B9=B6?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=AE=A1=E7=AE=97=E5=8F=AF=E8=A7=81=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=95=B0=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/css/styles.tcss | 1 + src/app/dialogs/agent.py | 154 +++++++++++++++++++++++++++++++-------- 2 files changed, 126 insertions(+), 29 deletions(-) diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index 1b2fd80..c89bbfa 100644 --- a/src/app/css/styles.tcss +++ b/src/app/css/styles.tcss @@ -180,6 +180,7 @@ AgentSelectionDialog { /* 智能体选择对话框样式 */ #agent-dialog { align: center middle; + min-height: 17; } /* 智能体对话框标题样式 */ diff --git a/src/app/dialogs/agent.py b/src/app/dialogs/agent.py index ac66a99..c000d27 100644 --- a/src/app/dialogs/agent.py +++ b/src/app/dialogs/agent.py @@ -55,10 +55,17 @@ class AgentSelectionDialog(ModalScreen): """ super().__init__() - self.agents = agents self.current_agent = current_agent or ("", "智能问答") self.callback = callback + # 重新排序智能体列表:智能问答永远第一,当前智能体(如果不是智能问答)排第二 + self.agents = self._reorder_agents(agents) + + # 滚动显示相关变量 + self.min_visible_items = 3 # 最少显示的智能体数量 + self.default_visible_items = 5 # 默认可见项目数量,会根据实际高度动态调整 + self.view_start = 0 # 当前显示区域的起始索引 + # 设置初始光标位置为当前已选中的智能体 self.selected_index = 0 for i, agent in enumerate(self.agents): @@ -66,33 +73,38 @@ class AgentSelectionDialog(ModalScreen): self.selected_index = i break - def compose(self) -> ComposeResult: - """构建智能体选择对话框""" - # 创建富文本内容,包含所有智能体选项 - agent_text_lines = [] - for i, (app_id, name) in enumerate(self.agents): - is_cursor = i == self.selected_index - is_current = app_id == self.current_agent[0] + def _calculate_visible_items(self) -> int: + """ + 根据对话框的实际显示高度动态计算可见项目数量 - if is_cursor and is_current: - # 光标在当前已选中的智能体上:绿底白字 + 勾选符号 - agent_text_lines.append(f"[white on green]► ✓ {name}[/white on green]") - elif is_cursor: - # 光标在其他智能体上:蓝底白字 - agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") - elif is_current: - # 当前已选中但光标不在这里:显示勾选符号 - agent_text_lines.append(f"[bright_green] ✓ {name}[/bright_green]") - else: - # 普通状态:亮白字 - agent_text_lines.append(f"[bright_white] {name}[/bright_white]") + Returns: + 可显示的智能体项目数量 - # 如果没有智能体,添加默认选项 - if not agent_text_lines: - agent_text_lines.append("[white on green]► ✓ 智能问答[/white on green]") + """ + try: + # 尝试获取对话框容器的高度 + dialog_container = self.query_one("#agent-dialog", Container) + container_height = dialog_container.size.height - # 使用Static组件显示文本,启用Rich markup - agent_content = Static("\n".join(agent_text_lines), markup=True, id="agent-content") + # 计算可用于显示智能体列表的高度 + # 减去标题、帮助文本等固定元素的高度 + title_height = 5 # 标题行 + help_height = 5 # 帮助文本行 + padding_height = 4 # 上下 padding + + available_height = container_height - title_height - help_height - padding_height + + # 每个智能体项目占用1行,确保至少显示最小数量 + return max(self.min_visible_items, min(available_height, len(self.agents))) + + except (AttributeError, ValueError, RuntimeError): + # 如果无法获取高度信息,使用默认值 + return self.default_visible_items + + def compose(self) -> ComposeResult: + """构建智能体选择对话框""" + # 使用 Static 组件显示文本,启用 Rich markup + agent_content = Static("", markup=True, id="agent-content") yield Container( Container( @@ -118,21 +130,39 @@ class AgentSelectionDialog(ModalScreen): self.app.pop_screen() elif event.key == "up" and self.selected_index > 0: self.selected_index -= 1 + self._adjust_view_to_selection() self._update_display() elif event.key == "down" and self.selected_index < len(self.agents) - 1: self.selected_index += 1 + self._adjust_view_to_selection() self._update_display() def on_mount(self) -> None: """挂载时设置初始显示""" + self._adjust_view_to_selection() + self._update_display() + + def on_resize(self) -> None: + """窗口大小变化时重新计算显示""" + # 重新调整视图位置和更新显示 + self._adjust_view_to_selection() self._update_display() def _update_display(self) -> None: """更新显示内容""" - # 重新生成文本内容 + # 动态计算当前可显示的项目数量 + current_visible_items = self._calculate_visible_items() + + # 计算可见区域的智能体 + visible_agents = self.agents[self.view_start : self.view_start + current_visible_items] + + # 生成文本内容 agent_text_lines = [] - for i, (app_id, name) in enumerate(self.agents): - is_cursor = i == self.selected_index + + # 显示可见的智能体 + for i, (app_id, name) in enumerate(visible_agents): + actual_index = self.view_start + i + is_cursor = actual_index == self.selected_index is_current = app_id == self.current_agent[0] if is_cursor and is_current: @@ -152,10 +182,76 @@ class AgentSelectionDialog(ModalScreen): if not agent_text_lines: agent_text_lines.append("[white on green]► ✓ 智能问答[/white on green]") - # 更新Static组件的内容 + # 更新 Static 组件的内容 try: agent_content = self.query_one("#agent-content", Static) agent_content.update("\n".join(agent_text_lines)) except (AttributeError, ValueError, RuntimeError): # 如果查找失败,忽略错误 pass + + def _reorder_agents(self, agents: list[tuple[str, str]]) -> list[tuple[str, str]]: + """ + 重新排序智能体列表 + + 规则: + 1. 智能问答永远排第一 + 2. 如果当前智能体不是智能问答,排第二 + 3. 其他智能体保持原有顺序 + """ + if not agents: + return [("", "智能问答")] + + # 查找智能问答和当前智能体 + default_qa = ("", "智能问答") + current_agent = self.current_agent + + reordered = [] + remaining = [] + + # 第一步:处理所有智能体,分类收集 + current_found = False + + for agent in agents: + if agent[0] == "": # 智能问答 + # 智能问答不加入 remaining,会单独处理 + pass + elif agent[0] == current_agent[0] and current_agent[0] != "": # 当前智能体且不是智能问答 + current_found = True + # 当前智能体不加入 remaining,会单独处理 + else: + remaining.append(agent) + + # 第一项:智能问答 + reordered.append(default_qa) + + # 第二项:如果当前智能体不是智能问答且存在,加入第二位 + if current_found and current_agent[0] != "": + reordered.append(current_agent) + + # 其余项:保持原有顺序 + reordered.extend(remaining) + + return reordered + + def _adjust_view_to_selection(self) -> None: + """调整视图位置,确保选中项在可见区域内""" + # 动态计算当前可显示的项目数量 + current_visible_items = self._calculate_visible_items() + + if len(self.agents) <= current_visible_items: + # 如果总数不超过可显示数量,显示全部 + self.view_start = 0 + return + + # 确保选中项在可见区域内 + if self.selected_index < self.view_start: + # 选中项在可见区域上方,向上滚动 + self.view_start = self.selected_index + elif self.selected_index >= self.view_start + current_visible_items: + # 选中项在可见区域下方,向下滚动 + self.view_start = self.selected_index - current_visible_items + 1 + + # 确保 view_start 不超出范围 + max_start = max(0, len(self.agents) - current_visible_items) + self.view_start = max(0, min(self.view_start, max_start)) -- Gitee