ForcePilot/backend/package/yuxi/channel/extensions/terminal/plugin.py
Kris 6f7e29e2a5 feat(channel): 添加终端渠道扩展
新增终端(Terminal)渠道扩展,支持在 Yuxi 平台中通过命令行终端交互。

包含以下功能模块:
- plugin: 终端渠道插件核心,处理命令行输入输出交互
2026-05-21 11:53:39 +08:00

396 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()