"""Boardware Genius 命令行入口。 本文件职责: 1. 定义所有 CLI 命令(onboard / agent / gateway / cron / channels / provider) 2. 组装运行时依赖(Config、AgentLoop、MessageBus、ChannelManager 等) 3. 提供交互式终端体验(prompt_toolkit + rich) 阅读建议: - 先看 `onboard()`,理解配置与工作区如何初始化 - 再看 `agent()`,理解 CLI 单轮与交互模式 - 最后看 `gateway()`,理解多渠道常驻运行模式 """ import asyncio import os import signal from pathlib import Path import select import sys import typer from rich.console import Console from rich.markdown import Markdown from rich.table import Table from rich.text import Text from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML from prompt_toolkit.history import FileHistory from prompt_toolkit.patch_stdout import patch_stdout from nanobot import __brand__, __version__ from nanobot.config.schema import Config app = typer.Typer( name="nanobot", help=f"{__brand__} - Personal AI Assistant", no_args_is_help=True, ) console = Console() # 交互模式下可用于退出会话的命令集合(统一做 lower() 比较)。 EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} # --------------------------------------------------------------------------- # CLI input: prompt_toolkit for editing, paste, history, and display # --------------------------------------------------------------------------- _PROMPT_SESSION: PromptSession | None = None _SAVED_TERM_ATTRS = None # original termios settings, restored on exit def _flush_pending_tty_input() -> None: """Drop unread keypresses typed while the model was generating output.""" # 目的:避免“模型输出期间用户按键残留”,导致下一次输入提示符出现脏字符。 # 对于 TTY 终端,尽量清空 stdin 缓冲;非 TTY 场景直接返回。 try: fd = sys.stdin.fileno() if not os.isatty(fd): return except Exception: return try: import termios # 优先使用系统原生 tcflush,最可靠。 termios.tcflush(fd, termios.TCIFLUSH) return except Exception: pass try: # 兼容兜底:通过非阻塞 read 手动把可读缓冲清掉。 while True: ready, _, _ = select.select([fd], [], [], 0) if not ready: break if not os.read(fd, 4096): break except Exception: return def _restore_terminal() -> None: """Restore terminal to its original state (echo, line buffering, etc.).""" # 某些情况下(Ctrl+C、中断、异常退出)终端可能残留“无回显”等状态, # 这里恢复到启动 prompt_toolkit 之前的属性,避免终端被“弄坏”。 if _SAVED_TERM_ATTRS is None: return try: import termios termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) except Exception: pass def _init_prompt_session() -> None: """Create the prompt_toolkit session with persistent file history.""" global _PROMPT_SESSION, _SAVED_TERM_ATTRS # 保存当前终端状态,退出交互模式时恢复。 try: import termios _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) except Exception: pass history_file = Path.home() / ".nanobot" / "history" / "cli_history" history_file.parent.mkdir(parents=True, exist_ok=True) _PROMPT_SESSION = PromptSession( # FileHistory 会把输入历史持久化到本地文件,支持上下键回看历史命令。 history=FileHistory(str(history_file)), enable_open_in_editor=False, multiline=False, # Enter submits (single line mode) ) def _print_agent_response(response: str, render_markdown: bool) -> None: """Render assistant response with consistent terminal styling.""" # 同一出口统一渲染回复:便于 CLI 单轮和交互模式共用显示逻辑。 content = response or "" body = Markdown(content) if render_markdown else Text(content) console.print() console.print(f"[cyan]{__brand__}[/cyan]") console.print(body) console.print() def _is_exit_command(command: str) -> bool: """Return True when input should end interactive chat.""" # 注意:调用方会先 .strip(),这里仅负责集合匹配。 return command.lower() in EXIT_COMMANDS async def _read_interactive_input_async() -> str: """Read user input using prompt_toolkit (handles paste, history, display). prompt_toolkit natively handles: - Multiline paste (bracketed paste mode) - History navigation (up/down arrows) - Clean display (no ghost characters or artifacts) """ if _PROMPT_SESSION is None: raise RuntimeError("Call _init_prompt_session() first") try: # patch_stdout 可避免“后台日志输出”打乱 prompt_toolkit 输入界面。 with patch_stdout(): return await _PROMPT_SESSION.prompt_async( HTML("You: "), ) except EOFError as exc: # Ctrl+D 等 EOF 统一转为 KeyboardInterrupt,简化上层退出处理。 raise KeyboardInterrupt from exc def version_callback(value: bool): """处理 --version/-v 选项并立即退出。""" if value: console.print(f"{__brand__} v{__version__}") raise typer.Exit() @app.callback() def main( version: bool = typer.Option( None, "--version", "-v", callback=version_callback, is_eager=True ), ): """Boardware Genius - Personal AI Assistant.""" pass # ============================================================================ # Onboard / Setup # ============================================================================ @app.command() def onboard(): """Initialize Boardware Genius configuration and workspace.""" from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path # 第 1 步:确定配置文件路径(默认是 ~/.nanobot/config.json) # 这个路径由 config.loader.get_config_path() 统一管理,避免硬编码路径。 config_path = get_config_path() # 第 2 步:处理配置文件 # - 如果已有配置:给用户两个选择(覆盖重置 / 刷新保留旧值) # - 如果没有配置:直接创建默认配置 if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") console.print(" [bold]y[/bold] = overwrite with defaults (existing values will be lost)") console.print(" [bold]N[/bold] = refresh config, keeping existing values and adding new fields") if typer.confirm("Overwrite?"): # 覆盖模式:直接写入一个全新的默认 Config config = Config() save_config(config) console.print(f"[green]✓[/green] Config reset to defaults at {config_path}") else: # 刷新模式:先读取旧配置,再按新 schema 重新保存 # 这样可以保留用户已有值,同时补全新版本新增字段。 config = load_config() save_config(config) console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)") else: # 首次安装:写入默认配置 save_config(Config()) console.print(f"[green]✓[/green] Created config at {config_path}") # 第 3 步:准备工作区(默认 ~/.nanobot/workspace) # get_workspace_path() 内部会 expanduser 并保证目录存在。 workspace = get_workspace_path() if not workspace.exists(): # 这里是额外保险:即使 helper 已创建过,重复 mkdir 也安全(exist_ok=True)。 workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") # 第 4 步:把内置模板文件写入工作区(只在文件不存在时创建) # 这些文件会参与系统提示词构建,例如 AGENTS.md / USER.md / TOOLS.md。 _create_workspace_templates(workspace) # 第 5 步:输出下一步操作提示,指导用户继续配置 API Key 并开始对话。 console.print(f"\n{__brand__} is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") console.print(" 2. Chat with Boardware Genius: [cyan]nanobot agent -m \"Hello!\"[/cyan]") console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") def _create_workspace_templates(workspace: Path): """Create default workspace template files from bundled templates.""" from importlib.resources import files as pkg_files # 从安装包里定位模板目录 nanobot/templates # 注意:这里是“包内资源”,不是当前工作目录的相对路径。 templates_dir = pkg_files("nanobot") / "templates" # 把 templates 根目录下的 .md 文件复制到 workspace 根目录。 # 采用“仅缺失时创建”策略,避免覆盖用户已编辑的文件。 for item in templates_dir.iterdir(): if not item.name.endswith(".md"): continue dest = workspace / item.name if not dest.exists(): dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") console.print(f" [dim]Created {item.name}[/dim]") # memory 目录用于长期记忆和历史归档。 memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) # 创建 memory/MEMORY.md:长期记忆(可被 agent 读取并更新) memory_template = templates_dir / "memory" / "MEMORY.md" memory_file = memory_dir / "MEMORY.md" if not memory_file.exists(): memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") console.print(" [dim]Created memory/MEMORY.md[/dim]") # 创建 memory/HISTORY.md:追加式历史日志,便于 grep 检索。 history_file = memory_dir / "HISTORY.md" if not history_file.exists(): history_file.write_text("", encoding="utf-8") console.print(" [dim]Created memory/HISTORY.md[/dim]") # 创建 skills 目录:存放用户自定义技能(workspace 级别,优先级高于内置技能)。 (workspace / "skills").mkdir(exist_ok=True) def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider from nanobot.providers.custom_provider import CustomProvider # 根据模型名推断 provider;schema.Config 内部已经实现匹配规则。 model = config.agents.defaults.model provider_name = config.get_provider_name(model) p = config.get_provider(model) # OpenAI Codex (OAuth) if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider( default_model=model, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM if provider_name == "custom": return CustomProvider( api_key=p.api_key if p else "no-key", api_base=config.get_api_base(model) or "http://localhost:8000/v1", default_model=model, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) # LiteLLM 通道:绝大多数 provider 走这里。 from nanobot.providers.registry import find_by_name spec = find_by_name(provider_name) if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) return LiteLLMProvider( api_key=p.api_key if p else None, api_base=config.get_api_base(model), default_model=model, extra_headers=p.extra_headers if p else None, provider_name=provider_name, request_timeout_seconds=p.request_timeout_seconds if p else 600, ) # ============================================================================ # Gateway / Server # ============================================================================ @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """启动 Boardware Genius 网关常驻服务。 这是“生产运行入口”之一,主要职责: 1. 初始化配置、总线、模型提供方、会话管理、Agent 主循环; 2. 启动渠道监听(Telegram/Slack/Discord/...); 3. 启动 cron 定时任务与 heartbeat 心跳任务; 4. 在进程退出时按顺序清理所有长连接与后台任务。 与 `agent` 命令的区别: - `gateway` 是常驻服务,负责“自动触发类任务”(cron/heartbeat); - `agent` 更偏交互调试/本地会话,不默认承担常驻调度职责。 """ from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.runtime import run_cron_job from nanobot.session.manager import SessionManager from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService from nanobot.utils.helpers import get_cron_store_path # verbose 模式仅放大 Python logging 级别,便于排查启动和连接问题。 if verbose: import logging logging.basicConfig(level=logging.DEBUG) console.print(f"{__brand__}: starting gateway on port {port}...") # 运行时核心对象初始化顺序: # config -> bus -> provider -> sessions -> cron -> agent -> channels -> heartbeat config = load_config() bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) # 先创建 CronService(后续拿到 agent 再注入执行回调)。 # 这样可保证 cron 与 agent 使用同一运行时实例,避免上下文不一致。 cron_store_path = get_cron_store_path(config.workspace_path) cron = CronService(cron_store_path) # 创建 AgentLoop 并注入 cron_service。 # 注意:这里只是“把 cron 工具能力挂到 agent”,真正定时执行要靠 cron.start()。 agent = AgentLoop( bus=bus, provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, temperature=config.agents.defaults.temperature, max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, a2a_config=config.tools.a2a, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, authz_config=config.authz, backend_identity=config.backend_identity, gateway_port=config.gateway.port, ) # 把 cron 执行回调绑定到 agent:定时触发时会走一次完整 agent 处理流程。 # 回调契约:输入 CronJob,返回本次执行得到的文本结果(可为空)。 async def on_cron_job(job: CronJob): """通过 AgentLoop 执行单个 cron 任务并按配置投递结果。 关键点: - task 型任务优先复用创建时的 session_key,保留原会话上下文; - 若任务未记录来源 session,才回退到 `cron:{job.id}` 隔离会话; - channel/chat_id 从 job payload 读取,不存在时回退到 `cli:direct`; - 仅当 `deliver=True` 且 `to` 非空时,才把结果真正发到渠道。 """ return await run_cron_job( job, agent=agent, bus=bus, default_channel="cli", default_chat_id="direct", ) cron.on_job = on_cron_job # 渠道管理器负责建立外部 IM 连接并把消息接入 MessageBus。 channels = ChannelManager(config, bus) def _pick_heartbeat_target() -> tuple[str, str]: """为 heartbeat 选择一个“可路由”的目标会话。 选择策略(按优先级): 1. 最近活跃且属于启用渠道的外部会话; 2. 若没有可用外部会话,回退到 `cli:direct`。 """ enabled = set(channels.enabled_channels) # Prefer the most recently updated non-internal session on an enabled channel. for item in session_manager.list_sessions(): key = item.get("key") or "" if ":" not in key: continue channel, chat_id = key.split(":", 1) if channel in {"cli", "system"}: continue if channel in enabled and chat_id: return channel, chat_id # 若没有可路由外部会话,退回 CLI 虚拟会话。 return "cli", "direct" # 心跳服务:周期性触发 agent 读取 HEARTBEAT.md # 设计目标是“后台自检/主动推进”,不是抢占用户会话。 async def on_heartbeat(prompt: str) -> str: """执行一次 heartbeat prompt,得到 agent 输出。""" channel, chat_id = _pick_heartbeat_target() async def _silent(*_args, **_kwargs): pass return await agent.process_direct( prompt, session_key="heartbeat", channel=channel, chat_id=chat_id, on_progress=_silent, # suppress: heartbeat should not push progress to external channels ) async def on_heartbeat_notify(response: str) -> None: """把 heartbeat 结果投递到外部渠道(若存在可用目标)。""" from nanobot.bus.events import OutboundMessage channel, chat_id = _pick_heartbeat_target() if channel == "cli": return # No external channel available to deliver to await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, on_notify=on_heartbeat_notify, interval_s=30 * 60, # 30 minutes enabled=True ) if channels.enabled_channels: console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]✓[/green] Cron: {cron_status['jobs']} scheduled jobs") console.print(f"[green]✓[/green] Heartbeat: every 30m") async def run(): """网关主协程:并发拉起 cron/heartbeat/agent/channels 并统一收尾。""" # gateway 常驻主循环:并发运行 agent 消费循环 + 各渠道监听循环。 try: await cron.start() await heartbeat.start() await asyncio.gather( agent.run(), channels.start_all(), ) except KeyboardInterrupt: console.print("\nShutting down...") finally: # 统一清理顺序,尽量避免资源泄漏(MCP 连接、定时器、渠道连接等)。 await agent.close_mcp() heartbeat.stop() cron.stop() agent.stop() await channels.stop_all() asyncio.run(run()) # ============================================================================ # Web Commands # ============================================================================ @app.command() def web( port: int = typer.Option(18080, "--port", "-p", help="Web API server port"), host: str = typer.Option("0.0.0.0", "--host", help="Web API host"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """启动单用户 Web 后端(用于前后端分离场景)。""" import uvicorn from nanobot.config.loader import load_config from nanobot.web.server import create_app if verbose: import logging logging.basicConfig(level=logging.DEBUG) config = load_config() config.gateway.port = port _create_workspace_templates(config.workspace_path) console.print(f"{__brand__}: starting web backend on {host}:{port}...") web_app = create_app(config=config) uvicorn.run(web_app, host=host, port=port) # ============================================================================ # Agent Commands # ============================================================================ @app.command() def agent( message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"), markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), logs: bool = typer.Option(False, "--logs/--no-logs", help="Show Boardware Genius runtime logs during chat"), ): """直接与 agent 交互(单轮模式或交互模式)。 两种工作形态: - `-m/--message`:单轮执行,输入一次得到一次回复后退出; - 无 message:进入交互循环,持续走 bus 的 inbound/outbound 链路。 说明: - 这里也会注入 CronService,但默认不启动 cron 定时器; - 目的是保留“任务管理能力”(add/list/remove),而非常驻调度。 """ from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop from nanobot.cron.service import CronService from loguru import logger from nanobot.utils.helpers import get_cron_store_path # CLI 模式也复用与 gateway 基本一致的运行时组件。 config = load_config() bus = MessageBus() provider = _make_provider(config) # CLI 模式下也要注入 CronService,主要是为了支持 cron 工具链的“任务管理能力”: # 1) agent 在当前会话里调用 cron 相关工具时,需要统一的持久化入口(jobs.json)。 # 2) 这里默认不启动常驻调度循环,因此暂时不需要绑定 on_job 执行回调。 # 3) 真正按时间触发任务并回调执行,通常由 gateway 常驻模式在 start() 后接管。 cron_store_path = get_cron_store_path(config.workspace_path) cron = CronService(cron_store_path) if logs: logger.enable("nanobot") else: logger.disable("nanobot") agent_loop = AgentLoop( bus=bus, provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, temperature=config.agents.defaults.temperature, max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, a2a_config=config.tools.a2a, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, authz_config=config.authz, backend_identity=config.backend_identity, gateway_port=config.gateway.port, ) # `_thinking_ctx` 统一封装“思考中”UI 的上下文管理器。 # 设计原因: # 1) logs=True 时,终端会持续打印运行日志;如果同时显示 spinner, # 两者会争用同一行渲染区域,出现闪烁/覆盖,影响可读性。 # 2) 因此日志模式返回 `nullcontext()`(空上下文):保持 `with` 调用形态一致, # 但不额外渲染任何加载动画。 # 3) logs=False 时,终端较干净,使用 rich 的 `console.status(...)` 显示 spinner, # 给用户明确反馈“模型仍在处理”,避免误判为卡死。 # 4) 这里使用的 status/spinner 与当前 prompt_toolkit 输入流程兼容, # 不会破坏后续输入提示符状态。 def _thinking_ctx(): if logs: from contextlib import nullcontext # 空上下文:进入/退出都不做事,仅用于统一 with 接口。 return nullcontext() # 非日志模式下启用转圈动画,提升等待期间的交互感知。 return console.status(f"[dim]{__brand__} is thinking...[/dim]", spinner="dots") async def _cli_progress(content: str, *, tool_hint: bool = False) -> None: """CLI 进度回调:按 channels 配置过滤后渲染中间态输出。""" ch = agent_loop.channels_config if ch and tool_hint and not ch.send_tool_hints: return if ch and not tool_hint and not ch.send_progress: return console.print(f" [dim]↳ {content}[/dim]") if message: # 单轮模式:直接调用 agent.process_direct,不启动总线循环。 async def run_once(): with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress) _print_agent_response(response, render_markdown=markdown) await agent_loop.close_mcp() asyncio.run(run_once()) else: # 交互模式: # - 不直接调用 process_direct,而是走 MessageBus 完整链路; # - 路径与 Telegram/WhatsApp 等外部渠道一致(inbound -> agent -> outbound), # 便于在本地 CLI 复现真实运行行为与事件时序。 from nanobot.bus.events import InboundMessage # 初始化 prompt_toolkit 会话(历史记录、编辑能力、粘贴兼容等)。 _init_prompt_session() # 打印一次交互模式提示,告知退出方式。 console.print(f"{__brand__} interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") # session_id 解析规则: # 1) 传入 "channel:chat_id" 时,显式使用对应渠道与会话; # 2) 仅传入 "xxx" 时,默认视作 CLI 渠道下的 chat_id=xxx。 # 这样既支持模拟外部渠道,也兼容最常见的纯 CLI 对话场景。 if ":" in session_id: cli_channel, cli_chat_id = session_id.split(":", 1) else: cli_channel, cli_chat_id = "cli", session_id def _exit_on_sigint(signum, frame): # prompt_toolkit 场景下 Ctrl+C 需要主动恢复终端并快速退出。 _restore_terminal() console.print("\nGoodbye!") os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) async def run_interactive(): # 1) 启动 agent 主循环任务: # 它会持续消费 inbound 队列并把结果写入 outbound 队列。 bus_task = asyncio.create_task(agent_loop.run()) # 2) `turn_done` 是“当前用户这一轮是否完成”的同步信号。 # 初始 set() 表示当前没有待完成轮次(idle)。 turn_done = asyncio.Event() turn_done.set() # 存放“当前轮”最终回复文本(通常只取第一条主回复)。 turn_response: list[str] = [] async def _consume_outbound(): # 专门消费 outbound 队列,职责分三类: # - 进度消息(_progress):实时打印,不结束本轮; # - 当前轮主回复:写入 turn_response 并 set(turn_done); # - 轮次外消息(例如异步通知):即时打印。 while True: try: # 用短超时轮询,既能及时处理消息,也便于取消时快速退出。 msg = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) if msg.metadata.get("_progress"): # 进度消息可按配置开关过滤: # - 工具提示(_tool_hint) # - 普通进度文本 is_tool_hint = msg.metadata.get("_tool_hint", False) ch = agent_loop.channels_config if ch and is_tool_hint and not ch.send_tool_hints: pass elif ch and not is_tool_hint and not ch.send_progress: pass else: console.print(f" [dim]↳ {msg.content}[/dim]") elif not turn_done.is_set(): # 仍在等待“当前轮”结束:把正式回复记下来并唤醒等待方。 if msg.content: turn_response.append(msg.content) turn_done.set() elif msg.content: # 非当前轮的额外消息(如工具主动发送),直接展示。 console.print() _print_agent_response(msg.content, render_markdown=markdown) except asyncio.TimeoutError: # 轮询超时属于正常情况,继续等下一条 outbound。 continue except asyncio.CancelledError: # 外层 finally 会 cancel 本任务,这里优雅退出。 break # 独立启动 outbound 消费协程,避免主输入循环被队列消费阻塞。 outbound_task = asyncio.create_task(_consume_outbound()) try: while True: try: # 清掉模型输出期间残留按键,避免下一次输入提示符“脏输入”。 _flush_pending_tty_input() user_input = await _read_interactive_input_async() command = user_input.strip() if not command: # 空输入不发给 agent,直接进入下一轮读取。 continue if _is_exit_command(command): _restore_terminal() console.print("\nGoodbye!") break # 发布新一轮之前先重置轮次状态,防止误用上一轮结果。 turn_done.clear() turn_response.clear() # 把用户输入发布到 inbound 队列,交由 agent_loop.run() 处理。 await bus.publish_inbound(InboundMessage( channel=cli_channel, sender_id="user", chat_id=cli_chat_id, content=user_input, )) with _thinking_ctx(): # 等待本轮 agent 产出回复或结束信号。 await turn_done.wait() if turn_response: # 仅渲染当前轮收集到的主回复(通常第一条即可)。 _print_agent_response(turn_response[0], render_markdown=markdown) except KeyboardInterrupt: # 兼容未被 signal handler 接住的中断路径。 _restore_terminal() console.print("\nGoodbye!") break except EOFError: # Ctrl+D/管道 EOF 的统一退出路径。 _restore_terminal() console.print("\nGoodbye!") break finally: # 收尾顺序: # 1) 请求 agent 主循环停止; # 2) 取消 outbound 消费任务; # 3) 等待两者结束(忽略取消异常); # 4) 关闭 MCP 连接,避免资源泄漏。 agent_loop.stop() outbound_task.cancel() await asyncio.gather(bus_task, outbound_task, return_exceptions=True) await agent_loop.close_mcp() asyncio.run(run_interactive()) # ============================================================================ # Channel Commands # ============================================================================ channels_app = typer.Typer(help="Manage channels") app.add_typer(channels_app, name="channels") def _exit_after_group_help(ctx: typer.Context) -> None: """Print group help and exit successfully when no subcommand is provided.""" if ctx.invoked_subcommand is None: typer.echo(ctx.get_help()) raise typer.Exit() @channels_app.callback(invoke_without_command=True) def channels_main(ctx: typer.Context): _exit_after_group_help(ctx) @channels_app.command("status") def channels_status(): """展示渠道启用状态与关键配置摘要。 设计原则: - 让用户快速判断“渠道是否可用”; - 只显示必要摘要,不直接打印敏感凭据全量内容。 """ from nanobot.config.loader import load_config config = load_config() table = Table(title="Channel Status") table.add_column("Channel", style="cyan") table.add_column("Enabled", style="green") table.add_column("Configuration", style="yellow") # 下方按渠道逐项展示:是否启用 + 核心配置是否已填写。 # 为避免泄露敏感信息,token/app_id 仅展示前缀片段。 # WhatsApp wa = config.channels.whatsapp table.add_row( "WhatsApp", "✓" if wa.enabled else "✗", wa.bridge_url ) dc = config.channels.discord table.add_row( "Discord", "✓" if dc.enabled else "✗", dc.gateway_url ) # Feishu fs = config.channels.feishu fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]" table.add_row( "Feishu", "✓" if fs.enabled else "✗", fs_config ) # Mochat mc = config.channels.mochat mc_base = mc.base_url or "[dim]not configured[/dim]" table.add_row( "Mochat", "✓" if mc.enabled else "✗", mc_base ) # Telegram tg = config.channels.telegram tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" table.add_row( "Telegram", "✓" if tg.enabled else "✗", tg_config ) # Slack slack = config.channels.slack slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]" table.add_row( "Slack", "✓" if slack.enabled else "✗", slack_config ) # DingTalk dt = config.channels.dingtalk dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]" table.add_row( "DingTalk", "✓" if dt.enabled else "✗", dt_config ) # QQ qq = config.channels.qq qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]" table.add_row( "QQ", "✓" if qq.enabled else "✗", qq_config ) # Matrix mx = config.channels.matrix mx_config = f"user_id: {mx.user_id}" if mx.user_id else "[dim]not configured[/dim]" table.add_row( "Matrix", "✓" if mx.enabled else "✗", mx_config ) # Email em = config.channels.email em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]" table.add_row( "Email", "✓" if em.enabled else "✗", em_config ) console.print(table) def _get_bridge_dir() -> Path: """获取并准备本地 bridge 目录(如缺失则自动构建)。 返回值: - 可直接用于 `npm start` 的 bridge 运行目录。 处理流程: 1. 优先复用已构建产物; 2. 其次从安装包目录或源码目录复制; 3. 最后执行 npm install + npm run build。 """ import shutil import subprocess # bridge 运行目录统一放在用户数据目录,避免污染源码目录。 user_bridge = Path.home() / ".nanobot" / "bridge" # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) # 支持两种运行形态: # 1) pip 安装:bridge 可能在包数据里 # 2) 源码开发:bridge 在仓库根目录 pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) console.print(f"{__brand__}: setting up bridge...") # 重新复制并构建,确保 bridge 资源与当前版本同步。 user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) console.print("[green]✓[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) return user_bridge @channels_app.command("login") def channels_login(): """启动 bridge 并显示二维码登录流程(主要用于 WhatsApp)。""" import subprocess from nanobot.config.loader import load_config config = load_config() bridge_dir = _get_bridge_dir() console.print(f"{__brand__}: starting bridge...") console.print("Scan the QR code to connect.\n") # 可选注入 BRIDGE_TOKEN 做 bridge 鉴权。 env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) except subprocess.CalledProcessError as e: console.print(f"[red]Bridge failed: {e}[/red]") except FileNotFoundError: console.print("[red]npm not found. Please install Node.js.[/red]") # ============================================================================ # Cron Commands # ============================================================================ cron_app = typer.Typer(help="Manage scheduled tasks") app.add_typer(cron_app, name="cron") @cron_app.callback(invoke_without_command=True) def cron_main(ctx: typer.Context): _exit_after_group_help(ctx) @cron_app.command("list") def cron_list( all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"), ): """列出已配置的 cron 任务。""" from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.utils.helpers import get_cron_store_path # CLI 侧每次命令调用都“现读现用” store,避免长驻缓存带来的陈旧视图。 store_path = get_cron_store_path(load_config().workspace_path) service = CronService(store_path) jobs = service.list_jobs(include_disabled=all) if not jobs: console.print("No scheduled jobs.") return # 使用表格输出,便于快速对比任务 ID/调度表达式/状态。 table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") # Next Run 展示时优先按 job 的 tz 渲染,失败再回退本地时区显示。 import time from datetime import datetime as _dt from zoneinfo import ZoneInfo for job in jobs: # Format schedule if job.schedule.kind == "every": sched = f"every {(job.schedule.every_ms or 0) // 1000}s" elif job.schedule.kind == "cron": sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" # Format next run next_run = "" if job.state.next_run_at_ms: ts = job.state.next_run_at_ms / 1000 try: tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) # 状态列只反映 enabled 开关,不代表“最近执行是否成功”。 status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" table.add_row(job.id, job.name, sched, status, next_run) console.print(table) @cron_app.command("add") def cron_add( name: str = typer.Option(..., "--name", "-n", help="Job name"), message: str = typer.Option(..., "--message", "-m", help="Message or prompt for the job"), mode: str = typer.Option("task", "--mode", help="Execution mode: reminder or task"), session_key: str = typer.Option(None, "--session-key", help="Reuse an existing session for task jobs"), every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), to: str = typer.Option(None, "--to", help="Recipient for delivery"), channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), ): """新增 cron 任务(every / cron / at 三选一)。""" from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule from nanobot.utils.helpers import get_cron_store_path # tz 仅对 cron_expr 有意义,提前拦截无效组合,减少用户困惑。 if tz and not cron_expr: console.print("[red]Error: --tz can only be used with --cron[/red]") raise typer.Exit(1) normalized_mode = mode.strip().lower() if normalized_mode not in {"reminder", "task"}: console.print("[red]Error: --mode must be 'reminder' or 'task'[/red]") raise typer.Exit(1) payload_kind = "system_event" if normalized_mode == "reminder" else "agent_turn" # 三种调度类型互斥: # - every: 固定秒间隔 # - cron: cron 表达式 # - at: 单次执行 if every: # every 单位是秒;CronService 内部用毫秒。 schedule = CronSchedule(kind="every", every_ms=every * 1000) elif cron_expr: # cron 表达式调度,具体语义由 croniter + tz 解释。 schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: import datetime # ISO 格式解析失败会抛 ValueError(由下方 except 统一处理文案)。 dt = datetime.datetime.fromisoformat(at) schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) # 命令入口只是管理面:创建任务并写盘,不直接触发执行。 store_path = get_cron_store_path(load_config().workspace_path) service = CronService(store_path) try: job = service.add_job( name=name, schedule=schedule, message=message, payload_kind=payload_kind, session_key=session_key, deliver=deliver, to=to, channel=channel, ) except ValueError as e: console.print(f"[red]Error: {e}[/red]") raise typer.Exit(1) from e console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})") @cron_app.command("remove") def cron_remove( job_id: str = typer.Argument(..., help="Job ID to remove"), ): """删除指定 cron 任务(仅管理面,不执行 agent)。""" # 这里只做“管理面”删除,不触发 agent 流程。 from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.utils.helpers import get_cron_store_path store_path = get_cron_store_path(load_config().workspace_path) service = CronService(store_path) if service.remove_job(job_id): console.print(f"[green]✓[/green] Removed job {job_id}") else: console.print(f"[red]Job {job_id} not found[/red]") @cron_app.command("enable") def cron_enable( job_id: str = typer.Argument(..., help="Job ID"), disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"), ): """启用或禁用指定任务。""" # --disable 为 True 时,enabled=False;否则启用。 from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.utils.helpers import get_cron_store_path store_path = get_cron_store_path(load_config().workspace_path) service = CronService(store_path) job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" console.print(f"[green]✓[/green] Job '{job.name}' {status}") else: console.print(f"[red]Job {job_id} not found[/red]") @cron_app.command("run") def cron_run( job_id: str = typer.Argument(..., help="Job ID to run"), force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"), ): """手动立即执行一个任务(可选忽略禁用状态)。""" from loguru import logger from nanobot.config.loader import load_config from nanobot.cron.runtime import run_cron_job from nanobot.cron.service import CronService from nanobot.cron.types import CronExecutionResult, CronJob from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop from nanobot.utils.helpers import get_cron_store_path # 手动 run 只关心最终结果,默认关闭冗余日志,避免 CLI 输出噪声。 logger.disable("nanobot") config = load_config() provider = _make_provider(config) bus = MessageBus() # 为单次执行构建“轻量运行时”:只初始化执行链路,不启动 channels/gateway 常驻服务。 agent_loop = AgentLoop( bus=bus, provider=provider, workspace=config.workspace_path, model=config.agents.defaults.model, temperature=config.agents.defaults.temperature, max_tokens=config.agents.defaults.max_tokens, max_iterations=config.agents.defaults.max_tool_iterations, memory_window=config.agents.defaults.memory_window, brave_api_key=config.tools.web.search.api_key or None, exec_config=config.tools.exec, a2a_config=config.tools.a2a, restrict_to_workspace=config.tools.restrict_to_workspace, mcp_servers=config.tools.mcp_servers, channels_config=config.channels, authz_config=config.authz, backend_identity=config.backend_identity, gateway_port=config.gateway.port, ) store_path = get_cron_store_path(config.workspace_path) service = CronService(store_path) # 用列表容器保存异步回调结果,便于命令结束后在同步上下文打印。 # 这样可以把 on_job 内部拿到的 response 带出 asyncio.run 的作用域。 result_holder: list[str | None] = [] async def on_job(job: CronJob) -> CronExecutionResult: # 手动触发时也沿用“agent 处理 + session key 命名”策略。 result = await run_cron_job( job, agent=agent_loop, bus=bus, default_channel="cli", default_chat_id="direct", ) result_holder.append(result.response) return result service.on_job = on_job async def run(): # run_job 只是调用服务层入口,是否执行取决于 job.enabled 与 force 参数。 return await service.run_job(job_id, force=force) if asyncio.run(run()): console.print("[green]✓[/green] Job executed") if result_holder: _print_agent_response(result_holder[0], render_markdown=True) else: console.print(f"[red]Failed to run job {job_id}[/red]") # ============================================================================ # Status Commands # ============================================================================ @app.command() def status(): """展示 Boardware Genius 运行配置与 provider 状态概览。""" from nanobot.config.loader import load_config, get_config_path config_path = get_config_path() config = load_config() workspace = config.workspace_path console.print(f"{__brand__} Status\n") console.print(f"Config: {config_path} {'[green]✓[/green]' if config_path.exists() else '[red]✗[/red]'}") console.print(f"Workspace: {workspace} {'[green]✓[/green]' if workspace.exists() else '[red]✗[/red]'}") if config_path.exists(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") # 按 registry 顺序展示 provider 配置状态。 # OAuth 显示“已接入 OAuth”,本地 provider 显示 api_base。 for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) if p is None: continue if spec.is_oauth: # OAuth provider 不一定有 api_key,展示“OAuth 已接入”更符合真实状态。 console.print(f"{spec.label}: [green]✓ (OAuth)[/green]") elif spec.is_local: # Local deployments show api_base instead of api_key if p.api_base: console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]") else: console.print(f"{spec.label}: [dim]not set[/dim]") else: has_key = bool(p.api_key) console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}") # ============================================================================ # OAuth Login # ============================================================================ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") @provider_app.callback(invoke_without_command=True) def provider_main(ctx: typer.Context): _exit_after_group_help(ctx) _LOGIN_HANDLERS: dict[str, callable] = {} def _register_login(name: str): """注册 OAuth 登录处理器的小装饰器。 用法: - 通过 `@_register_login("provider_name")` 把函数挂入 `_LOGIN_HANDLERS`; - `provider login` 命令再按 provider 名称分发到对应处理函数。 """ def decorator(fn): _LOGIN_HANDLERS[name] = fn return fn return decorator @provider_app.command("login") def provider_login( provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"), ): """触发指定 OAuth provider 的登录流程。""" from nanobot.providers.registry import PROVIDERS # 命令行允许 hyphen 写法,这里归一化到 registry 的 underscore 名称。 key = provider.replace("-", "_") spec = next((s for s in PROVIDERS if s.name == key and s.is_oauth), None) if not spec: names = ", ".join(s.name.replace("_", "-") for s in PROVIDERS if s.is_oauth) console.print(f"[red]Unknown OAuth provider: {provider}[/red] Supported: {names}") raise typer.Exit(1) # 通过注册表映射到具体 provider 的登录函数,实现命令层与实现层解耦。 handler = _LOGIN_HANDLERS.get(spec.name) if not handler: console.print(f"[red]Login not implemented for {spec.label}[/red]") raise typer.Exit(1) console.print(f"{__brand__} OAuth Login - {spec.label}\n") handler() @_register_login("openai_codex") def _login_openai_codex() -> None: """OpenAI Codex OAuth 登录流程。 流程说明: 1. 先尝试读取本地缓存 token; 2. 若无可用 token,进入交互式设备授权; 3. 授权成功后输出账户标识,失败则非零退出。 """ try: from oauth_cli_kit import get_token, login_oauth_interactive token = None try: token = get_token() except Exception: pass if not (token and token.access): console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") token = login_oauth_interactive( print_fn=lambda s: console.print(s), prompt_fn=lambda s: typer.prompt(s), ) if not (token and token.access): console.print("[red]✗ Authentication failed[/red]") raise typer.Exit(1) console.print(f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]") except ImportError: console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") raise typer.Exit(1) @_register_login("github_copilot") def _login_github_copilot() -> None: """GitHub Copilot 设备流登录触发。 通过一次最小 LiteLLM 请求触发底层授权流程。 触发成功即表示 OAuth 凭证已写入可用缓存。 """ import asyncio console.print("[cyan]Starting GitHub Copilot device flow...[/cyan]\n") async def _trigger(): from litellm import acompletion await acompletion(model="github_copilot/gpt-4o", messages=[{"role": "user", "content": "hi"}], max_tokens=1) try: asyncio.run(_trigger()) console.print("[green]✓ Authenticated with GitHub Copilot[/green]") except Exception as e: console.print(f"[red]Authentication error: {e}[/red]") raise typer.Exit(1) if __name__ == "__main__": app()