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