开源一个「Agent 能自己改造界面」的客户端:界面是输出,多 Agent 是内核

开源一个「Agent 能自己改造界面」的客户端:界面是输出,多 Agent 是内核

现在的 AI 客户端,大概都长一个样:左边聊天,右边预览,顶上挂一排按钮。

用久了会发现三件事很别扭:

  1. 界面是写死的。 我想让 Agent 把预算数据做成一张带趋势的卡片,它只能说”你可以这样做”,然后我复制代码自己贴;
  2. Agent 看不见界面。 它不知道自己刚才画出来的东西长什么样、我点了哪个按钮,只能靠我打字描述;
  3. “危险操作”是假的。 要么全放行(等于把机器交出去),要么一个都不给(等于把 Agent 关进笼子),中间没有”我看着它做”这一档。

于是我花了一段时间写了一个自托管的客户端,今天正式开源:

github.com/jiafeimao-gjf/self-hosting-agent

它的核心主张只有一句:

界面是输出,多 Agent 是内核,一个 Agent 一个子进程。

它长什么样

先看图,这张是真实运行时的截图(不是设计稿):

真实界面:左侧对话是 Markdown 渲染的,右侧浏览器面板里跑着 Agent 自己写的一个可交互页面

左边是我和 Agent 的对话——Agent 输出的 Markdown 会被真正渲染(标题、列表、代码块、表格),工具调用是可读的一行摘要。

右边是内置浏览器面板,里面运行的不是我写的页面,而是 Agent 自己用 workspace.write 写出来的一个 universe.html:粒子宇宙、六条参数滑杆、还能点「让 AGENT 改造」把改动要求发回给 Agent。

这两件事加起来就是它和别的客户端最不一样的地方:Agent 不是在”描述”界面,它真的在产出界面。

三条不变量

整个项目围着三条规矩转:

四层架构:数据自下而上,权限自上而下

一、界面 = f(事件日志)。
界面不是状态,是投影。日志在,界面就能重建。所以刷新页面、重启进程、回放历史都不会让界面消失——这条规矩后来还顺手解决了”重启不丢界面”和”界面版本回滚”两个需求。

二、上下文 = f(事件日志)。
Agent 的记忆同样不是内存里的数组,而是日志的投影。窗口可以裁剪,事实不会丢。

三、权限自上而下收敛。
能力在内核,编排在运行时,表达在表面层。Agent 可以画界面、可以改客户端自己的源码,但改不了沙箱规则,也绕不过审批门。

Agent Loop:一轮五步

Agent Loop:一轮五步,外加四道预算闸门

循环住在子进程里(src/loop/loop.ts),它只跟两样东西打交道:模型端口和工具表。所有对外的副作用都必须走协议帧——它没有别的出口。

一轮是五步:组装上下文 → 模型推理 → 工具分发 → 外发界面意图 → 检查点。

为什么非要拆成子进程?三个实际理由:

  • 崩溃隔离:模型端口报错、工具卡死,只毁这一个进程,宿主照常服务;
  • 换模型 = 换进程:设置一改就回收重建,历史由日志投影而来,不丢;
  • 强边界:子进程拿不到宿主里的任何对象,它只能发帧。”能做”和”被允许做”是两件事。

另外四道闸门卡在每轮开头:轮数、工具调用总数、累计 token、墙钟时限。命中任一就 budget_exhausted 收工——收工、留痕,不假装成功。

工具调用与返回(这一段是重点)

这是我最想讲的部分,也是这个项目里设计得最”较真”的地方:

工具调用与返回:两类工具,一条回填链

一句话概括:子进程只负责”想”,真正动手的每一步都发生在宿主里——而且每一步都记账。

模型返回的只是”我想调 workspace.write,参数是这些”。接下来发生的事:

  1. 子进程发 tool.call 帧出去,然后挂起等回填(会等,但绝不永远等——有超时);
  2. 宿主收到帧,判断这是不是宿主工具。是的话:记 host.tool.call → 执行 → 记 host.tool.result;
  3. 如果是敏感动作(比如执行 shell 命令),中间还要过一道人类审批;
  4. 宿主发 tool.reply 帧回填,子进程的桥 resolve,循环继续;
  5. 结果被写进上下文({role:'tool', text:'[workspace.write] …'}),下一轮模型就能看到它。

三个不显然但很要紧的设计:

