Skip to content

CLI / TUI / Web

源码版本v2026.7.20

职责

界面层是用户触达 agent 的另一组入口(除网关消息平台外):CLI(hermes_cli/,命令行子命令体系)、TUI(ui-tui/,TypeScript 终端 UI + tui_gateway/ Python 桥)、Web(web/,浏览器界面)。三者都最终把用户输入送进 agent 的 run_conversation,把输出渲染回来。它们和网关层是并列关系——网关接外部平台,界面层接本地用户。

关键文件

CLI

TUI

Web

CLI 子命令表面

hermes_cli/_parser.py 把顶层 argparse 单独抽出来,这样 relaunch.py 等模块能在不跑 main 的前提下内省有哪些 flag。解析器只管 chat 子命令和全局选项,其他子命令在 main.py 里就地构造(和 cmd_* 函数强耦合)。_EPILOGUE 里这串示例就是用户面对的子命令全景:

python
hermes                        Start interactive chat
hermes --tui                  Launch the modern TUI
hermes auth add <provider>    Add a pooled credential
hermes gateway                Run messaging gateway
hermes dashboard              Start web UI dashboard (port 9119)

CLI 既是「对话入口」,也是「运维面板」——auth/model/config/gateway/dashboard/backup 全挂在同一棵子命令树上。只有 chat 及其衍生(-c/--resume)会进 run_conversation,其他直接走各自模块。

TUI 网关的 stdin/JSON-RPC 主循环

TUI 把终端渲染放在 TypeScript 端(ui-tui/),Python 端只跑一个轻量 tui_gateway 桥。两边用 stdin/stdout 上的 JSON-RPC 通信。tui_gateway/entry.pymain() 核心是一个 readline + dispatch 循环(tui_gateway/entry.py):

python
while True:
    raw = sys.stdin.readline()
    if not raw:
        # Stdin fell through — spurious EOF (child flipped O_NONBLOCK)
        # or genuine close.  handle_spurious_eof 决定是否继续。
        if not handle_spurious_eof(_recovery_times, _log_exit):
            break
        continue
    try:
        req = json.loads(raw.strip())
    except json.JSONDecodeError:
        write_json({"jsonrpc": "2.0", "error": {"code": -32700, ...}, "id": None})
        continue
    resp = dispatch(req)

几个工程细节得留意:handle_spurious_eof 处理子进程翻转 O_NONBLOCK 造成的「假 EOF」;_log_exit 在 stdout 写失败时留一份崩溃日志;SIGPIPE 被显式忽略(signal.signal(signal.SIGPIPE, signal.SIG_IGN)),避免后台 TTS/beep 线程写已关管道把整个进程拖死。

三者最终都汇到 run_conversation

无论从哪条入口进来,对话类的请求最终都调到 AIAgent.run_conversation——它本身只是个转发器,真正的主循环在 agent/conversation_loop.py(run_agent.py:6350):

python
def run_conversation(self, user_message, system_message=None,
                     conversation_history=None, ..., moa_config=None) -> Dict[str, Any]:
    """Forwarder — see ``agent.conversation_loop.run_conversation``."""
    from agent.conversation_loop import run_conversation
    from agent.portal_tags import set_conversation_context
    token = set_conversation_context(self._conversation_root_id())
    with scoped_runtime_main({}):
        return run_conversation(self, user_message, ...)

会话 ID 通过 ContextVar 推下去——主循环内的所有 LLM 调用(压缩、视觉、MoA slot、后台审视 fork)都自动带上同一个标签,不需要在每个调用点显式传。Agent 实例由 init_agent(agent/agent_init.py:276)装配,签名里一长串 callback 参数(step_callback/stream_delta_callback/event_callback/...)就是三种壳的钩子——同一份 agent,不同壳挂不同回调,渲染形态就完全不同。

数据流

  1. CLI:hermes <subcommand>hermes_cli/_parser.py 解析 → 子命令模块(auth/backup/blueprint/...)执行;对话类命令最终调 AIAgent.run_conversation:6350
  2. TUI:ui-tui/(TS 前端)经 WebSocket 连 tui_gateway/ws.py,tui_gateway 把终端事件桥到 agent(tui_gateway/server.py 持有 agent),输出经 tui_gateway/event_publisher.py 推回前端。
  3. Web:web/(Vite 前端)同样经后端连 agent,渲染对话与工具结果。
  4. 三者的 agent 实例都经 init_agent:276 装配,共享同一套能力层。

设计动机

为什么三套壳共用一个 run_conversation 而不是各自跑一套循环?核心是「渲染差异 vs 业务一致」。流式 token、工具进度、事件格式是壳的事;会话状态、工具调用、预算扣减、记忆注入是 agent 的事。把壳隔离在回调层,换壳不用改业务,加业务也不用三处都改。tui_gateway 单独存在是因为 TS 端无法直接持有 Python agent——它是语言桥,不是业务层。

边界与失败

  • TUI 的 stdin/stdout 是 JSON-RPC 管道,不是日志print 调试乱写 stdout 会把 TUI 解析崩;Python 端日志走 stderr,会被 TUI 当作 gateway.stderr 事件投到 Activity 面板。
  • tui_gateway 子进程崩溃后 TUI 不会自动重连_CRASH_LOG 是事后取证用的,不是恢复机制——用户得手动重启 hermes --tui
  • Web 不是 TUI 的替代品web/hermes dashboard(端口 9119),定位是本地控制台 + 长会话查看,不是远程多用户访问的 web 产品。

小结

界面层 = 三种壳(CLI/TUI/Web)+ 一个 tui_gateway 桥。它们不持业务,只负责输入输出形态。与网关层并列:网关接外部消息平台,界面层接本地终端/浏览器。ACP(见此)则接 IDE,构成完整的「触达面」。

非官方社区学习站,内容以 MIT 许可的 NousResearch/hermes-agent 源码为依据。