diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index 074f17c0471ccd9206b6831c80e87778e5a44575..921d707a206dee2842de5a855bd785d1233c7100 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/dialogs/__init__.py b/src/app/dialogs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b73f00caeed85cc2636726e7ad9c556392c6ce6c --- /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 0000000000000000000000000000000000000000..3c8f3f2d82a3ab638b83b03104c1d5e37d86bfec --- /dev/null +++ b/src/app/dialogs/agent.py @@ -0,0 +1,159 @@ +"""智能体相关对话框组件""" + +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], + current_agent: tuple[str, str] | None = None, + ) -> None: + """ + 初始化智能体选择对话框 + + Args: + agents: 智能体列表,格式为 [(app_id, name), ...] + 第一项为("", "智能问答")表示无智能体 + callback: 选择完成后的回调函数 + current_agent: 当前已选中的智能体 + + """ + super().__init__() + self.agents = agents + 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): + 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]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + 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") + + 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): + 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]") + + # 如果没有智能体,添加默认选项 + if not agent_text_lines: + agent_text_lines.append("[white on green]► ✓ 智能问答[/white on green]") + + # 更新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 0000000000000000000000000000000000000000..995891722b033200753f9a23724e367ed83a8b2e --- /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 594a721e408dc273a1290304020561d61295e6bc..668c2b2627b8fc26a4debb35b216c937a118afd0 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -9,10 +9,10 @@ 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,35 +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 IntelligentTerminal(App): """基于 Textual 的智能终端应用""" @@ -193,6 +164,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 +180,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 +209,22 @@ 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) + def action_toggle_focus(self) -> None: """在命令输入框和文本区域之间切换焦点""" # 获取当前聚焦的组件 @@ -248,9 +238,13 @@ class IntelligentTerminal(App): self.query_one(CommandInput).focus() def on_mount(self) -> None: - """初始化完成时设置焦点""" + """初始化完成时设置焦点和绑定""" self.query_one(CommandInput).focus() + def refresh_llm_client(self) -> None: + """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" + self._llm_client = BackendFactory.create_client(self.config_manager) + def exit(self, *args, **kwargs) -> None: # noqa: ANN002, ANN003 """退出应用前取消所有后台任务""" # 取消所有正在运行的后台任务 @@ -268,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: """处理命令输入""" @@ -311,10 +285,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: @@ -482,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) diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index 683ac7447f063b373bd48cc47963916b61b441d9..1b5fa34ab5b985997a3dbaddb72884c29d2e13e7 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 8d258944d25abb869e51be67e8fbdb90aae4706a..ba44eb770d82f31d3db0f2a5bff6b6ad4e73cdb2 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), )