diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index 921d707a206dee2842de5a855bd785d1233c7100..2e4f45e8df8544efc73886efce3222e85cdbfeba 100644 --- a/src/app/css/styles.tcss +++ b/src/app/css/styles.tcss @@ -231,3 +231,62 @@ Static { color: #aaaaaa; text-style: italic; } + +/* MCP 组件样式 */ +#mcp-confirm, #mcp-parameter { + height: auto; + padding: 1; + border: solid #ff9800; + background: #1a1a1a; +} + +/* MCP 确认组件样式 */ +.confirm-title { + text-align: center; + text-style: bold; + color: #ff9800; + margin-bottom: 1; +} + +.confirm-buttons { + height: auto; + align: center middle; + margin-top: 1; +} + +.risk-low { + color: #4caf50; +} + +.risk-medium { + color: #ff9800; +} + +.risk-high { + color: #f44336; +} + +/* MCP 参数组件样式 */ +.param-title { + text-align: center; + text-style: bold; + color: #2196f3; + margin-bottom: 1; +} + +.param-message { + color: #ffeb3b; + margin-bottom: 1; +} + +.param-buttons { + height: auto; + align: center middle; + margin-top: 1; +} + +/* MCP 按钮样式 */ +#mcp-confirm-yes, #mcp-confirm-no, #mcp-param-submit, #mcp-param-cancel { + margin: 0 1; + width: auto; +} diff --git a/src/app/dialogs/agent.py b/src/app/dialogs/agent.py index 3c8f3f2d82a3ab638b83b03104c1d5e37d86bfec..d339b546e90b045a23957ce1030a8ab88b90fd3d 100644 --- a/src/app/dialogs/agent.py +++ b/src/app/dialogs/agent.py @@ -77,7 +77,7 @@ class AgentSelectionDialog(ModalScreen): 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]") + 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]") diff --git a/src/app/mcp_widgets.py b/src/app/mcp_widgets.py new file mode 100644 index 0000000000000000000000000000000000000000..aa5b7561d7c083e732df57b538b17e5ffa958a14 --- /dev/null +++ b/src/app/mcp_widgets.py @@ -0,0 +1,167 @@ +"""MCP 交互组件""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from textual import on +from textual.containers import Container, Horizontal, Vertical +from textual.message import Message +from textual.widgets import Button, Input, Label, Static + +if TYPE_CHECKING: + from textual.app import ComposeResult + + from backend.hermes.stream import HermesStreamEvent + + +class MCPConfirmWidget(Container): + """MCP 工具执行确认组件""" + + def __init__( + self, + event: HermesStreamEvent, + *, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + ) -> None: + """初始化确认组件""" + super().__init__(name=name, id=widget_id, classes=classes) + self.event = event + + def compose(self) -> ComposeResult: + """构建确认界面""" + step_name = self.event.get_step_name() + content = self.event.get_content() + risk = content.get("risk", "unknown") + reason = content.get("reason", "需要用户确认是否执行此工具") + + # 风险级别文本 + risk_text = { + "low": "低风险", + "medium": "中等风险", + "high": "高风险", + }.get(risk, "未知风险") + + with Vertical(): + yield Static("⚠️ 工具执行确认", classes="confirm-title") + yield Static(f"工具名称: {step_name}") + yield Static(f"风险级别: {risk_text}", classes=f"risk-{risk}") + yield Static(f"原因: {reason}") + yield Static("") + with Horizontal(classes="confirm-buttons"): + yield Button("确认执行 (Y)", variant="success", id="mcp-confirm-yes") + yield Button("取消 (N)", variant="error", id="mcp-confirm-no") + yield Static("请选择: Y(确认) / N(取消)") + + @on(Button.Pressed, "#mcp-confirm-yes") + def confirm_execution(self) -> None: + """确认执行""" + self.post_message(MCPConfirmResult(confirmed=True, task_id=self.event.get_task_id())) + + @on(Button.Pressed, "#mcp-confirm-no") + def cancel_execution(self) -> None: + """取消执行""" + self.post_message(MCPConfirmResult(confirmed=False, task_id=self.event.get_task_id())) + + +class MCPParameterWidget(Container): + """MCP 工具参数输入组件""" + + def __init__( + self, + event: HermesStreamEvent, + *, + name: str | None = None, + widget_id: str | None = None, + classes: str | None = None, + ) -> None: + """初始化参数输入组件""" + super().__init__(name=name, id=widget_id, classes=classes) + self.event = event + self.param_inputs: dict[str, Input] = {} + + def compose(self) -> ComposeResult: + """构建参数输入界面""" + step_name = self.event.get_step_name() + content = self.event.get_content() + message = content.get("message", "需要补充参数") + params = content.get("params", {}) + + with Vertical(): + yield Static("📝 参数补充", classes="param-title") + yield Static(f"工具名称: {step_name}") + yield Static(message, classes="param-message") + yield Static("") + + # 为每个需要填写的参数创建输入框 + for param_name, param_value in params.items(): + if param_value is None or param_value == "": + yield Label(f"{param_name}:") + param_input = Input( + placeholder=f"请输入 {param_name}", + id=f"param_{param_name}", + ) + self.param_inputs[param_name] = param_input + yield param_input + + # 额外信息输入框 + yield Label("补充说明(可选):") + description_input = Input( + placeholder="请输入补充说明信息", + id="param_description", + ) + self.param_inputs["description"] = description_input + yield description_input + + with Horizontal(classes="param-buttons"): + yield Button("提交", variant="success", id="mcp-param-submit") + yield Button("取消", variant="error", id="mcp-param-cancel") + + @on(Button.Pressed, "#mcp-param-submit") + def submit_parameters(self) -> None: + """提交参数""" + # 收集用户输入的参数 + content_params = {} + description = "" + + for param_name, input_widget in self.param_inputs.items(): + value = input_widget.value.strip() + if param_name == "description": + description = value + elif value: + content_params[param_name] = value + + # 构建参数结构 + params = { + "content": content_params, + "description": description, + } + + self.post_message(MCPParameterResult(params=params, task_id=self.event.get_task_id())) + + @on(Button.Pressed, "#mcp-param-cancel") + def cancel_parameters(self) -> None: + """取消参数输入""" + self.post_message(MCPParameterResult(params=None, task_id=self.event.get_task_id())) + + +class MCPConfirmResult(Message): + """MCP 确认结果消息""" + + def __init__(self, *, confirmed: bool, task_id: str) -> None: + """初始化确认结果""" + super().__init__() + self.confirmed = confirmed + self.task_id = task_id + + +class MCPParameterResult(Message): + """MCP 参数结果消息""" + + def __init__(self, *, params: dict | None, task_id: str) -> None: + """初始化参数结果""" + super().__init__() + self.params = params + self.task_id = task_id diff --git a/src/app/tui.py b/src/app/tui.py index 668c2b2627b8fc26a4debb35b216c937a118afd0..29bb079e25d97164a67d62daf0bf2ca4c10f8f76 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -10,11 +10,14 @@ from textual import on from textual.app import App, ComposeResult from textual.binding import Binding, BindingType from textual.containers import Container +from textual.message import Message from textual.widgets import Footer, Header, Input, Static from app.dialogs import AgentSelectionDialog, BackendRequiredDialog, ExitDialog +from app.mcp_widgets import MCPConfirmResult, MCPConfirmWidget, MCPParameterResult, MCPParameterWidget from app.settings import SettingsScreen from backend.factory import BackendFactory +from backend.hermes import HermesChatClient from config import ConfigManager from log.manager import get_logger, log_exception from tool.command_processor import process_command @@ -169,6 +172,22 @@ class IntelligentTerminal(App): Binding(key="tab", action="toggle_focus", description="切换焦点"), ] + class SwitchToMCPConfirm(Message): + """切换到 MCP 确认界面的消息""" + + def __init__(self, event) -> None: # noqa: ANN001 + """初始化消息""" + super().__init__() + self.event = event + + class SwitchToMCPParameter(Message): + """切换到 MCP 参数输入界面的消息""" + + def __init__(self, event) -> None: # noqa: ANN001 + """初始化消息""" + super().__init__() + self.event = event + def __init__(self) -> None: """初始化应用""" super().__init__() @@ -182,6 +201,9 @@ class IntelligentTerminal(App): self._llm_client: LLMClientBase | None = None # 当前选择的智能体 self.current_agent: tuple[str, str] = ("", "智能问答") + # MCP 状态 + self._mcp_mode: str = "normal" # "normal", "confirm", "parameter" + self._current_mcp_task_id: str = "" # 创建日志实例 self.logger = get_logger(__name__) @@ -195,7 +217,7 @@ class IntelligentTerminal(App): def action_settings(self) -> None: """打开设置页面""" - self.push_screen(SettingsScreen(self.config_manager, self._get_llm_client())) + self.push_screen(SettingsScreen(self.config_manager, self.get_llm_client())) def action_request_quit(self) -> None: """请求退出应用""" @@ -212,7 +234,7 @@ class IntelligentTerminal(App): def action_choose_agent(self) -> None: """选择智能体的动作""" # 获取 Hermes 客户端 - llm_client = self._get_llm_client() + llm_client = self.get_llm_client() # 检查客户端类型 if not hasattr(llm_client, "get_available_agents"): @@ -241,6 +263,20 @@ class IntelligentTerminal(App): """初始化完成时设置焦点和绑定""" self.query_one(CommandInput).focus() + def get_llm_client(self) -> LLMClientBase: + """获取大模型客户端,使用单例模式维持对话历史""" + if self._llm_client is None: + self._llm_client = BackendFactory.create_client(self.config_manager) + + # 为 Hermes 客户端设置 MCP 事件处理器以支持 MCP 交互 + if isinstance(self._llm_client, HermesChatClient): + from app.tui_mcp_handler import TUIMCPEventHandler + + mcp_handler = TUIMCPEventHandler(self, self._llm_client) + self._llm_client.set_mcp_handler(mcp_handler) + + return self._llm_client + def refresh_llm_client(self) -> None: """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" self._llm_client = BackendFactory.create_client(self.config_manager) @@ -285,6 +321,39 @@ class IntelligentTerminal(App): # 添加完成回调,自动从集合中移除 task.add_done_callback(self._task_done_callback) + @on(SwitchToMCPConfirm) + def handle_switch_to_mcp_confirm(self, message: SwitchToMCPConfirm) -> None: + """处理切换到 MCP 确认界面的消息""" + self._mcp_mode = "confirm" + self._current_mcp_task_id = message.event.get_task_id() + self._replace_input_with_mcp_widget(MCPConfirmWidget(message.event, widget_id="mcp-confirm")) + + @on(SwitchToMCPParameter) + def handle_switch_to_mcp_parameter(self, message: SwitchToMCPParameter) -> None: + """处理切换到 MCP 参数输入界面的消息""" + self._mcp_mode = "parameter" + self._current_mcp_task_id = message.event.get_task_id() + self._replace_input_with_mcp_widget(MCPParameterWidget(message.event, widget_id="mcp-parameter")) + + @on(MCPConfirmResult) + def handle_mcp_confirm_result(self, message: MCPConfirmResult) -> None: + """处理 MCP 确认结果""" + if message.task_id == self._current_mcp_task_id: + # 发送 MCP 响应并处理结果 + task = asyncio.create_task(self._send_mcp_response(message.task_id, message.confirmed)) + self.background_tasks.add(task) + task.add_done_callback(self._task_done_callback) + + @on(MCPParameterResult) + def handle_mcp_parameter_result(self, message: MCPParameterResult) -> None: + """处理 MCP 参数结果""" + if message.task_id == self._current_mcp_task_id: + # 发送 MCP 响应并处理结果 + params = message.params if message.params is not None else False + task = asyncio.create_task(self._send_mcp_response(message.task_id, params)) + self.background_tasks.add(task) + task.add_done_callback(self._task_done_callback) + def _task_done_callback(self, task: asyncio.Task) -> None: """任务完成回调,从任务集合中移除""" if task in self.background_tasks: @@ -348,7 +417,7 @@ class IntelligentTerminal(App): try: # 通过 process_command 获取命令处理结果和输出类型 - async for output_tuple in process_command(user_input, self._get_llm_client()): + async for output_tuple in process_command(user_input, self.get_llm_client()): content, is_llm_output = output_tuple # 解包输出内容和类型标志 received_any_content = True @@ -447,12 +516,6 @@ class IntelligentTerminal(App): # 等待一个小的延迟,确保UI有时间更新 await asyncio.sleep(0.01) - def _get_llm_client(self) -> LLMClientBase: - """获取大模型客户端,使用单例模式维持对话历史""" - if self._llm_client is None: - self._llm_client = BackendFactory.create_client(self.config_manager) - return self._llm_client - async def _cleanup_llm_client(self) -> None: """异步清理 LLM 客户端""" if self._llm_client is not None: @@ -476,7 +539,7 @@ class IntelligentTerminal(App): async def _show_agent_selection(self) -> None: """显示智能体选择对话框""" try: - llm_client = self._get_llm_client() + llm_client = self.get_llm_client() # 构建智能体列表 - 默认第一项为"智能问答"(无智能体) agent_list = [("", "智能问答")] @@ -506,7 +569,7 @@ class IntelligentTerminal(App): # 即使出错也显示默认选项 agent_list = [("", "智能问答")] try: - llm_client = self._get_llm_client() + llm_client = self.get_llm_client() await self._display_agent_dialog(agent_list, llm_client) except (OSError, ValueError, RuntimeError, AttributeError): self.logger.exception("无法显示智能体选择对话框") @@ -525,3 +588,131 @@ class IntelligentTerminal(App): dialog = AgentSelectionDialog(agent_list, on_agent_selected, self.current_agent) self.push_screen(dialog) + + def _replace_input_with_mcp_widget(self, widget) -> None: # noqa: ANN001 + """替换输入容器中的组件为 MCP 交互组件""" + try: + input_container = self.query_one("#input-container") + # 移除所有子组件 + input_container.remove_children() + # 添加新的 MCP 组件 + input_container.mount(widget) + # 聚焦到新组件 + widget.focus() + except Exception: + self.logger.exception("替换输入组件失败") + + def _restore_normal_input(self) -> None: + """恢复正常的命令输入组件""" + try: + input_container = self.query_one("#input-container") + # 移除所有子组件 + input_container.remove_children() + # 添加正常的命令输入组件 + input_container.mount(CommandInput()) + # 聚焦到输入框 + self.query_one(CommandInput).focus() + # 重置 MCP 状态 + self._mcp_mode = "normal" + self._current_mcp_task_id = "" + except Exception: + self.logger.exception("恢复正常输入组件失败") + + async def _send_mcp_response(self, task_id: str, params: bool | dict) -> None: + """发送 MCP 响应并处理结果""" + try: + # 恢复正常输入界面 + self._restore_normal_input() + + # 获取输出容器 + output_container = self.query_one("#output-container") + + # 发送 MCP 响应并处理流式回复 + llm_client = self.get_llm_client() + if hasattr(llm_client, "send_mcp_response"): + success = await self._handle_mcp_response_stream( + task_id, + params, + output_container, + llm_client, # type: ignore[arg-type] + ) + if not success: + # 如果没有收到任何响应内容,显示默认消息 + output_container.mount(OutputLine("💡 MCP 响应已发送")) + else: + self.logger.error("当前客户端不支持 MCP 响应功能") + output_container.mount(OutputLine("❌ 当前客户端不支持 MCP 响应功能")) + + except Exception as e: + self.logger.exception("发送 MCP 响应失败") + # 确保恢复正常界面 + self._restore_normal_input() + # 显示错误信息 + output_container = self.query_one("#output-container") + error_message = self._format_error_message(e) + output_container.mount(OutputLine(f"❌ 发送 MCP 响应失败: {error_message}")) + finally: + self.processing = False + + async def _handle_mcp_response_stream( + self, + task_id: str, + params: bool | dict, + output_container, # noqa: ANN001 + llm_client, # noqa: ANN001 + ) -> bool: + """处理 MCP 响应的流式回复""" + current_line: OutputLine | MarkdownOutputLine | None = None + current_content = "" + is_first_content = True + received_any_content = False + timeout_seconds = 60.0 + + try: + # 使用 asyncio.wait_for 包装整个流处理过程 + async def _process_stream() -> bool: + nonlocal current_line, current_content, is_first_content, received_any_content + + async for content in llm_client.send_mcp_response(task_id, params): + if not content.strip(): + continue + + received_any_content = True + + # 判断是否为 LLM 输出内容 + is_llm_output = not content.startswith((">", "❌", "⚠️", "💡")) + + # 更新累积内容 + current_content += content + + # 处理内容块 + params_obj = ContentChunkParams( + content=content, + is_llm_output=is_llm_output, + current_content=current_content, + is_first_content=is_first_content, + ) + current_line = await self._process_content_chunk( + params_obj, + current_line, + output_container, + ) + + # 第一段内容后设置标记 + if is_first_content: + is_first_content = False + + # 滚动到末尾 + await self._scroll_to_end() + + return received_any_content + + # 执行流处理,添加超时 + return await asyncio.wait_for(_process_stream(), timeout=timeout_seconds) + + except asyncio.TimeoutError: + output_container.mount(OutputLine(f"⏱️ MCP 响应超时 ({timeout_seconds}秒)")) + return received_any_content + except asyncio.CancelledError: + output_container.mount(OutputLine("🚫 MCP 响应被取消")) + raise diff --git a/src/app/tui_mcp_handler.py b/src/app/tui_mcp_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..b0acbe93cb509aaa27ac592798429d585ef98e5c --- /dev/null +++ b/src/app/tui_mcp_handler.py @@ -0,0 +1,57 @@ +"""TUI 应用的 MCP 事件处理器实现""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from backend.mcp_handler import MCPEventHandler +from log.manager import get_logger + +if TYPE_CHECKING: + from app.tui import IntelligentTerminal + from backend.hermes import HermesChatClient, HermesStreamEvent + + +class TUIMCPEventHandler(MCPEventHandler): + """TUI 应用的 MCP 事件处理器实现""" + + def __init__(self, tui_app: IntelligentTerminal, hermes_client: HermesChatClient) -> None: + """ + 初始化 TUI MCP 事件处理器 + + Args: + tui_app: TUI 应用实例 + hermes_client: Hermes 客户端实例 + + """ + self.tui_app = tui_app + self.hermes_client = hermes_client + self.logger = get_logger(__name__) + + async def handle_waiting_for_start(self, event: HermesStreamEvent) -> None: + """ + 处理等待用户确认执行的事件 + + Args: + event: MCP 事件对象 + + """ + try: + # 通知 TUI 切换到确认界面 + self.tui_app.post_message(self.tui_app.SwitchToMCPConfirm(event)) + except Exception: + self.logger.exception("处理用户确认请求时发生错误") + + async def handle_waiting_for_param(self, event: HermesStreamEvent) -> None: + """ + 处理等待用户输入参数的事件 + + Args: + event: MCP 事件对象 + + """ + try: + # 通知 TUI 切换到参数输入界面 + self.tui_app.post_message(self.tui_app.SwitchToMCPParameter(event)) + except Exception: + self.logger.exception("处理用户参数输入请求时发生错误") diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index 1b5fa34ab5b985997a3dbaddb72884c29d2e13e7..911e2325a629f200379322dd776070a7eafeff32 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -22,11 +22,13 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator from types import TracebackType + from backend.mcp_handler import MCPEventHandler + from .models import HermesAgent, HermesChatRequest from .services.agent import HermesAgentManager from .services.conversation import HermesConversationManager from .services.model import HermesModelManager - from .stream import HermesStreamProcessor + from .stream import HermesStreamEvent, HermesStreamProcessor def validate_url(url: str) -> bool: @@ -62,6 +64,9 @@ class HermesChatClient(LLMClientBase): # 当前选择的智能体ID self._current_agent_id: str = "" + # MCP 事件处理器(可选) + self._mcp_handler: MCPEventHandler | None = None + self.logger.info("Hermes 客户端初始化成功 - URL: %s", base_url) @property @@ -69,6 +74,7 @@ class HermesChatClient(LLMClientBase): """获取模型管理器(延迟初始化)""" if self._model_manager is None: from .services.model import HermesModelManager + self._model_manager = HermesModelManager(self.http_manager) return self._model_manager @@ -77,6 +83,7 @@ class HermesChatClient(LLMClientBase): """获取智能体管理器(延迟初始化)""" if self._agent_manager is None: from .services.agent import HermesAgentManager + self._agent_manager = HermesAgentManager(self.http_manager) return self._agent_manager @@ -85,6 +92,7 @@ class HermesChatClient(LLMClientBase): """获取会话管理器(延迟初始化)""" if self._conversation_manager is None: from .services.conversation import HermesConversationManager + self._conversation_manager = HermesConversationManager(self.http_manager) return self._conversation_manager @@ -93,9 +101,14 @@ class HermesChatClient(LLMClientBase): """获取流处理器(延迟初始化)""" if self._stream_processor is None: from .stream import HermesStreamProcessor + self._stream_processor = HermesStreamProcessor() return self._stream_processor + def set_mcp_handler(self, handler: MCPEventHandler | None) -> None: + """设置 MCP 事件处理器""" + self._mcp_handler = handler + def set_current_agent(self, agent_id: str) -> None: """ 设置当前使用的智能体 @@ -152,6 +165,7 @@ class HermesChatClient(LLMClientBase): # 创建聊天请求 from .models import HermesApp, HermesChatRequest, HermesFeatures + app = HermesApp(self._current_agent_id) request = HermesChatRequest( app=app, @@ -197,6 +211,58 @@ class HermesChatClient(LLMClientBase): """ return await self.agent_manager.get_available_agents() + async def send_mcp_response(self, task_id: str, params: bool | dict) -> AsyncGenerator[str, None]: + """ + 发送 MCP 响应并获取流式回复 + + Args: + task_id: 任务ID + params: 响应参数(bool 表示确认/取消,dict 表示参数补全) + + Yields: + str: 流式响应的文本内容 + + Raises: + HermesAPIError: 当 API 调用失败时 + + """ + self.logger.info("发送 MCP 响应 - 任务ID: %s", task_id) + start_time = time.time() + + try: + # 构建 MCP 响应请求 + client = await self.http_manager.get_client() + chat_url = urljoin(self.http_manager.base_url, "/api/chat") + headers = self.http_manager.build_headers() + + request_data = { + "taskId": task_id, + "params": params, + } + + self.logger.info("准备发送 MCP 响应请求 - URL: %s, 任务ID: %s", chat_url, task_id) + self.logger.debug("请求头: %s", headers) + self.logger.debug("请求内容: %s", request_data) + + async with client.stream( + "POST", + chat_url, + json=request_data, + headers=headers, + ) as response: + self.logger.info("收到 MCP 响应 - 状态码: %d", response.status_code) + await self._validate_chat_response(response) + async for text in self._process_stream_events(response): + yield text + + duration = time.time() - start_time + self.logger.info("MCP 响应请求完成 - 耗时: %.3fs", duration) + + except Exception as e: + duration = time.time() - start_time + log_exception(self.logger, "MCP 响应请求失败", e) + raise + async def close(self) -> None: """关闭 HTTP 客户端""" # 如果有未完成的会话,先停止它 @@ -265,6 +331,7 @@ class HermesChatClient(LLMClientBase): has_content = False event_count = 0 + has_error_message = False # 标记是否已经产生错误消息 self.logger.info("开始处理流式响应事件") @@ -287,16 +354,18 @@ class HermesChatClient(LLMClientBase): should_break, break_message = self.stream_processor.handle_special_events(event) if should_break: if break_message: + has_error_message = True # 标记已产生错误消息 yield break_message break - # 处理文本内容 - text_content = event.get_text_content() - if text_content: + # 处理各种事件内容 + content_yielded = False + async for content in self._handle_event_content(event): has_content = True - self.stream_processor.log_text_content(text_content) - yield text_content - else: + content_yielded = True + yield content + + if not content_yielded: self.logger.info("事件无文本内容") self.logger.info("流式响应处理完成 - 事件数量: %d, 有内容: %s", event_count, has_content) @@ -305,10 +374,41 @@ class HermesChatClient(LLMClientBase): self.logger.exception("处理流式响应事件时出错") raise - # 处理无内容的情况 - if not has_content: + # 只有在没有内容且没有错误消息的情况下才显示无内容消息 + if not has_content and not has_error_message: yield self.stream_processor.get_no_content_message(event_count) + async def _handle_event_content(self, event: HermesStreamEvent) -> AsyncGenerator[str, None]: + """处理单个事件的内容""" + # 处理 MCP 状态信息 + mcp_status = self.stream_processor.format_mcp_status(event) + if mcp_status: + yield mcp_status + + # 处理 MCP 交互事件 + if event.event_type == "step.waiting_for_start": + # 通知 TUI 切换到确认界面 + if self._mcp_handler is not None: + await self._mcp_handler.handle_waiting_for_start(event) + content = event.get_content() + step_name = event.get_step_name() + reason = content.get("reason", "需要用户确认") + yield f"⏸️ 等待用户确认执行工具 '{step_name}': {reason}" + elif event.event_type == "step.waiting_for_param": + # 通知 TUI 切换到参数输入界面 + if self._mcp_handler is not None: + await self._mcp_handler.handle_waiting_for_param(event) + content = event.get_content() + step_name = event.get_step_name() + message = content.get("message", "需要补充参数") + yield f"📝 等待用户输入参数 - 工具 '{step_name}': {message}" + + # 处理文本内容 + text_content = event.get_text_content() + if text_content: + self.stream_processor.log_text_content(text_content) + yield text_content + async def _stop(self) -> None: """停止当前会话""" if self._conversation_manager is not None: diff --git a/src/backend/hermes/models.py b/src/backend/hermes/models.py index ba44eb770d82f31d3db0f2a5bff6b6ad4e73cdb2..c7b891915f52cdd78c481ec941f0e90643117ca1 100644 --- a/src/backend/hermes/models.py +++ b/src/backend/hermes/models.py @@ -61,7 +61,7 @@ class HermesMessage: class HermesFeatures: """Hermes 功能特性配置""" - def __init__(self, max_tokens: int = 2048, context_num: int = 2) -> None: + def __init__(self, max_tokens: int = 8192, context_num: int = 10) -> None: """初始化功能特性配置""" self.max_tokens = max_tokens self.context_num = context_num diff --git a/src/backend/hermes/services/agent.py b/src/backend/hermes/services/agent.py index 29c213c393203bbdcf3a9ba502da8a2ed401bf09..a81751fcacdd8345f750ffb9344df895951b059e 100644 --- a/src/backend/hermes/services/agent.py +++ b/src/backend/hermes/services/agent.py @@ -127,7 +127,6 @@ class HermesAgentManager: # 构建查询参数 params = { - "appType": "agent", # 只获取智能体类型的应用 "page": page, # 当前页码 } @@ -169,20 +168,13 @@ class HermesAgentManager: self.logger.warning("第 %d 页响应格式无效:不是字典", page) return False - # 检查响应码 - code = data.get("code") - if code != 0: - message = data.get("message", "未知错误") - self.logger.warning("第 %d 页 API 返回错误: code=%s, message=%s", page, code, message) - return False - - # 检查result字段 + # 检查 result 字段 result = data.get("result") if not isinstance(result, dict): self.logger.warning("第 %d 页 result 字段不是对象", page) return False - # 检查applications字段 + # 检查 applications 字段 applications = result.get("applications") if not isinstance(applications, list): self.logger.warning("第 %d 页 applications 字段不是数组", page) @@ -204,11 +196,6 @@ class HermesAgentManager: if not isinstance(app_data, dict): continue - # 只处理Agent类型的应用 - app_type = app_data.get("appType") - if app_type != "agent": - continue - try: agent = HermesAgent.from_dict(app_data) if agent.app_id and agent.name: # 确保必要字段存在 diff --git a/src/backend/hermes/stream.py b/src/backend/hermes/stream.py index ffd04f0a149687b3d0c1d0b6ed8646ff5f65df9d..60e81c42485a87dd8f86b771d889812d58aa4f09 100644 --- a/src/backend/hermes/stream.py +++ b/src/backend/hermes/stream.py @@ -57,6 +57,56 @@ class HermesStreamEvent: return content["text"] return None + def get_flow_info(self) -> dict[str, Any]: + """获取流信息""" + return self.data.get("flow", {}) + + def get_step_name(self) -> str: + """获取步骤名称""" + flow = self.get_flow_info() + return flow.get("stepName", "") + + def get_step_id(self) -> str: + """获取步骤ID""" + flow = self.get_flow_info() + return flow.get("stepId", "") + + def get_conversation_id(self) -> str: + """获取会话ID""" + return self.data.get("conversationId", "") + + def get_task_id(self) -> str: + """获取任务ID""" + return self.data.get("taskId", "") + + def get_content(self) -> dict[str, Any]: + """获取内容部分""" + return self.data.get("content", {}) + + def is_mcp_step_event(self) -> bool: + """判断是否为 MCP 步骤相关事件""" + step_events = { + "step.init", + "step.input", + "step.output", + "step.cancel", + "step.error", + "step.waiting_for_start", + "step.waiting_for_param", + } + return self.event_type in step_events + + def is_flow_event(self) -> bool: + """判断是否为流相关事件""" + flow_events = { + "flow.start", + "flow.stop", + "flow.failed", + "flow.success", + "flow.cancel", + } + return self.event_type in flow_events + class HermesStreamProcessor: """Hermes 流响应处理器""" @@ -73,11 +123,11 @@ class HermesStreamProcessor: if event.event_type == "error": self.logger.error("收到后端错误事件: %s", event.data.get("error", "Unknown error")) - return True, "抱歉,后端服务出现错误,请稍后重试。" + return True, "后端服务出现错误,请稍后重试。" if event.event_type == "sensitive": self.logger.warning("收到敏感内容事件: %s", event.data.get("message", "Sensitive content detected")) - return True, "抱歉,响应内容包含敏感信息,已被系统屏蔽。" + return True, "响应内容包含敏感信息,已被系统屏蔽。" return False, None @@ -93,4 +143,28 @@ class HermesStreamProcessor: "流式响应完成但未产生任何文本内容 - 事件总数: %d", event_count, ) - return "抱歉,服务暂时无法响应您的请求,请稍后重试。" + return "服务暂时无法响应,请稍后重试。" + + def format_mcp_status(self, event: HermesStreamEvent) -> str | None: + """格式化 MCP 状态信息为可读文本""" + if not event.is_mcp_step_event() and not event.is_flow_event(): + return None + + step_name = event.get_step_name() + event_type = event.event_type + + # 定义事件类型到状态消息的映射 + status_messages = { + "step.init": f"🔧 正在初始化工具: {step_name}", + "step.input": f"📥 工具 {step_name} 正在执行...", + "step.output": f"✅ 工具 {step_name} 执行完成", + "step.cancel": f"❌ 工具 {step_name} 已取消", + "step.error": f"⚠️ 工具 {step_name} 执行失败", + "flow.start": "🚀 开始执行工作流", + "flow.stop": "⏸️ 工作流已暂停,等待用户操作", + "flow.success": "🎉 工作流执行成功", + "flow.failed": "💥 工作流执行失败", + "flow.cancel": "🛑 工作流已取消", + } + + return status_messages.get(event_type) diff --git a/src/backend/mcp_handler.py b/src/backend/mcp_handler.py new file mode 100644 index 0000000000000000000000000000000000000000..b10b491289d2088ee5818ea4b7ed85c361a87911 --- /dev/null +++ b/src/backend/mcp_handler.py @@ -0,0 +1,33 @@ +"""MCP 事件处理器接口""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backend.hermes.stream import HermesStreamEvent + + +class MCPEventHandler(ABC): + """MCP 事件处理器接口""" + + @abstractmethod + async def handle_waiting_for_start(self, event: HermesStreamEvent) -> None: + """ + 处理等待用户确认执行的事件 + + Args: + event: MCP 事件对象 + + """ + + @abstractmethod + async def handle_waiting_for_param(self, event: HermesStreamEvent) -> None: + """ + 处理等待用户输入参数的事件 + + Args: + event: MCP 事件对象 + + """ diff --git a/src/config/manager.py b/src/config/manager.py index 05acf7c40232c7702c294295727ff1b56c538e2b..f093ea845517184471038e8c83d85f6ac727989e 100644 --- a/src/config/manager.py +++ b/src/config/manager.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from config.model import Backend, ConfigModel +from config.model import Backend, ConfigModel, LogLevel class ConfigManager: @@ -74,6 +74,15 @@ class ConfigManager: self.data.eulerintelli.api_key = key self._save_settings() + def get_log_level(self) -> LogLevel: + """获取当前日志级别""" + return self.data.log_level + + def set_log_level(self, level: LogLevel) -> None: + """更新日志级别并保存""" + self.data.log_level = level + self._save_settings() + def _load_settings(self) -> None: """从文件载入设置""" if self.config_path.exists(): diff --git a/src/config/model.py b/src/config/model.py index 84273314259e336ff8f1bae12424c76aa2426257..158599d3891fe7122caa02aad77f6a8a8dfe58a0 100644 --- a/src/config/model.py +++ b/src/config/model.py @@ -11,6 +11,15 @@ class Backend(str, Enum): EULERINTELLI = "eulerintelli" +class LogLevel(str, Enum): + """日志级别""" + + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + + @dataclass class OpenAIConfig: """OpenAI 后端配置""" @@ -60,6 +69,7 @@ class ConfigModel: backend: Backend = field(default=Backend.OPENAI) openai: OpenAIConfig = field(default_factory=OpenAIConfig) eulerintelli: HermesConfig = field(default_factory=HermesConfig) + log_level: LogLevel = field(default=LogLevel.INFO) @classmethod def from_dict(cls, d: dict) -> "ConfigModel": @@ -73,10 +83,23 @@ class ConfigModel: else: backend = Backend.OPENAI + log_level_value = d.get("log_level", LogLevel.INFO) + # 确保 log_level 始终是 LogLevel 枚举类型 + if isinstance(log_level_value, LogLevel): + log_level = log_level_value + elif isinstance(log_level_value, str): + try: + log_level = LogLevel(log_level_value) + except ValueError: + log_level = LogLevel.INFO + else: + log_level = LogLevel.INFO + return cls( backend=backend, openai=OpenAIConfig.from_dict(d.get("openai", {})), eulerintelli=HermesConfig.from_dict(d.get("eulerintelli", {})), + log_level=log_level, ) def to_dict(self) -> dict: @@ -85,4 +108,5 @@ class ConfigModel: "backend": self.backend.value, # 保存枚举的值 "openai": self.openai.to_dict(), "eulerintelli": self.eulerintelli.to_dict(), + "log_level": self.log_level.value, } diff --git a/src/log/manager.py b/src/log/manager.py index 9b09e1a5ee82d79010689f255f849e1a955fc2ad..09ba5a330f71090ec837f463d4d58c631b40bd9c 100644 --- a/src/log/manager.py +++ b/src/log/manager.py @@ -6,17 +6,21 @@ import contextlib import logging from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from config.manager import ConfigManager class LogManager: """日志管理器""" - def __init__(self) -> None: + def __init__(self, config_manager: ConfigManager | None = None) -> None: """初始化日志管理器""" self._log_dir = Path.home() / ".cache" / "openEuler Intelligence" / "logs" self._log_dir.mkdir(parents=True, exist_ok=True) self._current_log_file: Path | None = None + self._config_manager = config_manager self._setup_logging() self._cleanup_old_logs() @@ -107,11 +111,24 @@ class LogManager: log_filename = f"smart-shell-{current_time.strftime('%Y%m%d-%H%M%S')}.log" self._current_log_file = self._log_dir / log_filename + # 从配置中获取日志级别 + log_level = logging.INFO # 默认级别 + if self._config_manager is not None: + try: + config_log_level = self._config_manager.get_log_level() + log_level = getattr(logging, config_log_level.value) + except (AttributeError, ValueError, TypeError) as e: + # 如果配置管理器不可用或配置有误,使用默认级别 + # 在这里我们还不能使用 logger,因为 logging 还没完全设置好 + import sys + sys.stderr.write(f"警告: 获取日志级别配置失败: {e}, 使用默认级别 INFO\n") + log_level = logging.INFO + # 配置根日志记录器 handlers = [logging.FileHandler(self._current_log_file, encoding="utf-8")] logging.basicConfig( - level=logging.INFO, + level=log_level, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=handlers, ) @@ -165,10 +182,10 @@ class _LogManagerSingleton: def __init__(self) -> None: self._instance: LogManager | None = None - def get_instance(self) -> LogManager: + def get_instance(self, config_manager: ConfigManager | None = None) -> LogManager: """获取日志管理器实例""" if self._instance is None: - self._instance = LogManager() + self._instance = LogManager(config_manager) return self._instance @@ -176,9 +193,9 @@ class _LogManagerSingleton: _singleton = _LogManagerSingleton() -def setup_logging() -> None: +def setup_logging(config_manager: ConfigManager | None = None) -> None: """初始化日志系统""" - _singleton.get_instance() + _singleton.get_instance(config_manager) def get_logger(name: str) -> logging.Logger: diff --git a/src/main.py b/src/main.py index fe247f0abdc5c6686bc5a97a82e15a0fbfc4ea9c..53f245fb9712bbd1931907175595d73806da1c9e 100644 --- a/src/main.py +++ b/src/main.py @@ -5,6 +5,7 @@ import atexit import sys from app.tui import IntelligentTerminal +from config.manager import ConfigManager from log.manager import ( cleanup_empty_logs, disable_console_output, @@ -29,13 +30,23 @@ def parse_args() -> argparse.Namespace: action="store_true", help="初始化 openEuler Intelligence 后端(仅支持 openEuler 操作系统)", ) + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR"], + help="设置日志级别", + ) + + # 注册清理函数,确保在程序异常退出时也能清理空日志文件 + atexit.register(cleanup_empty_logs) + return parser.parse_args() def show_logs() -> None: """显示最新的日志内容""" - # 初始化日志系统以确保日志管理器可用 - setup_logging() + # 初始化配置和日志系统 + config_manager = ConfigManager() + setup_logging(config_manager) # 显示日志时启用控制台输出 enable_console_output() @@ -61,16 +72,38 @@ def main() -> None: oi_backend_init() return - # 初始化日志系统 - setup_logging() + # 初始化配置和日志系统 + config_manager = ConfigManager() + + # 处理命令行参数设置的日志级别 + if args.log_level: + from config.model import LogLevel + if args.log_level not in LogLevel.__members__: + sys.stderr.write(f"无效的日志级别: {args.log_level}\n") + sys.exit(1) + config_manager.set_log_level(LogLevel(args.log_level)) + + # 初始化日志系统并验证设置 + setup_logging(config_manager) + enable_console_output() # 启用控制台输出以显示验证信息 + + logger = get_logger(__name__) + logger.info("日志级别已设置为: %s", args.log_level) + logger.debug("这是一条 DEBUG 级别的测试消息") + logger.info("这是一条 INFO 级别的测试消息") + logger.warning("这是一条 WARNING 级别的测试消息") + logger.error("这是一条 ERROR 级别的测试消息") + + sys.stdout.write(f"✓ 日志级别已成功设置为: {args.log_level}\n") + sys.stdout.write("✓ 日志系统初始化完成\n") + return + + setup_logging(config_manager) # 在 TUI 模式下禁用控制台日志输出,避免干扰界面 disable_console_output() logger = get_logger(__name__) - # 注册退出时清理空日志文件 - atexit.register(cleanup_empty_logs) - try: app = IntelligentTerminal() app.run() diff --git a/src/tool/oi_backend_init.py b/src/tool/oi_backend_init.py index 7148f21743902da5c542973d1f11c6c319bb87b5..b429094cab54537e60f85fbd0c0f057b37f39613 100644 --- a/src/tool/oi_backend_init.py +++ b/src/tool/oi_backend_init.py @@ -4,11 +4,11 @@ from __future__ import annotations from log.manager import get_logger -logger = get_logger(__name__) - def oi_backend_init() -> None: """初始化后端系统 - 启动 TUI 部署助手""" + logger = get_logger(__name__) + try: from typing import TYPE_CHECKING