失败不致命。 工具不存在(UNKNOWN_TOOL)、宿主超时(HOST_TOOL_TIMEOUT)、被人类拒绝(审批被拒绝)——这三种都不是”抛出异常、整轮崩掉”,而是作为文本结果回到模型,让它自己决定下一步:换个工具、换个参数,或者干脆放弃并解释原因。

只声明真的注册了的工具。 工具表是唯一事实。这里踩过一个坑:早期的实现直接把”常量表”整个声明给子进程,于是模型会去调一个根本没注册的工具,拿回一个 UNKNOWN_TOOL,然后困惑地重试。现在只声明实际注册的那些——没启用 shell 时,模型连 shell.run 都看不到。

名字有两个空间。 内部工具名允许带点(ui.render),但 OpenAI 接口的 tools[0].function.name 只接受 ^[a-zA-Z0-9_-]+$。适配器负责外发时净化、回收时映射回来,内部名字保持不变。

不给”安全的 shell”,给”被看着的 shell”

上面第 3 步里的”审批”,值得单独说。

给 Agent 跑 shell 等于给出用户级任意命令执行:能读 ~/.ssh,能删文件,能往外发数据。想靠”过滤几个危险命令”解决是徒劳的——$(...)、base64、管道、脚本文件,随便绕过。

所以我给的立场是:**不给”安全的 shell”,给”被看着的 shell”**。

审批对话框:完整命令原文 + 风险等级 + 三个按钮

  • 默认不存在:不加 --allow-shell,shell.run 根本不注册,模型看不到这个工具,没有”试一试”的机会;
  • 每条都要人批:启用后每次调用都弹对话框,把完整命令原文摆在人面前。”人类在场即放行”这条策略对 shell 无效;
  • 没人在就是拒绝:没有客户端连着时立即 deny(fail closed),不挂住;等人有上限(默认 10 分钟),超时按拒绝;
  • 环境变量白名单:只传 PATH/HOME/LANG/TERM/TMPDIR 等,AGENT_API_KEY 绝不进 shell——否则 env 一条命令就把 Key 打出来了;
  • 进程治理:独立进程组 + 超时 kill(-pid),sleep 100 & 这类后台子进程会随整棵树一起回收;
  • 审计含拒绝:每次调用都写日志:命令、决定、退出码、耗时、是否截断。被拒绝的也留痕。

这套设计在开发过程中被真机验证过一次,很有意思:我在调试时用另一个浏览器窗口做别的事,把客户端的页面顶掉了,SSE 连接断开——此时 Agent 恰好请求执行 ls -la,系统立即拒绝并在日志里留下 decision: "deny"。

没有客户端在,就不放行。这不是我特意测的,是它自己按规矩办的。

事件日志即真相

事件日志即真相:事实 → 投影 → 传输 → 呈现

日志不只是”记个流水”,它是这个项目里唯一的事实来源:界面的持久化、对话的回放、上下文的投影、审计与排障,全都从这一份 append-only 的事件流里长出来。

一个具体的例子:界面面板原本”画完就只活在内存里”,重启就没了。修法不是新写一套存储,而是让界面改动先成为日志里的一条事实(写一条 ui.patch 事件),启动时按序回放。已有不变量直接覆盖了新需求。

代价是要想清楚两件事:回放必须幂等(回放不能再写事件,否则每重启一次日志滚一倍),被拒绝的改动不能记(它没改任何东西,记了只会让回放重复走一遍拒绝路径)。

还有一个真踩过的坑,写在这里给做类似系统的朋友提个醒:

清空对话的”边界”取自宿主日志的 seq,却被拿去过滤子进程日志的事件——它们是两个文件、两套完全独立的号段。小场景里两边号段接近,侥幸正确;真模型跑一会儿,宿主事件多、号段涨得更快,一旦超过子进程的序号,整条回复被过滤掉。症状是”流式输出完了,整条消息消失”。

别拿两个独立编号体系作比较。 这个 bug 让我排查了很久。

工程方法:规格和测试,一条都不许脱节

这个项目是按 SDD + TDD 混合驱动做的,而且把”咬合”本身做成了门禁:

  • 需求先写成可验收的判据,每条有稳定 ID(SHELL-002、STREAM-004…),写在 specs/ 里;
  • 每条判据钉一个测试,测试里标注 // @spec SHELL-002;
  • npm run check 跑一个双向门禁:规格里有判据没测试 → 报错;测试引用了不存在的判据 → 报错。

