From 6f7e29e2a5f937db7c82f6ec64a5014cc15dd04f Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 11:53:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20=E6=B7=BB=E5=8A=A0=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=B8=A0=E9=81=93=E6=89=A9=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增终端(Terminal)渠道扩展,支持在 Yuxi 平台中通过命令行终端交互。 包含以下功能模块: - plugin: 终端渠道插件核心,处理命令行输入输出交互 --- .../channel/extensions/terminal/__init__.py | 304 ++++++++++++++ .../channel/extensions/terminal/plugin.json | 23 + .../channel/extensions/terminal/plugin.py | 395 ++++++++++++++++++ 3 files changed, 722 insertions(+) create mode 100644 backend/package/yuxi/channel/extensions/terminal/__init__.py create mode 100644 backend/package/yuxi/channel/extensions/terminal/plugin.json create mode 100644 backend/package/yuxi/channel/extensions/terminal/plugin.py diff --git a/backend/package/yuxi/channel/extensions/terminal/__init__.py b/backend/package/yuxi/channel/extensions/terminal/__init__.py new file mode 100644 index 00000000..5acf8163 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/terminal/__init__.py @@ -0,0 +1,304 @@ +import logging +import os +import re +import signal +import subprocess +import sys + +from yuxi.channel.capabilities import ChannelCapabilities +from yuxi.channel.extensions.base import BaseChannelPlugin +from yuxi.channel.extensions.terminal.plugin import TerminalAdapter, RICH_AVAILABLE +from yuxi.channel.plugins.registry import ChannelPluginRegistry +from yuxi.channel.protocols import AgentTool, AgentToolParam, ChannelCommand, SessionResolution + +logger = logging.getLogger(__name__) + + +class TerminalPlugin(BaseChannelPlugin): + id = "terminal" + name = "Terminal" + order = 10 + label = "Terminal (CLI)" + aliases = ["cli", "console", "cmd"] + + def __init__(self): + super().__init__() + self._adapter = TerminalAdapter() + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities( + chat_types=["direct"], + message_types=["text"], + interactions=[], + reactions=False, + typing_indicator=True, + threads=False, + edit=False, + unsend=False, + reply=False, + media=False, + native_commands=True, + polls=False, + streaming=True, + streaming_mode="block", + block_streaming=True, + block_streaming_chunk_min_chars=800, + block_streaming_chunk_max_chars=1200, + block_streaming_coalesce_min_chars=None, + block_streaming_coalesce_max_chars=None, + block_streaming_coalesce_idle_ms=1000, + ) + + async def start(self, ctx) -> object: + return await self._adapter.start(ctx) + + async def stop(self, ctx) -> None: + await self._adapter.stop(ctx) + signal.signal(signal.SIGINT, signal.SIG_DFL) + + async def send_text( + self, + target_id: str, + content: str, + *, + reply_to_id: str | None = None, + thread_id: str | None = None, + account_id: str | None = None, + ) -> None: + return await self._adapter.send_text(target_id, content) + + async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: + await self._adapter.send_typing(target_id, thread_id) + + async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None: + await self._adapter.clear_typing(target_id, thread_id) + + def list_account_ids(self, config: dict) -> list[str]: + return self._adapter.list_account_ids(config) + + async def resolve_account(self, account_id: str) -> dict: + return await self._adapter.resolve_account(account_id) + + def is_configured(self, account: dict) -> bool: + return self._adapter.is_configured(account) + + def is_enabled(self, account: dict) -> bool: + return self._adapter.is_enabled(account) + + async def probe(self, account: dict) -> bool: + if not sys.stdin.isatty(): + logger.warning("Terminal probe: stdin is not a tty (pipe or redirect detected)") + return False + if not RICH_AVAILABLE: + logger.info("Terminal probe: rich library not available, Markdown rendering disabled") + return True + + def resolve_session(self, msg: object): + return SessionResolution(kind="direct", conversation_id="terminal-default") + + def parse_explicit_target(self, content: str) -> str | None: + match = re.search(r"@(\w[-\w]*)\s*$", content.strip()) + return match.group(1) if match else None + + def disabled_reason(self, account: dict) -> str: + reasons = [] + if not sys.stdin.isatty(): + reasons.append("stdin is not a terminal (pipe or redirect detected)") + if not reasons: + return "" + return "; ".join(reasons) + + def describe_account(self, account: dict) -> dict: + rich_status = "可用" if RICH_AVAILABLE else "未安装 (pip install rich)" + tty_status = "是" if sys.stdin.isatty() else "否 (pipe/redirect)" + return { + "account_id": account.get("account_id", "default"), + "label": "Terminal (CLI)", + "is_tty": tty_status, + "rich_markdown": rich_status, + } + + def config_schema(self) -> dict: + return { + "type": "object", + "properties": { + "accounts": { + "type": "object", + "description": "Terminal 渠道账户配置(自动生成 default 账户)", + "properties": { + "default": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": True, + "description": "启用 Terminal 渠道", + }, + "rich_markdown": { + "type": "boolean", + "default": True, + "description": "使用 Rich 库渲染 Markdown(需要 pip install rich)", + }, + "streaming": { + "type": "boolean", + "default": True, + "description": "启用流式输出", + }, + "color_theme": { + "type": "string", + "enum": ["dark", "light", "no-color"], + "default": "dark", + "description": "终端配色主题 (dark: 暗色 / light: 亮色 / no-color: 无颜色)", + }, + }, + } + }, + }, + }, + } + + def get_commands(self) -> list: + return [ + ChannelCommand( + name="help", + aliases=["h"], + description="显示帮助信息", + usage="/help", + handler="help", + ), + ChannelCommand( + name="exit", + aliases=["quit"], + description="退出 Terminal 渠道", + usage="/exit", + handler="exit", + ), + ChannelCommand( + name="clear", + aliases=[], + description="清屏", + usage="/clear", + handler="clear", + ), + ChannelCommand( + name="history", + aliases=["hist"], + description="显示输入历史", + usage="/history", + handler="history", + ), + ChannelCommand( + name="model", + aliases=[], + description="切换模型", + usage="/model ", + handler="model", + ), + ] + + def build_system_prompt(self, context) -> str | None: + lines = [ + "[环境信息] 你正在通过 Terminal (CLI) 渠道与用户交互。", + "- 用户通过命令行输入消息,回复将打印到终端标准输出。", + "- 终端支持 Markdown 格式渲染(Rich 库),可以使用表格、代码块等。", + "- 流式输出采用块模式,每个块 800-1200 字符,间隔约 1 秒。", + "- 避免输出过长的单次回复,建议控制在 3000 字符以内以获得最佳体验。", + "- 不支持图片/视频等多媒体内容。", + ] + return "\n".join(lines) + + @property + def channel_format_instructions(self) -> str | None: + return ( + "Terminal 渠道格式规则:\n" + "- 支持 Markdown 渲染(Rich 库):标题、粗体/斜体、代码块(带语法高亮)、表格、列表、引用。\n" + "- 流式输出块大小限制:800-1200 字符/块,间隔约 1 秒。\n" + "- 不支持图片、视频、文件附件。请用文字描述代替。\n" + "- 代码块使用 ```语言标识,支持语法高亮。\n" + "- 表格会对齐渲染,列数较多时建议精简。" + ) + + async def run_startup_maintenance(self, cfg: dict) -> None: + if not RICH_AVAILABLE: + logger.info( + "Terminal: rich library not installed — Markdown rendering disabled. Install with: pip install rich" + ) + if not sys.stdin.isatty(): + logger.warning("Terminal: stdin is not a tty (pipe or redirect). 某些交互式功能可能不可用。") + + async def on_config_changed(self, prev_cfg: dict, next_cfg: dict, account_id: str) -> None: + logger.info("Terminal config changed for account %s", account_id) + + async def on_account_removed(self, account_id: str) -> None: + logger.info("Terminal account %s removed", account_id) + + async def on_retire(self) -> None: + logger.info("Terminal channel retiring, cleaning up resources") + + def get_agent_tools(self) -> list: + return [ + AgentTool( + name="terminal_clear_screen", + description="清屏,清除终端中的所有内容", + parameters=[], + handler_name="terminal_clear_screen", + ), + AgentTool( + name="terminal_run_shell", + description="在终端中执行 shell 命令并返回输出(仅只读命令,禁止修改系统)", + parameters=[ + AgentToolParam( + name="command", + type="string", + description="要执行的 shell 命令", + required=True, + ), + ], + handler_name="terminal_run_shell", + ), + ] + + async def execute_agent_tool(self, tool_name: str, params: dict, context: dict) -> dict: + if tool_name == "terminal_clear_screen": + os.system("cls" if os.name == "nt" else "clear") + return {"success": True, "result": "屏幕已清除", "error": None} + + if tool_name == "terminal_run_shell": + command = params.get("command", "") + if not command: + return {"success": False, "result": None, "error": "命令不能为空"} + dangerous = {"rm", "del", "format", "mkfs", "dd", ">", "|"} + if any(d in command.lower() for d in dangerous): + return {"success": False, "result": None, "error": "命令包含危险操作,已拒绝执行"} + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + output = result.stdout or result.stderr or "(无输出)" + return {"success": result.returncode == 0, "result": output[:2000], "error": None} + except subprocess.TimeoutExpired: + return {"success": False, "result": None, "error": "命令执行超时(30秒)"} + except Exception as e: + return {"success": False, "result": None, "error": str(e)} + + return {"success": False, "result": None, "error": f"未知工具: {tool_name}"} + + def build_summary(self, snapshot: object) -> dict: + return { + "channel": "terminal", + "state": "running" if self._adapter._ctx is not None else "stopped", + "messages_received": self._adapter._msg_counter, + "history_count": len(self._adapter._history), + "rich_available": RICH_AVAILABLE, + "tty": sys.stdin.isatty(), + } + + +terminal_plugin = TerminalPlugin() +ChannelPluginRegistry.register(terminal_plugin) +logger.info("Terminal plugin registered: id=%s name=%s", terminal_plugin.id, terminal_plugin.name) diff --git a/backend/package/yuxi/channel/extensions/terminal/plugin.json b/backend/package/yuxi/channel/extensions/terminal/plugin.json new file mode 100644 index 00000000..0f97f021 --- /dev/null +++ b/backend/package/yuxi/channel/extensions/terminal/plugin.json @@ -0,0 +1,23 @@ +{ + "id": "terminal", + "name": "Terminal", + "version": "0.3.0", + "description": "Terminal (CLI) channel plugin for ForcePilot — local stdin/stdout interactive channel with command system, multiline input, typing indicator, theme support, and prompt-toolkit integration", + "author": "ForcePilot", + "enabled": true, + "order": 10, + "dependencies": [], + "python_requires": ">=3.12", + "capabilities": { + "chat_types": ["direct"], + "message_types": ["text"], + "streaming": true, + "streaming_mode": "block", + "block_streaming": true, + "block_streaming_chunk_min_chars": 800, + "block_streaming_chunk_max_chars": 1200, + "block_streaming_coalesce_idle_ms": 1000, + "native_commands": true, + "typing_indicator": true + } +} \ No newline at end of file diff --git a/backend/package/yuxi/channel/extensions/terminal/plugin.py b/backend/package/yuxi/channel/extensions/terminal/plugin.py new file mode 100644 index 00000000..556f4cde --- /dev/null +++ b/backend/package/yuxi/channel/extensions/terminal/plugin.py @@ -0,0 +1,395 @@ +import asyncio +import logging +import shlex +import signal +import sys +from uuid import uuid4 + +from yuxi.channel.context import ChannelContext +from yuxi.channel.message.models import MessageType, PeerInfo, PeerKind, UnifiedMessage + +logger = logging.getLogger(__name__) + +RICH_AVAILABLE = False +_rich_console = None +_RICH_THEMES: dict = {} + +try: + from rich.console import Console + from rich.markdown import Markdown + from rich.theme import Theme + + _RICH_THEMES = { + "dark": None, + "light": Theme( + { + "markdown.code": "bright_black on white", + "markdown.heading": "bold blue", + } + ), + "no-color": Theme(inherit=False), + } + + def _init_rich_console(theme: str = "dark") -> None: + global _rich_console, RICH_AVAILABLE + try: + _rich_console = Console(theme=_RICH_THEMES.get(theme)) + RICH_AVAILABLE = True + except Exception: + _rich_console = None + RICH_AVAILABLE = False + + _init_rich_console("dark") +except ImportError: + pass + +PROMPT_TOOLKIT_AVAILABLE = False +_prompt_session = None +_kb = None +try: + from prompt_toolkit import PromptSession + from prompt_toolkit.completion import Completer, Completion + from prompt_toolkit.history import InMemoryHistory + from prompt_toolkit.key_binding import KeyBindings + + class _TerminalCompleter(Completer): + _COMMANDS = ["help", "exit", "quit", "clear", "history", "model"] + + def get_completions(self, document, complete_event): + text = document.text_before_cursor.strip() + if text.startswith("/"): + word = text[1:].split()[0] if len(text) > 1 else "" + for cmd in self._COMMANDS: + if cmd.startswith(word): + yield Completion( + f"/{cmd}", + start_position=-len(text), + display_meta=f"/{cmd}", + ) + + _kb = KeyBindings() + + @_kb.add("escape", "enter") + def _multiline_send(event): + event.current_buffer.validate_and_handle() + + try: + _prompt_session = PromptSession( + history=InMemoryHistory(), + completer=_TerminalCompleter(), + ) + PROMPT_TOOLKIT_AVAILABLE = True + except Exception: + logger.debug("prompt_toolkit PromptSession unavailable (no console), falling back to readline") +except ImportError: + pass + + +class TerminalAdapter: + def __init__(self): + self._msg_counter = 0 + self._ctx: ChannelContext | None = None + self._original_sigint = None + self._typing_task: asyncio.Task | None = None + self._is_typing = False + self._history: list[str] = [] + self._commands = { + "help": self._cmd_help, + "exit": self._cmd_exit, + "quit": self._cmd_exit, + "clear": self._cmd_clear, + "history": self._cmd_history, + "model": self._cmd_model, + } + + def list_account_ids(self, config: dict) -> list[str]: + return ["default"] + + async def resolve_account(self, account_id: str) -> dict: + return { + "account_id": "default", + "enabled": True, + "configured": True, + "name": "Terminal", + } + + def is_configured(self, account: dict) -> bool: + return True + + def is_enabled(self, account: dict) -> bool: + return True + + async def start(self, ctx: ChannelContext) -> None: + logger.info("Terminal channel starting (stdin listener)") + self._ctx = ctx + + if RICH_AVAILABLE: + account_config = ctx.config.get("accounts", {}).get("default", {}) + theme = account_config.get("color_theme", "dark") + _init_rich_console(theme) + + self._print_greeting() + + def _sigint_handler(_signum, _frame): + sys.stdout.write("\n\n[中断请求,正在取消当前回复...]\n") + sys.stdout.flush() + ctx.cancel_event.set() + + self._original_sigint = signal.signal(signal.SIGINT, _sigint_handler) + + try: + while not ctx.cancel_event.is_set(): + try: + line = await self._read_stdin_line() + except EOFError: + logger.info("Terminal stdin closed (EOF)") + sys.stdout.write("\nGoodbye!\n") + sys.stdout.flush() + break + + if line is None: + break + + text = line.strip() + if not text: + if PROMPT_TOOLKIT_AVAILABLE: + continue + self._print_prompt() + continue + + if text.startswith("/") and self._handle_command(text): + if not PROMPT_TOOLKIT_AVAILABLE: + self._print_prompt() + continue + + if text.startswith('"""'): + text = await self._read_multiline_input(text) + if text is None or not text.strip(): + if not PROMPT_TOOLKIT_AVAILABLE: + self._print_prompt() + continue + + self._enqueue_message(text) + + finally: + if self._original_sigint is not None: + signal.signal(signal.SIGINT, self._original_sigint) + self._original_sigint = None + + async def stop(self, ctx: ChannelContext) -> None: + logger.info("Terminal channel stopping") + ctx.cancel_event.set() + if self._original_sigint is not None: + signal.signal(signal.SIGINT, self._original_sigint) + self._original_sigint = None + await self.clear_typing("terminal-default") + sys.stdout.write("\n") + sys.stdout.flush() + + async def send_text(self, target_id: str, content: str) -> None: + sys.stdout.write("\n") + if RICH_AVAILABLE: + _rich_console.print(Markdown(content)) + else: + sys.stdout.write(content) + sys.stdout.write("\n") + sys.stdout.flush() + if not PROMPT_TOOLKIT_AVAILABLE: + self._print_prompt() + + async def send_typing(self, target_id: str, thread_id: str | None = None) -> None: + if not sys.stdin.isatty(): + return + self._is_typing = True + self._typing_task = asyncio.create_task(self._typing_animation()) + + async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None: + self._is_typing = False + if self._typing_task and not self._typing_task.done(): + self._typing_task.cancel() + try: + await self._typing_task + except asyncio.CancelledError: + pass + self._typing_task = None + sys.stdout.write("\r\033[K") + sys.stdout.flush() + + def _enqueue_message(self, text: str) -> None: + self._history.append(text) + if len(self._history) > 100: + self._history.pop(0) + self._msg_counter += 1 + msg_id = f"term:{uuid4().hex[:12]}" + + msg = UnifiedMessage( + msg_id=msg_id, + channel_type="terminal", + account_id="default", + content=text, + message_type=MessageType.TEXT, + sender=PeerInfo( + kind=PeerKind.DIRECT, + id="terminal-user", + display_name="Dev", + ), + timestamp=None, + ) + + asyncio.create_task(self._put_message(msg)) + + async def _put_message(self, msg: UnifiedMessage) -> None: + if self._ctx is None: + return + if self._ctx.queue.qsize() >= self._ctx.queue.maxsize * 0.8: + self._print_warning("消息队列繁忙,请稍候...") + await self._ctx.queue.put(msg) + + def _handle_command(self, text: str) -> bool: + try: + parts = shlex.split(text) + except ValueError: + return False + if not parts or not parts[0].startswith("/"): + return False + + cmd_name = parts[0][1:].lower() + handler = self._commands.get(cmd_name) + if handler and self._ctx: + handler(self._ctx) + return True + return False + + @staticmethod + def _cmd_help(ctx: ChannelContext) -> None: + help_text = ( + "\n可用命令:\n" + " /help 显示此帮助信息\n" + " /exit, /quit 退出 Terminal 渠道\n" + " /clear 清屏\n" + " /history 显示输入历史\n" + " /model 切换模型\n" + "\n快捷键: Ctrl+C 中断回复 Ctrl+D 退出\n" + ) + sys.stdout.write(f"{help_text}\n") + sys.stdout.flush() + + @staticmethod + def _cmd_exit(ctx: ChannelContext) -> None: + sys.stdout.write("\nGoodbye!\n") + sys.stdout.flush() + ctx.cancel_event.set() + + @staticmethod + def _cmd_clear(ctx: ChannelContext) -> None: + sys.stdout.write("\033[2J\033[H") + sys.stdout.flush() + + @staticmethod + def _cmd_model(ctx: ChannelContext) -> None: + sys.stdout.write("\n模型切换请在系统中配置\n") + sys.stdout.flush() + + def _cmd_history(self, ctx: ChannelContext) -> None: + if not self._history: + sys.stdout.write("\n暂无历史记录\n") + sys.stdout.flush() + return + lines = ["\n历史记录:"] + for i, entry in enumerate(self._history, 1): + preview = entry[:60] + "..." if len(entry) > 60 else entry + lines.append(f" {i}. {preview}") + lines.append("") + sys.stdout.write("\n".join(lines) + "\n") + sys.stdout.flush() + + async def _read_stdin_line(self, multiline: bool = False) -> str | None: + loop = asyncio.get_event_loop() + + if PROMPT_TOOLKIT_AVAILABLE: + try: + line = await loop.run_in_executor( + None, + lambda: _prompt_session.prompt( + "... " if multiline else "> ", + multiline=multiline, + key_bindings=_kb if multiline else None, + ), + ) + return line + except EOFError: + raise + except KeyboardInterrupt: + return "" + + line = await loop.run_in_executor(None, sys.stdin.readline) + if not line: + return None + return line + + async def _read_multiline_input(self, first_line: str) -> str | None: + remaining = first_line[3:] + + if PROMPT_TOOLKIT_AVAILABLE: + try: + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + lambda: _prompt_session.prompt( + "... ", + multiline=True, + key_bindings=_kb, + default=remaining, + ), + ) + except (KeyboardInterrupt, EOFError): + return None + + lines = [] + if remaining.strip(): + lines.append(remaining.strip()) + while self._ctx and not self._ctx.cancel_event.is_set(): + sys.stdout.write("... ") + sys.stdout.flush() + try: + next_line = await self._read_stdin_line() + except EOFError: + break + if next_line is None: + break + stripped = next_line.strip() + if stripped == '"""': + break + lines.append(next_line.rstrip("\n")) + return "\n".join(lines) if lines else None + + async def _typing_animation(self) -> None: + frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + idx = 0 + while self._is_typing: + frame = frames[idx % len(frames)] + if RICH_AVAILABLE: + sys.stdout.write(f"\r[bold yellow]{frame}[/bold yellow] 思考中...") + else: + sys.stdout.write(f"\r{frame} 思考中...") + sys.stdout.flush() + idx += 1 + await asyncio.sleep(0.1) + + @staticmethod + def _print_greeting() -> None: + sys.stdout.write("Terminal 渠道已启动,输入问题与 Agent 对话") + sys.stdout.write("\n按 Ctrl+C 中断回复,Ctrl+D 退出\n") + if not PROMPT_TOOLKIT_AVAILABLE: + sys.stdout.write("\n> ") + sys.stdout.flush() + + @staticmethod + def _print_prompt() -> None: + sys.stdout.write("> ") + sys.stdout.flush() + + @staticmethod + def _print_warning(text: str) -> None: + sys.stdout.write(f"\n[{text}]\n") + sys.stdout.flush()