feat(channel): 添加终端渠道扩展

新增终端(Terminal)渠道扩展,支持在 Yuxi 平台中通过命令行终端交互。

包含以下功能模块:
- plugin: 终端渠道插件核心,处理命令行输入输出交互
This commit is contained in:
Kris 2026-05-21 11:53:39 +08:00
parent 944c9cdbb6
commit 6f7e29e2a5
3 changed files with 722 additions and 0 deletions

View File

@ -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 <name>",
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)

View File

@ -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
}
}

View File

@ -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 <name> 切换模型\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()