title: 一个人的 AI 工作台:把 agent 搬到手机上
tags: 工程实践,Agent,架构
这篇是内部文档的公开版:抹掉了内网地址、主机名和私有仓库地址,保留了架构、取舍与教训。
全程零 npm 依赖、单机运行,能改代码、发文章、推微信、存云盘、备份上 NAS。
一个人的"AI 工作台":手机发一句话 → 沙箱里的 coding agent 干活 → 结果推回手机。
全部自建、零 npm 依赖、单机运行;能改代码、发文章、推微信、存云盘、备份上 NAS。
一、全景
┌────────────── 手机(浏览器 / PWA / 微信)──────────────┐
│ 聊天、任务清单、工具卡片、附件、@ 引用文件 │
└──────────────────────┬───────────────────────────────┘
│ HTTP + SSE
家庭内网 / WireGuard
│
┌──────────────────────────────────────▼───────────────────────────────────┐
│ 控制端主机(WireGuard 节点 A) │
│ · SSH 隧道 -L 8093 → 沙箱 8099(局域网桥,供 局域网设备 访问) │
│ · launchd 托管隧道;Worker 部署(wrangler)在这里 │
└──────────────────────────────────────┬───────────────────────────────────┘
│ WireGuard 私有网段
┌──────────────────────────────────────▼───────────────────────────────────┐
│ 工作容器(WireGuard 节点 B,8GB / 256GB) │
│ pi-gw:8099 ──spawn──▶ pi --mode rpc(每会话一进程)──▶ 模型 API │
│ serve-web:8080(/workspace + /root/mdviewer 只读浏览 + /__fetch 取文) │
│ watchdog / keepalive / supervisor / mem-guard(四层保活) │
└──────────────────────────────────────┬───────────────────────────────────┘
│ 每 10 分钟 rsync
┌─────────────▼──────────────┐
│ NAS 备份 + iCloud 破窗包 │
└────────────────────────────┘
持久化分层(这是整套东西最关键的约定):
| 路径 | 普通重启 | 容器重建 | 说明 |
|---|---|---|---|
/workspace |
✅ | ✅ | 持久卷,随 NAS 每 10 分钟备份 |
/root(用户文件) |
✅ | ❌ | overlay 层,重建即丢 → watchdog 从 /workspace 回填 |
/usr/local、/etc |
❌ | ❌ | 每次重置 |
二、四层保活(这部分比功能更值钱)
| 层 | 组件 | 职责 |
|---|---|---|
| L1 | supervisor(pi-gw-supervisor.sh) |
每 10 秒探活 8099,挂了立即拉起 |
| L2 | keepalive.sh | 每 5 分钟健康报告:sshd / wg0 / watchdog / 内存 / CPU 节流 / 磁盘,异常静默告警 |
| L3 | watchdog.sh(24 个段落) | 启动即自愈:WireGuard 配置、sshd、serve-web 回填、mdviewer 克隆、pi-gw 回填、DSH 运行时、密钥恢复… |
| L4 | mem-guard.sh(cron 每 5 分钟) | cgroup 内存守卫:清孤儿 pi / 孤儿浏览器 / 回收 page cache |
破窗包:每天生成一份自包含恢复脚本推 iCloud —— 灾备"一条命令"级别。
三、pi-gw(网关 + 前端)
3.1 架构
- 进程模型:每会话一个独立
pi --mode rpc子进程;上限 2 个,LRU 回收(上下文已落盘,重连即恢复) - 协议:行分隔 JSON。请求带
id;响应{type:'response',id,success,data};事件message_update / tool_execution_start / tool_execution_end / message_end / agent_settled - 前端:单文件 HTML + 原生 JS(ES5 风格,无框架无构建)。老浏览器解析失败=整站白屏,所以禁箭头函数/
?./\u{} - 规模:网关 1801 行、前端 4001 行、33 个端点、26 项自动化测试、零 npm 依赖
3.2 队列语义(2026-11-05 重做)
| 场景 | 行为 |
|---|---|
| 空闲发送 | 新一轮(queued:false) |
| 忙时发送(默认) | 打断重开:先 /api/abort 中止当前轮,再作为新一轮发出 |
| 忙时长按发送 | 显式排队 follow_up(等这轮彻底结束) |
| 排队面板 | 「清空排队」只丢队列不中止当前轮;方块按钮才中止这一轮 |
3.3 端点分组(33 个)
- 会话/对话:health state sessions history send abort stream(SSE) queue clearqueue unqueue
- 模型/上下文:models model default-model thinking compact stats
- 文件:upload ls text
files/**export find - 任务清单:todos(GET/POST;add/toggle/edit/del/clear)
- 对外发布:knowly share blog ima ima/notes ima/url wechat
- 杂项:sys ui-version
3.4 前端踩过的坑(全部有代码注释与绊线测试)
- 长按发送双计时器:
pointerdown+touchstart双绑 → 孤儿 timer → 每次轻点都弹 follow_up。现在二选一绑定 + 滑动 >10px 取消 - 状态不同源:
pending只听 SSE 的idle一帧 → 漏帧后永久"排队"。现在用服务端busy/streaming回写(带防抖) - 服务端 busy 卡死:
agent_settled丢帧后永真 → 30 秒定时扫描,连续 3 分钟无输出即复位 - 正则死循环:同一个
/g正则既exec又replace→ 页面冻死。循环内不复用正则 - 表情独占一行:装饰 emoji 打在气泡容器上 → 排在块级
<p>前;改打首个子元素 - PWA 不重载文档:改了 HTML 用户可能几天看不到 →
/api/ui-version每 60 秒自检,变了亮"界面已更新"
3.5 发布
/workspace/ops/pi-gw/publish.sh --dry-run # 看差异
/workspace/ops/pi-gw/publish.sh # 脱敏 → 泄露体检 → 推送
仓库:私有仓库(publish.sh 一条命令发布)。
运行副本含密钥,绝不直接 push:知识库密码、IMA 知识库 id、四家后端域名、内网前缀、token 示例全部脱敏成 env/空串,每条替换带命中次数断言,末尾还有全目录泄露体检。
四、mdviewer(Markdown 编辑器 / PWA)
- 版本 4.11,纯静态 PWA(
md.example.com,GitHub Pages),零构建零运行时依赖 - 本地优先:IndexedDB 文库 + localStorage 草稿,离线可用;支持原地保存(File System Access API)
- 渲染管线:marked → DOMPurify → KaTeX / Mermaid / 代码高亮 → 快照导出
- 公式保真(v4.11):渲染前把公式抽成占位符(跳过围栏代码/行内代码/价格误判),消毒后
katex.render回填;导出 HTML 时等渲染完再快照,并把katex.min.css+ 20 个 woff2 字体内联(断网也正确) - 外部网址只读浏览(v4.10):
?url=或菜单「🌐 从网址打开」,走三道闸——取文安检(只 https/443、拒 IP 与内网名、逐跳复查、10s/2MB)、消毒严档(外链断 opener、远程图点击才加载、不给音频/mermaid)、状态隔离(不写草稿/文库/分享/发布,只读) - 测试:
npm run check共 103 项(含 12 项公式保护、14 项取文安检)
五、对外服务(Cloudflare)
| 服务 | 地址 | 作用 |
|---|---|---|
| 协作分享 Worker | share.example.com |
R2 中转:URL 即密码的临时分享;写入闸门(只收 text/markdown、拒 Content-Encoding、8 位 id 白名单、2MB 上限) |
| 取文端点 | share.example.com/fetch-md |
替浏览器取远端 Markdown。闸门与 serve-web 共用 lib/fetch-guard.js;限流用 Durable Object(KV 计数实测无效:读缓存 60s + 单 key 每秒 1 写) |
| 静态站点 | md.example.com |
mdviewer(GitHub Pages,git push 即部署) |
沙箱侧 serve-web 提供 /__fetch 同源取文(LAN 用),guard 查找顺序:/root/mdviewer/lib/ → /workspace/ops/ → 同目录(持久区优先)。
六、测试体系
| 仓库 | 规模 | 内容 |
|---|---|---|
| pi-gw | 26 项 | smoke 7(真起网关+桩 pi)、todo 4、ui-pure 6(从 HTML 抠纯函数跑真值表)、ui-invariants 9(结构绊线) |
| mdviewer | 103 项 | 纯函数内核、正则护栏、分享/取文安检、公式保护、Worker 集成 |
分工要诚实:
ui-pure是真验证(输入→输出)ui-invariants是便宜绊线(防"被改回某个形状"):会误报等价重构,也测不到运行期错误- 2026-11-05 的三个 bug(孤儿计时器、pending 不回写、正则死循环)这套测试在设计上测不到——要测必须真浏览器
七、安全假设(有意接受的代价)
| 项 | 现状 | 越界前必须补 |
|---|---|---|
| pi-gw token | 固定值,等价容器 root | 失败退避 + 轮换 / OAuth |
| 暴露面 | 家庭内网 + Mac SSH 隧道;--allow-ip 前缀白名单 |
TLS + 强鉴权 + WAF |
| FS_DENY | 只挡直接翻文件(token/key 类) | 真正隔离靠容器 |
| 速率限制 | 网关侧无(Cloudflare 侧有 DO 兜底) | 令牌桶 |
| 并发 | 会话进程上限 2、内存 8GB 硬顶 | 内存水位保护 |
| 备份加密 | 密钥材料 AES 加密后随包推送 | 定期演练恢复 |
一句话:这是"个人威力工具",不是多租户服务。自有机器 + 内网里是杠杆,出边界就是风险敞口。
八、运维手册(常见故障 → 处理)
| 现象 | 处理 |
|---|---|
| 手机打不开 | ① Mac 上 launchctl 隧道是否在跑 ② curl 127.0.0.1:8099/api/health ③ supervisor 日志 |
| 网关挂了 | pi-gw-supervisor.sh 每 10 秒自动拉起;手动 reload-pi-gw.sh [延迟] |
| 容器重建后功能缺失 | watchdog 启动即自愈;手动兜底 /workspace/ops/pi-gw/restore.sh --force |
| 页面改了没生效 | 看底部有没有亮「界面已更新」;否则 location.reload() 或彻底关掉 PWA 重开 |
| 内存吃紧 | mem-guard.sh 每 5 分钟清孤儿;/api/sys 看 anon 与 cache 分别多少 |
| 改了 pi-gw 要发布 | gen-doc.py → verify-embedded.py(必须 ALL OK)→ publish.sh |
| 分享接口被刷 | Worker 闸门 + DO 限流(30 次/分钟/IP);Cloudflare 面板的 WAF 需付费,Free 无入口 |
九、变更记录与教训
2026-11-05
- pi-gw:队列语义重做(打断重开)、状态自愈三处、长按三修、IMA/博客/收藏链接、任务清单、界面版本自检、配色重构、测试 0→26、发布脚本脱敏
- mdviewer:v4.11 公式保真(占位符方案)、v4.10 外部网址只读导入 + Worker DO 限流
- 基础设施:watchdog 9b2 段自动回填 pi-gw、
/workspace/AGENTS.md(清单协议)、安全假设成文
教训(按代价排序)
- 改前端要验"页面还活着",不只是验函数返回值——一个
/g正则复用让整页冻死十几分钟 - 30 小时不崩不是防线好,是没动代码;一动手就连环翻车
- "修好了"与"用户看到修好了"是两件事——PWA 不重载文档,为此加了版本自检
- 测试要对得起"验证"二字:正则断言源码长相只是绊线,真验证要跑真值表或真浏览器
- 脱敏要成脚本:靠人记得"别把运行副本推上去"迟早出事
公开版整理:2026-11-05 | pi-gw 1801+4001 行 / 33 端点 / 26 测试 | mdviewer v4.11 / 103 测试 | 发布 24e9d0e