feat(channel): 添加终端渠道扩展
新增终端(Terminal)渠道扩展,支持在 Yuxi 平台中通过命令行终端交互。 包含以下功能模块: - plugin: 终端渠道插件核心,处理命令行输入输出交互
This commit is contained in:
parent
944c9cdbb6
commit
6f7e29e2a5
304
backend/package/yuxi/channel/extensions/terminal/__init__.py
Normal file
304
backend/package/yuxi/channel/extensions/terminal/__init__.py
Normal 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)
|
||||||
23
backend/package/yuxi/channel/extensions/terminal/plugin.json
Normal file
23
backend/package/yuxi/channel/extensions/terminal/plugin.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
395
backend/package/yuxi/channel/extensions/terminal/plugin.py
Normal file
395
backend/package/yuxi/channel/extensions/terminal/plugin.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user