ADR:实时变体模式¶
状态: 已实施 日期: 2026-04-12 作者: Paul Bakaus + Claude
背景¶
Impeccable 是一个面向 AI 编码代理的设计技能。它教会 AI 工具框架(Claude Code、Cursor、Gemini CLI、Codex 等)如何生成更好的前端设计。该技能包含 23 个命令(bolder、quieter、polish、typeset 等),由代理在源代码上运行。
缺失的一环:无法在实时页面上进行可视化迭代。用户可以让代理“把这个做得更醒目一点”,但他们必须阅读代码 diff、重新加载页面,然后判断是否满意。如果不满意,就得再次提出请求、等待、重新加载,如此循环。既缓慢又割裂。
目标: 让用户直接在浏览器中选择一个元素,挑选一个设计操作,然后看到 N 个真实的 HTML+CSS 变体被热替换进来。在视觉上循环浏览这些变体,接受或丢弃,然后重复。代理负责生成变体;浏览器负责展示它们。
决策¶
构建一个自成体系的实时变体模式,作为 impeccable 技能的一部分随附发布(无需单独执行 npm install)。该系统连接三方:浏览器(用户在此选取元素并循环浏览变体)、服务器(一个 localhost HTTP 服务器,负责转发消息)以及代理(通过修改源文件来生成变体的 AI)。
关键架构决策¶
1. 修改源代码,而非修补 DOM。
变体被写入实际的源文件,而不是注入浏览器的 DOM。这意味着:
- 框架状态(React、Vue 等)得以保留,因为框架自身的渲染管线会通过 HMR 来处理更新。
- “接受”操作非常简单:胜出的变体已经在源代码中,只需移除其他变体即可。
- 变体是真实的代码,用户可以在编辑器中检查、进行 diff 并提交。
2. 使用 SSE + fetch,而非 WebSocket。
使用 Server-Sent Events(服务器到浏览器)+ fetch POST(浏览器到服务器),而不是 WebSocket。这样就完全消除了 ws npm 依赖。服务器是零依赖的纯 Node.js(http、crypto、fs、net、os)。这一点很重要,因为这些脚本随技能目录一起发布,并可在用户的项目中运行,无需安装任何包。
3. 自成体系的技能脚本。
所有实时模式代码都位于 skill/scripts/ 中:
live-server.mjs— HTTP 服务器(SSE、轮询、源文件读取器)live-poll.mjs— 供代理进行轮询/回复循环的 CLI 客户端live-wrap.mjs— CLI 辅助工具,用于在源代码中查找元素并创建变体包装器live-browser.js— 浏览器脚本(元素选择器、操作面板、变体循环器、全局栏)
当用户通过 npx skills add pbakaus/impeccable 安装该技能时,无需任何额外设置即可获得实时模式。代理通过 node {{scripts_path}}/live-server.mjs 运行这些脚本。
4. 代理使用 HTTP 长轮询,而非 WebSocket 或 stdin。
代理通过 HTTP 长轮询与服务器通信(GET /poll 会阻塞,直到有浏览器事件到达)。这在所有 AI 工具框架中都能正常工作,因为每个框架都可以运行 shell 命令并读取其 stdout。无需任何针对特定框架的集成。
5. display: contents 变体包装器。
变体被包裹在一个带有 display: contents 的容器中,这使得包装器对 CSS 布局不可见。所选元素与其父元素的关系(flex 子元素、grid 子元素等)得以保留。包装器带有 data-impeccable-variants 和 data-impeccable-variant-count 属性,浏览器脚本利用这些属性来检测并循环变体。
6. 无 HMR 的回退方案。
对于不支持 HMR 的开发服务器(如 Bun 的静态 HTML import),浏览器直接从实时服务器的 /source 端点获取原始源文件,并将变体注入 DOM。这种方式具有普适性,但代价是注入时会丢失框架状态。
架构¶
┌─────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ │
│ live-browser.js (injected via <script> tag in source HTML) │
│ ├── Element picker (mousemove highlight, click select, │
│ │ keyboard nav: arrows=siblings, shift+arrows=parent/child) │
│ ├── Action bar (floating pill: action picker, freeform input, │
│ │ variant count, go button; morphs to generating/cycling) │
│ ├── Global bar (bottom pill: Detect toggle, Pick toggle, Exit) │
│ ├── Variant cycler (MutationObserver watches for new │
│ │ [data-impeccable-variant] children in DOM) │
│ ├── SSE connection (EventSource → /events for server push) │
│ ├── fetch POST (→ /events for browser-to-server messages) │
│ └── localStorage (session state survives reloads) │
│ │
└────────────┬──────────────────────────────────┬─────────────────┘
│ SSE (server → browser) │ POST (browser → server)
│ EventSource /events │ fetch /events
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ LIVE SERVER │
│ │
│ live-server.mjs (node, localhost:8400+, zero dependencies) │
│ ├── GET /live.js — browser script (token injected) │
│ ├── GET /detect.js — anti-pattern overlay (backwards compat)│
│ ├── GET /events — SSE stream to browser (server push) │
│ ├── POST /events — browser events (generate, accept, etc.)│
│ ├── GET /poll — agent long-poll (blocks until event) │
│ ├── POST /poll — agent reply (forwarded to browser SSE) │
│ ├── GET /source — raw file reader (no-HMR fallback) │
│ ├── GET /health — status, port, connected clients │
│ └── GET /stop — graceful shutdown │
│ │
│ State: session token, SSE client set, event queue, poll queue │
│ Server file: .impeccable/live/server.json (project root) │
│ │
└────────────┬──────────────────────────────────┬─────────────────┘
│ GET /poll (long-poll) │ POST /poll (reply)
│ blocks until browser event │ forwarded to SSE
▼ ▲
┌─────────────────────────────────────────────────────────────────┐
│ AGENT │
│ │
│ Follows skill/reference/live.md │
│ 1. Start server: node scripts_path/live-server.mjs & │
│ 2. Inject <script> into source HTML (comment-marked) │
│ 3. Poll loop: │
│ ├── generate → wrap + write variants + reply done │
│ ├── accept → present variant code + cleanup + reply done │
│ ├── discard → restore original + reply done │
│ ├── exit → cleanup script tag + stop server │
│ └── timeout → re-poll │
│ │
│ Tools used per generation: │
│ 1. node live-wrap.mjs (find element, create wrapper) │
│ 2. Edit (write all variants in single edit) │
│ 3. node live-poll.mjs --reply <id> done --file <path> │
│ │
└─────────────────────────────────────────────────────────────────┘
消息流¶
生成变体¶
User clicks element → picks "Bolder" → clicks Go
↓
Browser POST /events: {type:"generate", id:"abc", action:"bolder", count:3, element:{...}}
↓
Server enqueues event
↓
Agent GET /poll returns: {type:"generate", id:"abc", action:"bolder", count:3, element:{...}}
↓
Agent runs: node live-wrap.mjs --id abc --count 3 --classes "hero-left"
→ Finds element in source, wraps with data-impeccable-variants container
→ Original stays visible (no flash of empty content)
↓
Agent writes all 3 variants in a single Edit tool call
→ Each variant is a <div data-impeccable-variant="N"> with full HTML replacement
→ First variant visible, others display:none
↓
Agent POST /poll: {id:"abc", type:"done", file:"public/index.html"}
↓
Server forwards via SSE to browser: {type:"done", file:"public/index.html"}
↓
Browser checks: variants in DOM? (HMR)
YES → MutationObserver detected them → show cycling bar
NO → fetch /source?path=public/index.html → parse → inject → show cycling bar
接受变体¶
User clicks Accept on variant 2
↓
Browser POST /events: {type:"accept", id:"abc", variantId:"2"}
Browser shows "Applying variant..." spinner
↓
Agent GET /poll returns: {type:"accept", id:"abc", variantId:"2"}
Agent reads variant 2 HTML from source, presents to user
Agent removes variant wrapper, restores clean source
Agent POST /poll: {id:"abc", type:"done"}
↓
Browser receives done via SSE → green "Variant applied" confirmation → auto-dismiss
丢弃¶
User clicks Discard (or presses Escape)
↓
Browser POST /events: {type:"discard", id:"abc"}
Browser immediately: restores original element in DOM, hides bar, resets to PICKING
↓
Agent GET /poll returns: {type:"discard", id:"abc"}
Agent removes variant wrapper from source, restores original
Agent POST /poll: {id:"abc", type:"done"}
变体包装器格式¶
在源文件(HTML 示例)中:
<!-- impeccable-variants-start abc12345 -->
<div data-impeccable-variants="abc12345" data-impeccable-variant-count="3" style="display: contents">
<div data-impeccable-variant="original">
<!-- original element (visible until first variant arrives) -->
</div>
<div data-impeccable-variant="1">
<!-- variant 1 (visible) -->
</div>
<div data-impeccable-variant="2" style="display: none">
<!-- variant 2 -->
</div>
<div data-impeccable-variant="3" style="display: none">
<!-- variant 3 -->
</div>
</div>
<!-- impeccable-variants-end abc12345 -->
注释标记使清理具有确定性。包装器上的 display: contents 保留 flex/grid 布局。data-impeccable-variant-count 属性告诉浏览器预期会有多少个变体。
浏览器 UI¶
全局栏(实时模式下始终可见)¶
紧凑的浮动胶囊,位于底部中央。轻盈、半透明,与品牌美学一致。
- Impeccable 品牌标志(洋红色)
- 检测开关(眼睛图标):在扩展模式下加载反模式扫描器,显示覆盖层计数徽标
- 选取开关(十字准线图标):启用/禁用元素选择器(默认:开启)
- 退出按钮(x):发送退出事件,拆除所有 UI
当“检测”和“选取”同时激活时,检测覆盖层会获得 pointer-events: none,以便选择器可以看穿它们。选择器的 z-index(100001)高于检测覆盖层(99999)。
操作栏(浮动、上下文相关)¶
显示在所选元素下方。在不同状态之间切换:
- 配置:
[Action pill ▾] [freeform input] [×3] [Go →] - 正在生成:
[Action label] [● ● ○] Generating 2 of 3... - 循环浏览:
[←] [● ● ●] 2/3 [→] [✓ Accept] [✕] - 正在保存:
[spinner] Applying variant... - 已确认:
[✓ Variant applied](绿色,1.8 秒后自动消失)
会话持久化¶
localStorage 存储:
- 会话状态(id、action、count、已到达变体、可见变体)
- 已处理的会话(已接受/已丢弃的会话 ID)
这些在页面刷新、浏览器关闭/重新打开、HMR 和意外刷新后仍然保留。页面加载时,resumeSession() 会检查 DOM 中的活动变体包装器 + localStorage 状态,并恢复到正确的循环位置。
安全性¶
- 会话令牌:
crypto.randomUUID(),在所有变更端点和 SSE 连接上检查。 - 仅限 localhost:服务器绑定到
127.0.0.1,而不是0.0.0.0。 - 令牌位于服务器文件中:项目根目录下的
.impeccable/live/server.json。只有用户自己的进程能读取它。 - 令牌注入到
/live.js:服务器在服务时会在开头添加window.__IMPECCABLE_TOKEN__。 - 路径穿越防护:
/source端点会验证请求路径位于process.cwd()之内。 - 不使用 eval/innerHTML:所有浏览器 UI 都使用
createElement和textContent构建。
服务器韧性¶
- 防抖退出:当所有 SSE 客户端断开连接时,服务器会等待 8 秒,然后才向代理发送退出信号。这可以避免因 HMR 重载和短暂网络波动导致的误退出。
- 过期 PID 检测:启动时,服务器会检查现有 PID 文件中的进程是否仍在运行。已死亡的进程会被自动清理。
- 浏览器对服务器失联的处理:在 5 次 SSE 重连尝试失败后,浏览器会清理所有 UI,并显示“Live server disconnected”提示条。
性能优化¶
生成循环已从约 40 秒优化到约 15-20 秒:
wrapCLI 辅助工具:一条命令替代 3-4 次代理工具调用(grep + read + edit)。按 ID/class/tag 优先级查找元素,创建变体包装器,返回文件路径和插入行。- 批量写入变体:所有变体在一次文件编辑中写入,而不是每个变体一次编辑。节省 N-1 次工具调用往返。
- 页面 URL 提示:浏览器在生成事件中包含
location.pathname,以便代理可以将 URL 直接映射到源文件。
净效果:4 次工具调用(wrap + edit + read + reply),而不是 8 次以上。
测试覆盖¶
- 26 个 wrap 测试(
tests/live-wrap.test.mjs):针对buildSearchQueries、findElement、findClosingLine、detectCommentSyntax的单元测试 + 针对完整wrapCli在 HTML 和 JSX fixture 上的集成测试。 - 15 个服务器测试(
tests/live-server.test.mjs):集成测试,启动真实服务器,测试所有端点,验证浏览器→代理事件流和代理→浏览器的 SSE 投递。
已知限制¶
- Bun 的静态 HTML import:Bun 的
import from "index.html"会在模块加载时进行缓存。源文件发生更改后,必须重启服务器,更改才会出现在所提供的 HTML 中。无 HMR 的兜底方案(从/source发起 fetch)可以处理这种情况,但不如 Vite/Next.js 那样无缝,因为后者原生支持 HMR。 - 同一时间只能进行一次生成:同一时间只能有一个 generate/cycle 会话处于活动状态。这是设计使然(源文件在同一时间只能有一个变体包装器)。
- 生成期间无法取消:一旦 agent 开始生成,它会在用户再次交互之前完成所有变体的生成。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 im/impeccable