跳转至

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 秒:

  1. wrap CLI 辅助工具:一条命令替代 3-4 次代理工具调用(grep + read + edit)。按 ID/class/tag 优先级查找元素,创建变体包装器,返回文件路径和插入行。
  2. 批量写入变体:所有变体在一次文件编辑中写入,而不是每个变体一次编辑。节省 N-1 次工具调用往返。
  3. 页面 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