From 687dd5d13c72307ce83c3986e872080c0e39df98 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Thu, 7 Aug 2025 15:33:54 +0800 Subject: [PATCH 1/3] feat: choose agent in TUI Signed-off-by: Hongyu Shi --- src/app/css/styles.tcss | 92 +++++++++++++ src/app/tui.py | 245 ++++++++++++++++++++++++++++++++++- src/backend/hermes/client.py | 26 +++- src/backend/hermes/models.py | 4 +- 4 files changed, 358 insertions(+), 9 deletions(-) diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index 074f17c..921d707 100644 --- a/src/app/css/styles.tcss +++ b/src/app/css/styles.tcss @@ -139,3 +139,95 @@ Static { height: 3; margin-top: 1; } + +/* 智能体选择对话框屏幕样式 */ +#agent-dialog-screen { + align: center middle; + width: 100%; + height: 100%; +} + +/* 智能体选择对话框样式 */ +#agent-dialog { + align: center middle; + width: 60%; + height: 70%; + border: solid #4963b1; + color: #ffffff; + margin: 1 1; + padding: 1; +} + +/* 智能体对话框标题样式 */ +#agent-dialog-title { + text-align: center; + margin-bottom: 1; + padding: 1; + color: #688efd; + text-style: bold; +} + +/* 智能体内容显示区域 */ +#agent-content { + height: 1fr; + width: 100%; + background: #1a1a1a; + color: #ffffff; + padding: 2; + border: solid #688efd; + margin: 1 0; + overflow: auto; +} + +/* 智能体对话框帮助文本样式 */ +#agent-dialog-help { + text-align: center; + margin-top: 1; + padding: 1; + color: #aaaaaa; + text-style: italic; +} + +/* 后端要求对话框屏幕样式 */ +#backend-dialog-screen { + align: center middle; + width: 100%; + height: 100%; +} + +/* 后端要求对话框样式 */ +#backend-dialog { + align: center middle; + width: 50%; + height: 15; + border: solid #ff9800; + color: #ffffff; + margin: 1 1; + padding: 1; +} + +/* 后端对话框标题样式 */ +#backend-dialog-title { + text-align: center; + margin-bottom: 1; + padding: 1; + color: #ff9800; + text-style: bold; +} + +/* 后端对话框文本样式 */ +#backend-dialog-text { + text-align: center; + margin-bottom: 1; + padding: 1; + color: #ffffff; +} + +/* 后端对话框帮助文本样式 */ +#backend-dialog-help { + text-align: center; + margin-top: 1; + padding: 1; + color: #aaaaaa; + text-style: italic; +} diff --git a/src/app/tui.py b/src/app/tui.py index 594a721..86e57a8 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, ClassVar, NamedTuple +from typing import TYPE_CHECKING, Callable, ClassVar, NamedTuple from rich.markdown import Markdown as RichMarkdown from textual import on @@ -185,6 +185,121 @@ class ExitDialog(ModalScreen): self.app.exit() +class BackendRequiredDialog(ModalScreen): + """后端要求提示对话框""" + + def compose(self) -> ComposeResult: + """构建后端要求提示对话框""" + yield Container( + Container( + Label("智能体功能提示", id="backend-dialog-title"), + Label("请选择 openHermes 后端来使用智能体功能", id="backend-dialog-text"), + Label("按任意键关闭", id="backend-dialog-help"), + id="backend-dialog", + ), + id="backend-dialog-screen", + ) + + def on_key(self, event: KeyEvent) -> None: + """处理键盘事件 - 任意键关闭对话框""" + self.app.pop_screen() + + +class AgentSelectionDialog(ModalScreen): + """智能体选择对话框""" + + def __init__(self, agents: list[tuple[str, str]], callback: Callable[[tuple[str, str]], None]) -> None: + """ + 初始化智能体选择对话框 + + Args: + agents: 智能体列表,格式为 [(app_id, name), ...] + 第一项为("", "智能问答")表示无智能体 + callback: 选择完成后的回调函数 + + """ + super().__init__() + self.agents = agents + self.selected_index = 0 + self.callback = callback + + def compose(self) -> ComposeResult: + """构建智能体选择对话框""" + # 创建富文本内容,包含所有智能体选项 + agent_text_lines = [] + for i, (_app_id, name) in enumerate(self.agents): + if i == self.selected_index: + # 选中状态:蓝底白字 + agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") + else: + # 普通状态:白字黑底 + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + + # 使用Static组件显示文本,启用Rich markup + agent_content = Static("\n".join(agent_text_lines), markup=True, id="agent-content") + + yield Container( + Container( + Label("选择智能体", id="agent-dialog-title"), + agent_content, + Label("使用上下键选择,回车确认,ESC取消", id="agent-dialog-help"), + id="agent-dialog", + ), + id="agent-dialog-screen", + ) + + def on_key(self, event: KeyEvent) -> None: + """处理键盘事件""" + if event.key == "escape": + self.app.pop_screen() + elif event.key == "enter": + # 确保有智能体可选择 + if self.agents and 0 <= self.selected_index < len(self.agents): + selected_agent = self.agents[self.selected_index] + else: + selected_agent = ("", "智能问答") + self.callback(selected_agent) + self.app.pop_screen() + elif event.key == "up" and self.selected_index > 0: + self.selected_index -= 1 + self._update_display() + elif event.key == "down" and self.selected_index < len(self.agents) - 1: + self.selected_index += 1 + self._update_display() + + def on_mount(self) -> None: + """挂载时设置初始显示""" + self._update_display() + + def _update_display(self) -> None: + """更新显示内容""" + # 重新生成文本内容 + agent_text_lines = [] + for i, (_app_id, name) in enumerate(self.agents): + if i == self.selected_index: + # 选中状态:蓝底白字 + agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") + else: + # 普通状态:亮白字 + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + + # 更新Static组件的内容 + try: + agent_content = self.query_one("#agent-content", Static) + agent_content.update("\n".join(agent_text_lines)) + except (AttributeError, ValueError, RuntimeError): + # 如果查找失败,忽略错误 + pass + + class IntelligentTerminal(App): """基于 Textual 的智能终端应用""" @@ -193,6 +308,7 @@ class IntelligentTerminal(App): BINDINGS: ClassVar[list[BindingType]] = [ Binding(key="ctrl+s", action="settings", description="设置"), Binding(key="ctrl+r", action="reset_conversation", description="重置对话"), + Binding(key="ctrl+t", action="choose_agent", description="选择智能体"), Binding(key="esc", action="request_quit", description="退出"), Binding(key="tab", action="toggle_focus", description="切换焦点"), ] @@ -208,6 +324,8 @@ class IntelligentTerminal(App): self.background_tasks: set[asyncio.Task] = set() # 创建并保持单一的 LLM 客户端实例以维持对话历史 self._llm_client: LLMClientBase | None = None + # 当前选择的智能体 + self.current_agent: tuple[str, str] = ("", "智能问答") # 创建日志实例 self.logger = get_logger(__name__) @@ -235,6 +353,97 @@ class IntelligentTerminal(App): output_container = self.query_one("#output-container") output_container.remove_children() + def action_choose_agent(self) -> None: + """选择智能体的动作""" + # 获取 Hermes 客户端 + llm_client = self._get_llm_client() + + # 检查客户端类型 + if not hasattr(llm_client, "get_available_agents"): + # 显示后端要求提示对话框 + self.push_screen(BackendRequiredDialog()) + return + + # 异步获取智能体列表 + task = asyncio.create_task(self._show_agent_selection()) + self.background_tasks.add(task) + task.add_done_callback(self._task_done_callback) + + async def _show_agent_selection(self) -> None: + """显示智能体选择对话框""" + try: + llm_client = self._get_llm_client() + + # 构建智能体列表 - 默认第一项为"智能问答"(无智能体) + agent_list = [("", "智能问答")] + + # 尝试获取可用智能体 + if hasattr(llm_client, "get_available_agents"): + try: + available_agents = await llm_client.get_available_agents() # type: ignore[attr-defined] + # 添加获取到的智能体 + agent_list.extend( + [ + (agent.app_id, agent.name) + for agent in available_agents + if hasattr(agent, "app_id") and hasattr(agent, "name") + ], + ) + except (AttributeError, OSError, ValueError, RuntimeError) as e: + self.logger.warning("获取智能体列表失败,使用默认选项: %s", str(e)) + # 继续使用默认的智能问答选项 + else: + self.logger.info("当前客户端不支持智能体功能,显示默认选项") + + # 显示选择对话框(至少包含"智能问答"选项) + await self._display_agent_dialog(agent_list, llm_client) + + except (OSError, ValueError, RuntimeError) as e: + log_exception(self.logger, "显示智能体选择对话框失败", e) + # 即使出错也显示默认选项 + agent_list = [("", "智能问答")] + try: + llm_client = self._get_llm_client() + await self._display_agent_dialog(agent_list, llm_client) + except (OSError, ValueError, RuntimeError, AttributeError): + self._show_error_message("无法显示智能体选择对话框") + + async def _display_agent_dialog(self, agent_list: list[tuple[str, str]], llm_client: LLMClientBase) -> None: + """显示智能体选择对话框""" + + def on_agent_selected(selected_agent: tuple[str, str]) -> None: + """智能体选择回调""" + self.current_agent = selected_agent + app_id, name = selected_agent + + # 设置智能体到客户端 + if hasattr(llm_client, "set_current_agent"): + llm_client.set_current_agent(app_id) # type: ignore[attr-defined] + + # 显示选择结果 + self._show_info_message(f"已选择智能体: {name}") + + dialog = AgentSelectionDialog(agent_list, on_agent_selected) + self.push_screen(dialog) + + def _show_error_message(self, message: str) -> None: + """显示错误消息""" + try: + output_container = self.query_one("#output-container") + output_container.mount(OutputLine(message, command=False)) + except (AttributeError, ValueError, RuntimeError): + # 如果UI组件已不可用,只记录错误日志 + self.logger.exception("Failed to display error message") + + def _show_info_message(self, message: str) -> None: + """显示信息消息""" + try: + output_container = self.query_one("#output-container") + output_container.mount(OutputLine(message, command=False)) + except (AttributeError, ValueError, RuntimeError): + # 如果UI组件已不可用,只记录错误日志 + self.logger.exception("Failed to display info message") + def action_toggle_focus(self) -> None: """在命令输入框和文本区域之间切换焦点""" # 获取当前聚焦的组件 @@ -248,8 +457,36 @@ class IntelligentTerminal(App): self.query_one(CommandInput).focus() def on_mount(self) -> None: - """初始化完成时设置焦点""" + """初始化完成时设置焦点和绑定""" self.query_one(CommandInput).focus() + self._update_bindings() + + def _update_bindings(self) -> None: + """根据后端类型更新键绑定""" + from config.model import Backend + + # 移除现有的智能体选择绑定 + bindings_to_keep = [binding for binding in self.BINDINGS if getattr(binding, "action", None) != "choose_agent"] + + # 只有 Hermes 后端才添加智能体选择 + if self.config_manager.get_backend() == Backend.EULERINTELLI: + # 在"重置对话"后插入智能体选择 + agent_binding = Binding(key="ctrl+t", action="choose_agent", description="选择智能体") + # 找到重置对话的位置 + for i, binding in enumerate(bindings_to_keep): + if getattr(binding, "action", None) == "reset_conversation": + bindings_to_keep.insert(i + 1, agent_binding) + break + + # 更新绑定列表 + self.BINDINGS.clear() + self.BINDINGS.extend(bindings_to_keep) + + def refresh_llm_client(self) -> None: + """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" + self._llm_client = BackendFactory.create_client(self.config_manager) + # 配置更改后重新更新绑定 + self._update_bindings() def exit(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 """退出应用前取消所有后台任务""" @@ -311,10 +548,6 @@ class IntelligentTerminal(App): # 添加完成回调,自动从集合中移除 task.add_done_callback(self._task_done_callback) - def refresh_llm_client(self) -> None: - """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" - self._llm_client = BackendFactory.create_client(self.config_manager) - def _task_done_callback(self, task: asyncio.Task) -> None: """任务完成回调,从任务集合中移除""" if task in self.background_tasks: diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index 683ac74..1b5fa34 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -59,6 +59,9 @@ class HermesChatClient(LLMClientBase): self._conversation_manager: HermesConversationManager | None = None self._stream_processor: HermesStreamProcessor | None = None + # 当前选择的智能体ID + self._current_agent_id: str = "" + self.logger.info("Hermes 客户端初始化成功 - URL: %s", base_url) @property @@ -93,6 +96,27 @@ class HermesChatClient(LLMClientBase): self._stream_processor = HermesStreamProcessor() return self._stream_processor + def set_current_agent(self, agent_id: str) -> None: + """ + 设置当前使用的智能体 + + Args: + agent_id: 智能体ID,空字符串表示不使用智能体 + + """ + self._current_agent_id = agent_id + self.logger.info("设置当前智能体ID: %s", agent_id or "无智能体") + + def get_current_agent(self) -> str: + """ + 获取当前使用的智能体ID + + Returns: + str: 当前智能体ID,空字符串表示不使用智能体 + + """ + return self._current_agent_id + def reset_conversation(self) -> None: """重置会话,下次聊天时会创建新的会话""" if self._conversation_manager is not None: @@ -128,7 +152,7 @@ class HermesChatClient(LLMClientBase): # 创建聊天请求 from .models import HermesApp, HermesChatRequest, HermesFeatures - app = HermesApp("") + app = HermesApp(self._current_agent_id) request = HermesChatRequest( app=app, conversation_id=conversation_id, diff --git a/src/backend/hermes/models.py b/src/backend/hermes/models.py index 8d25894..ba44eb7 100644 --- a/src/backend/hermes/models.py +++ b/src/backend/hermes/models.py @@ -28,7 +28,7 @@ class HermesAgent: favorited: bool """是否已收藏""" - published: bool | None = None + published: bool = True """是否已发布""" @classmethod @@ -41,7 +41,7 @@ class HermesAgent: description=data.get("description", ""), icon=data.get("icon", ""), favorited=data.get("favorited", False), - published=data.get("published"), + published=data.get("published", True), ) -- Gitee From 4f3c9b9774ccb11e193999755da0c916268186fa Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Thu, 7 Aug 2025 15:46:21 +0800 Subject: [PATCH 2/3] refactor(tui): move dialogs to separate files Signed-off-by: Hongyu Shi --- src/app/dialogs/__init__.py | 6 ++ src/app/dialogs/agent.py | 128 ++++++++++++++++++++++++++++++ src/app/dialogs/common.py | 42 ++++++++++ src/app/tui.py | 152 +----------------------------------- 4 files changed, 180 insertions(+), 148 deletions(-) create mode 100644 src/app/dialogs/__init__.py create mode 100644 src/app/dialogs/agent.py create mode 100644 src/app/dialogs/common.py diff --git a/src/app/dialogs/__init__.py b/src/app/dialogs/__init__.py new file mode 100644 index 0000000..b73f00c --- /dev/null +++ b/src/app/dialogs/__init__.py @@ -0,0 +1,6 @@ +"""对话框模块""" + +from .agent import AgentSelectionDialog, BackendRequiredDialog +from .common import ExitDialog + +__all__ = ["AgentSelectionDialog", "BackendRequiredDialog", "ExitDialog"] diff --git a/src/app/dialogs/agent.py b/src/app/dialogs/agent.py new file mode 100644 index 0000000..bd2e2e8 --- /dev/null +++ b/src/app/dialogs/agent.py @@ -0,0 +1,128 @@ +"""智能体相关对话框组件""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from textual.app import ComposeResult + from textual.events import Key as KeyEvent + +from textual.containers import Container +from textual.screen import ModalScreen +from textual.widgets import Label, Static + + +class BackendRequiredDialog(ModalScreen): + """后端要求提示对话框""" + + def compose(self) -> ComposeResult: + """构建后端要求提示对话框""" + yield Container( + Container( + Label("智能体功能提示", id="backend-dialog-title"), + Label("请选择 openHermes 后端来使用智能体功能", id="backend-dialog-text"), + Label("按任意键关闭", id="backend-dialog-help"), + id="backend-dialog", + ), + id="backend-dialog-screen", + ) + + def on_key(self, event: KeyEvent) -> None: + """处理键盘事件 - 任意键关闭对话框""" + self.app.pop_screen() + + +class AgentSelectionDialog(ModalScreen): + """智能体选择对话框""" + + def __init__(self, agents: list[tuple[str, str]], callback: Callable[[tuple[str, str]], None]) -> None: + """ + 初始化智能体选择对话框 + + Args: + agents: 智能体列表,格式为 [(app_id, name), ...] + 第一项为("", "智能问答")表示无智能体 + callback: 选择完成后的回调函数 + + """ + super().__init__() + self.agents = agents + self.selected_index = 0 + self.callback = callback + + def compose(self) -> ComposeResult: + """构建智能体选择对话框""" + # 创建富文本内容,包含所有智能体选项 + agent_text_lines = [] + for i, (_app_id, name) in enumerate(self.agents): + if i == self.selected_index: + # 选中状态:蓝底白字 + agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") + else: + # 普通状态:白字黑底 + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + + # 使用Static组件显示文本,启用Rich markup + agent_content = Static("\n".join(agent_text_lines), markup=True, id="agent-content") + + yield Container( + Container( + Label("选择智能体", id="agent-dialog-title"), + agent_content, + Label("使用上下键选择,回车确认,ESC取消", id="agent-dialog-help"), + id="agent-dialog", + ), + id="agent-dialog-screen", + ) + + def on_key(self, event: KeyEvent) -> None: + """处理键盘事件""" + if event.key == "escape": + self.app.pop_screen() + elif event.key == "enter": + # 确保有智能体可选择 + if self.agents and 0 <= self.selected_index < len(self.agents): + selected_agent = self.agents[self.selected_index] + else: + selected_agent = ("", "智能问答") + self.callback(selected_agent) + self.app.pop_screen() + elif event.key == "up" and self.selected_index > 0: + self.selected_index -= 1 + self._update_display() + elif event.key == "down" and self.selected_index < len(self.agents) - 1: + self.selected_index += 1 + self._update_display() + + def on_mount(self) -> None: + """挂载时设置初始显示""" + self._update_display() + + def _update_display(self) -> None: + """更新显示内容""" + # 重新生成文本内容 + agent_text_lines = [] + for i, (_app_id, name) in enumerate(self.agents): + if i == self.selected_index: + # 选中状态:蓝底白字 + agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") + else: + # 普通状态:亮白字 + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + + # 更新Static组件的内容 + try: + agent_content = self.query_one("#agent-content", Static) + agent_content.update("\n".join(agent_text_lines)) + except (AttributeError, ValueError, RuntimeError): + # 如果查找失败,忽略错误 + pass diff --git a/src/app/dialogs/common.py b/src/app/dialogs/common.py new file mode 100644 index 0000000..9958917 --- /dev/null +++ b/src/app/dialogs/common.py @@ -0,0 +1,42 @@ +"""通用对话框组件""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from textual.app import ComposeResult + +from textual import on +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, Label + + +class ExitDialog(ModalScreen): + """退出确认对话框""" + + def compose(self) -> ComposeResult: + """构建退出确认对话框""" + yield Container( + Container( + Label("确认退出吗?", id="dialog-text"), + Horizontal( + Button("取消", classes="dialog-button", id="cancel"), + Button("确认", classes="dialog-button", id="confirm"), + id="dialog-buttons", + ), + id="exit-dialog", + ), + id="exit-dialog-screen", + ) + + @on(Button.Pressed, "#cancel") + def cancel_exit(self) -> None: + """取消退出""" + self.app.pop_screen() + + @on(Button.Pressed, "#confirm") + def confirm_exit(self) -> None: + """确认退出""" + self.app.exit() diff --git a/src/app/tui.py b/src/app/tui.py index 86e57a8..a244507 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -3,16 +3,16 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Callable, ClassVar, NamedTuple +from typing import TYPE_CHECKING, ClassVar, NamedTuple from rich.markdown import Markdown as RichMarkdown from textual import on from textual.app import App, ComposeResult from textual.binding import Binding, BindingType -from textual.containers import Container, Horizontal -from textual.screen import ModalScreen -from textual.widgets import Button, Footer, Header, Input, Label, Static +from textual.containers import Container +from textual.widgets import Footer, Header, Input, Static +from app.dialogs import AgentSelectionDialog, BackendRequiredDialog, ExitDialog from app.settings import SettingsScreen from backend.factory import BackendFactory from config import ConfigManager @@ -156,150 +156,6 @@ class CommandInput(Input): super().__init__(placeholder="输入命令或问题...", id="command-input") -class ExitDialog(ModalScreen): - """退出确认对话框""" - - def compose(self) -> ComposeResult: - """构建退出确认对话框""" - yield Container( - Container( - Label("确认退出吗?", id="dialog-text"), - Horizontal( - Button("取消", classes="dialog-button", id="cancel"), - Button("确认", classes="dialog-button", id="confirm"), - id="dialog-buttons", - ), - id="exit-dialog", - ), - id="exit-dialog-screen", - ) - - @on(Button.Pressed, "#cancel") - def cancel_exit(self) -> None: - """取消退出""" - self.app.pop_screen() - - @on(Button.Pressed, "#confirm") - def confirm_exit(self) -> None: - """确认退出""" - self.app.exit() - - -class BackendRequiredDialog(ModalScreen): - """后端要求提示对话框""" - - def compose(self) -> ComposeResult: - """构建后端要求提示对话框""" - yield Container( - Container( - Label("智能体功能提示", id="backend-dialog-title"), - Label("请选择 openHermes 后端来使用智能体功能", id="backend-dialog-text"), - Label("按任意键关闭", id="backend-dialog-help"), - id="backend-dialog", - ), - id="backend-dialog-screen", - ) - - def on_key(self, event: KeyEvent) -> None: - """处理键盘事件 - 任意键关闭对话框""" - self.app.pop_screen() - - -class AgentSelectionDialog(ModalScreen): - """智能体选择对话框""" - - def __init__(self, agents: list[tuple[str, str]], callback: Callable[[tuple[str, str]], None]) -> None: - """ - 初始化智能体选择对话框 - - Args: - agents: 智能体列表,格式为 [(app_id, name), ...] - 第一项为("", "智能问答")表示无智能体 - callback: 选择完成后的回调函数 - - """ - super().__init__() - self.agents = agents - self.selected_index = 0 - self.callback = callback - - def compose(self) -> ComposeResult: - """构建智能体选择对话框""" - # 创建富文本内容,包含所有智能体选项 - agent_text_lines = [] - for i, (_app_id, name) in enumerate(self.agents): - if i == self.selected_index: - # 选中状态:蓝底白字 - agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") - else: - # 普通状态:白字黑底 - agent_text_lines.append(f"[bright_white] {name}[/bright_white]") - - # 如果没有智能体,添加默认选项 - if not agent_text_lines: - agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") - - # 使用Static组件显示文本,启用Rich markup - agent_content = Static("\n".join(agent_text_lines), markup=True, id="agent-content") - - yield Container( - Container( - Label("选择智能体", id="agent-dialog-title"), - agent_content, - Label("使用上下键选择,回车确认,ESC取消", id="agent-dialog-help"), - id="agent-dialog", - ), - id="agent-dialog-screen", - ) - - def on_key(self, event: KeyEvent) -> None: - """处理键盘事件""" - if event.key == "escape": - self.app.pop_screen() - elif event.key == "enter": - # 确保有智能体可选择 - if self.agents and 0 <= self.selected_index < len(self.agents): - selected_agent = self.agents[self.selected_index] - else: - selected_agent = ("", "智能问答") - self.callback(selected_agent) - self.app.pop_screen() - elif event.key == "up" and self.selected_index > 0: - self.selected_index -= 1 - self._update_display() - elif event.key == "down" and self.selected_index < len(self.agents) - 1: - self.selected_index += 1 - self._update_display() - - def on_mount(self) -> None: - """挂载时设置初始显示""" - self._update_display() - - def _update_display(self) -> None: - """更新显示内容""" - # 重新生成文本内容 - agent_text_lines = [] - for i, (_app_id, name) in enumerate(self.agents): - if i == self.selected_index: - # 选中状态:蓝底白字 - agent_text_lines.append(f"[white on blue]► {name}[/white on blue]") - else: - # 普通状态:亮白字 - agent_text_lines.append(f"[bright_white] {name}[/bright_white]") - - # 如果没有智能体,添加默认选项 - if not agent_text_lines: - agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") - - # 更新Static组件的内容 - try: - agent_content = self.query_one("#agent-content", Static) - agent_content.update("\n".join(agent_text_lines)) - except (AttributeError, ValueError, RuntimeError): - # 如果查找失败,忽略错误 - pass - - class IntelligentTerminal(App): """基于 Textual 的智能终端应用""" -- Gitee From 86efedeaef84155bcbc25a1c08f1ef9adffd5767 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Thu, 7 Aug 2025 16:01:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(tui):=20=E4=BC=98=E5=8C=96=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=B7=B2=E9=80=89=E4=B8=AD=E7=9A=84=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/dialogs/agent.py | 59 +++++++++--- src/app/tui.py | 192 +++++++++++++++------------------------ 2 files changed, 118 insertions(+), 133 deletions(-) diff --git a/src/app/dialogs/agent.py b/src/app/dialogs/agent.py index bd2e2e8..3c8f3f2 100644 --- a/src/app/dialogs/agent.py +++ b/src/app/dialogs/agent.py @@ -36,7 +36,12 @@ class BackendRequiredDialog(ModalScreen): class AgentSelectionDialog(ModalScreen): """智能体选择对话框""" - def __init__(self, agents: list[tuple[str, str]], callback: Callable[[tuple[str, str]], None]) -> None: + def __init__( + self, + agents: list[tuple[str, str]], + callback: Callable[[tuple[str, str]], None], + current_agent: tuple[str, str] | None = None, + ) -> None: """ 初始化智能体选择对话框 @@ -44,28 +49,45 @@ class AgentSelectionDialog(ModalScreen): agents: 智能体列表,格式为 [(app_id, name), ...] 第一项为("", "智能问答")表示无智能体 callback: 选择完成后的回调函数 + current_agent: 当前已选中的智能体 """ super().__init__() self.agents = agents - self.selected_index = 0 + self.current_agent = current_agent or ("", "智能问答") self.callback = callback + # 设置初始光标位置为当前已选中的智能体 + self.selected_index = 0 + for i, agent in enumerate(self.agents): + if agent[0] == self.current_agent[0]: # 按 app_id 匹配 + self.selected_index = i + break + def compose(self) -> ComposeResult: """构建智能体选择对话框""" # 创建富文本内容,包含所有智能体选项 agent_text_lines = [] - for i, (_app_id, name) in enumerate(self.agents): - if i == self.selected_index: - # 选中状态:蓝底白字 + for i, (app_id, name) in enumerate(self.agents): + is_cursor = i == self.selected_index + is_current = app_id == self.current_agent[0] + + 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]") + # 普通状态:亮白字 + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") # 如果没有智能体,添加默认选项 if not agent_text_lines: - agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + agent_text_lines.append("[white on green]► ✓ 智能问答[/white on green]") # 使用Static组件显示文本,启用Rich markup agent_content = Static("\n".join(agent_text_lines), markup=True, id="agent-content") @@ -74,7 +96,7 @@ class AgentSelectionDialog(ModalScreen): Container( Label("选择智能体", id="agent-dialog-title"), agent_content, - Label("使用上下键选择,回车确认,ESC取消", id="agent-dialog-help"), + Label("使用上下键选择,回车确认,ESC取消 | ✓ 表示当前选中", id="agent-dialog-help"), id="agent-dialog", ), id="agent-dialog-screen", @@ -107,17 +129,26 @@ class AgentSelectionDialog(ModalScreen): """更新显示内容""" # 重新生成文本内容 agent_text_lines = [] - for i, (_app_id, name) in enumerate(self.agents): - if i == self.selected_index: - # 选中状态:蓝底白字 + for i, (app_id, name) in enumerate(self.agents): + is_cursor = i == self.selected_index + is_current = app_id == self.current_agent[0] + + 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]") + agent_text_lines.append(f"[bright_white] {name}[/bright_white]") # 如果没有智能体,添加默认选项 if not agent_text_lines: - agent_text_lines.append("[white on blue]► 智能问答[/white on blue]") + agent_text_lines.append("[white on green]► ✓ 智能问答[/white on green]") # 更新Static组件的内容 try: diff --git a/src/app/tui.py b/src/app/tui.py index a244507..668c2b2 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -225,81 +225,6 @@ class IntelligentTerminal(App): self.background_tasks.add(task) task.add_done_callback(self._task_done_callback) - async def _show_agent_selection(self) -> None: - """显示智能体选择对话框""" - try: - llm_client = self._get_llm_client() - - # 构建智能体列表 - 默认第一项为"智能问答"(无智能体) - agent_list = [("", "智能问答")] - - # 尝试获取可用智能体 - if hasattr(llm_client, "get_available_agents"): - try: - available_agents = await llm_client.get_available_agents() # type: ignore[attr-defined] - # 添加获取到的智能体 - agent_list.extend( - [ - (agent.app_id, agent.name) - for agent in available_agents - if hasattr(agent, "app_id") and hasattr(agent, "name") - ], - ) - except (AttributeError, OSError, ValueError, RuntimeError) as e: - self.logger.warning("获取智能体列表失败,使用默认选项: %s", str(e)) - # 继续使用默认的智能问答选项 - else: - self.logger.info("当前客户端不支持智能体功能,显示默认选项") - - # 显示选择对话框(至少包含"智能问答"选项) - await self._display_agent_dialog(agent_list, llm_client) - - except (OSError, ValueError, RuntimeError) as e: - log_exception(self.logger, "显示智能体选择对话框失败", e) - # 即使出错也显示默认选项 - agent_list = [("", "智能问答")] - try: - llm_client = self._get_llm_client() - await self._display_agent_dialog(agent_list, llm_client) - except (OSError, ValueError, RuntimeError, AttributeError): - self._show_error_message("无法显示智能体选择对话框") - - async def _display_agent_dialog(self, agent_list: list[tuple[str, str]], llm_client: LLMClientBase) -> None: - """显示智能体选择对话框""" - - def on_agent_selected(selected_agent: tuple[str, str]) -> None: - """智能体选择回调""" - self.current_agent = selected_agent - app_id, name = selected_agent - - # 设置智能体到客户端 - if hasattr(llm_client, "set_current_agent"): - llm_client.set_current_agent(app_id) # type: ignore[attr-defined] - - # 显示选择结果 - self._show_info_message(f"已选择智能体: {name}") - - dialog = AgentSelectionDialog(agent_list, on_agent_selected) - self.push_screen(dialog) - - def _show_error_message(self, message: str) -> None: - """显示错误消息""" - try: - output_container = self.query_one("#output-container") - output_container.mount(OutputLine(message, command=False)) - except (AttributeError, ValueError, RuntimeError): - # 如果UI组件已不可用,只记录错误日志 - self.logger.exception("Failed to display error message") - - def _show_info_message(self, message: str) -> None: - """显示信息消息""" - try: - output_container = self.query_one("#output-container") - output_container.mount(OutputLine(message, command=False)) - except (AttributeError, ValueError, RuntimeError): - # 如果UI组件已不可用,只记录错误日志 - self.logger.exception("Failed to display info message") - def action_toggle_focus(self) -> None: """在命令输入框和文本区域之间切换焦点""" # 获取当前聚焦的组件 @@ -315,34 +240,10 @@ class IntelligentTerminal(App): def on_mount(self) -> None: """初始化完成时设置焦点和绑定""" self.query_one(CommandInput).focus() - self._update_bindings() - - def _update_bindings(self) -> None: - """根据后端类型更新键绑定""" - from config.model import Backend - - # 移除现有的智能体选择绑定 - bindings_to_keep = [binding for binding in self.BINDINGS if getattr(binding, "action", None) != "choose_agent"] - - # 只有 Hermes 后端才添加智能体选择 - if self.config_manager.get_backend() == Backend.EULERINTELLI: - # 在"重置对话"后插入智能体选择 - agent_binding = Binding(key="ctrl+t", action="choose_agent", description="选择智能体") - # 找到重置对话的位置 - for i, binding in enumerate(bindings_to_keep): - if getattr(binding, "action", None) == "reset_conversation": - bindings_to_keep.insert(i + 1, agent_binding) - break - - # 更新绑定列表 - self.BINDINGS.clear() - self.BINDINGS.extend(bindings_to_keep) def refresh_llm_client(self) -> None: """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" self._llm_client = BackendFactory.create_client(self.config_manager) - # 配置更改后重新更新绑定 - self._update_bindings() def exit(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 """退出应用前取消所有后台任务""" @@ -361,26 +262,6 @@ class IntelligentTerminal(App): # 调用父类的exit方法 super().exit(*args, **kwargs) - async def _cleanup_llm_client(self) -> None: - """异步清理 LLM 客户端""" - if self._llm_client is not None: - try: - await self._llm_client.close() - self.logger.info("LLM 客户端已安全关闭") - except (OSError, RuntimeError, ValueError) as e: - log_exception(self.logger, "关闭 LLM 客户端时出错", e) - - def _cleanup_task_done_callback(self, task: asyncio.Task) -> None: - """清理任务完成回调""" - if task in self.background_tasks: - self.background_tasks.remove(task) - try: - task.result() - except asyncio.CancelledError: - pass - except (OSError, ValueError, RuntimeError): - self.logger.exception("LLM client cleanup error") - @on(Input.Submitted, "#command-input") def handle_input(self, event: Input.Submitted) -> None: """处理命令输入""" @@ -571,3 +452,76 @@ class IntelligentTerminal(App): 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: + try: + await self._llm_client.close() + self.logger.info("LLM 客户端已安全关闭") + except (OSError, RuntimeError, ValueError) as e: + log_exception(self.logger, "关闭 LLM 客户端时出错", e) + + def _cleanup_task_done_callback(self, task: asyncio.Task) -> None: + """清理任务完成回调""" + if task in self.background_tasks: + self.background_tasks.remove(task) + try: + task.result() + except asyncio.CancelledError: + pass + except (OSError, ValueError, RuntimeError): + self.logger.exception("LLM client cleanup error") + + async def _show_agent_selection(self) -> None: + """显示智能体选择对话框""" + try: + llm_client = self._get_llm_client() + + # 构建智能体列表 - 默认第一项为"智能问答"(无智能体) + agent_list = [("", "智能问答")] + + # 尝试获取可用智能体 + if hasattr(llm_client, "get_available_agents"): + try: + available_agents = await llm_client.get_available_agents() # type: ignore[attr-defined] + # 添加获取到的智能体 + agent_list.extend( + [ + (agent.app_id, agent.name) + for agent in available_agents + if hasattr(agent, "app_id") and hasattr(agent, "name") + ], + ) + except (AttributeError, OSError, ValueError, RuntimeError) as e: + self.logger.warning("获取智能体列表失败,使用默认选项: %s", str(e)) + # 继续使用默认的智能问答选项 + else: + self.logger.info("当前客户端不支持智能体功能,显示默认选项") + + await self._display_agent_dialog(agent_list, llm_client) + + except (OSError, ValueError, RuntimeError) as e: + log_exception(self.logger, "显示智能体选择对话框失败", e) + # 即使出错也显示默认选项 + agent_list = [("", "智能问答")] + try: + llm_client = self._get_llm_client() + await self._display_agent_dialog(agent_list, llm_client) + except (OSError, ValueError, RuntimeError, AttributeError): + self.logger.exception("无法显示智能体选择对话框") + + async def _display_agent_dialog(self, agent_list: list[tuple[str, str]], llm_client: LLMClientBase) -> None: + """显示智能体选择对话框""" + + def on_agent_selected(selected_agent: tuple[str, str]) -> None: + """智能体选择回调""" + self.current_agent = selected_agent + app_id, name = selected_agent + + # 设置智能体到客户端 + if hasattr(llm_client, "set_current_agent"): + llm_client.set_current_agent(app_id) # type: ignore[attr-defined] + + dialog = AgentSelectionDialog(agent_list, on_agent_selected, self.current_agent) + self.push_screen(dialog) -- Gitee