现在的状态是 26 份规格、285 条判据、316 个测试,285/285 全覆盖,0 悬空引用。

门禁还带一个自检,因为它自己骗过人:曾经 ID 正则写得偏窄,而新规格用了更长的前缀,于是整份规格对门禁隐形,门禁还高高兴兴报”全部覆盖”。现在”某个规格文件一条判据都没解析出来”会直接报错——门禁必须对自己的盲区发火,否则它给的是虚假安全感。

另外一个小目标:零第三方运行时依赖。Markdown 渲染器、SSE 解析、追溯门禁、进程池、邮箱、任务板都是自己写的。这不是为了炫技,是为了让这个”Agent 可以自己改自己”的项目,依赖面足够小、可审计。

它现在能做什么

  • 多对话 + 独立工作空间:每个对话独立的事件日志、Agent 进程、工作空间、模型配置、界面历史;
  • 模型可换:OpenAI 兼容协议与 Anthropic 协议都支持(本机 Ollama 也行),全局默认 + 对话级覆盖,输入框旁边就能切;
  • 流式输出:两套协议都支持流式,网关不认流式时自动降级;
  • 内置浏览器面板:渲染 Agent 写下的 HTML 文件,页面里的交互经桥回流成一条人类动作;
  • 界面版本列表:每一版都有时间戳和区块数,点一下回到那一版;重启不丢;
  • 审批对话框:敏感动作由人逐次批准;
  • 自举:Agent 可以改客户端自己的源码(改 .css 无刷新生效,改 .js/.html 给一个必须由人点击的刷新按钮),而人类永远有一键回滚。

快速开始

需要 Node 24+(用原生 TS 与 node:test,不需要构建步骤):

git clone git@github.com:jiafeimao-gjf/self-hosting-agent.git
cd self-hosting-agent
npm run check     # 规格追溯门禁 + 全量测试(316 个)
npm run demo      # 端到端演示:拉起 Agent Loop 子进程,跑完五步状态机
npm run serve     # 起客户端,浏览器打开 http://127.0.0.1:4311

npm run serve 启动后,在「设置」里填任意 OpenAI 兼容端点(或本机 Ollama),就能用了。

想试 shell 审批那条链路:npm run serve -- --allow-shell。

诚实的边界

写完能跑和”产品级”之间还有距离,目前明确的缺口是:

  • 没有 Electron / Tauri 外壳,现在是”本地服务 + 浏览器”;
  • 内置浏览器面板还不支持真实 URL 导航(只渲染工作空间里的 HTML 文件);
  • 客户端组件的热更新是”CSS 无刷新、JS/HTML 需人工点一下刷新”,不是完整的 HMR;
  • 开发与主力验证在 macOS;CI 在 Ubuntu + Node 24 上跑全量门禁,但没有做过 Windows 验证。

这些我都写进了 README 的边界一节——比让人踩到之后才发现要诚实一些。

最后

这个项目的出发点是:如果客户端是 Agent 与人类交流的窗口,那这个窗口本身就应该能被 Agent 改造。

界面不是 Agent 的”回复”,而是它的输出;工具调用不是”函数调用”,而是一条要留痕、要过审批、失败也不致命的链;而整套东西的可信度,最后落在”每一条承诺都有测试守着”这件事上。

仓库地址:https://github.com/jiafeimao-gjf/self-hosting-agent(Apache-2.0)

架构细节整理成了一份自包含的 HTML,放在 docs/architecture.html,双击即看,也可以当幻灯片翻页。

如果这个思路对你有用,欢迎 star、提 issue,或者直接告诉我想让 Agent 画出什么样的界面——说不定它自己就能改了。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1056615746@qq.com

赏

文章标题:开源一个「Agent 能自己改造界面」的客户端:界面是输出,多 Agent 是内核

字数:3.2k

本文作者:攀登

发布时间:2026-10-05, 11:30:00

最后更新:2026-10-05, 11:13:14

原始链接:http://jiafeimao-gjf.github.io/2026/10/05/%E5%BC%80%E6%BA%90%E4%B8%80%E4%B8%AAAgent%E8%83%BD%E8%87%AA%E5%B7%B1%E6%94%B9%E9%80%A0%E7%95%8C%E9%9D%A2%E7%9A%84%E5%AE%A2%E6%88%B7%E7%AB%AF/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

×

Help us with donation