Skip to main content
Glama

English | 简体中文

npm version node license Chrome MCP verdict release

Typing SVG

Nodepact

让节点自己的渲染差异,成为 coding agent 的验收结论。

你在自己已打开的 Chrome 里点一个节点、留一句话,Nodepact 把它落成一条可判定的断言; 之后每次 nodepact check,返回的是那个节点前后渲染的差异加 PASS / FAIL, DONE 由机器的差异说了算,而不是 agent 的措辞。

元素锚定纠偏对 · 只连你已开的 Chromium · 判定路径上没有模型 · 断言库与差异图全在本地 .nodepact/


为什么需要 Nodepact

你让 coding agent 改一处界面,看到的是一个渲染缺陷:按钮贴着输入框、间距不对、颜色偏了。 现在你只有一种表达方式——在聊天里用文字描述它("设置页那个提交按钮离输入框太近了"), 然后自己再看一遍页面,判断 agent 到底改对没有。

这条回路里有两个洞:

  • 批注没有绑在它说的那个元素上。 agent 只能重新猜你指的是哪个节点。

  • 没有一个机器能读的验收信号。 agent 说"我已经修好了",这句话本身就是"完成"的唯一证据。

于是人既是描述者又是复核者,回路永远合不上。

Nodepact 把这两件事合成一个原语:元素锚定纠偏对(element-anchored steering pair)。

  • 锚点 = 那个 DOM 节点的稳定定位(CSS 选择器 + 结构指纹)+ 修复前的渲染基线(该节点的裁剪截图)。

  • 断言 = 一条可判定的谓词:两节点间距、computed style,或者"相对基线确实变了"。

  • 判定 = 断言成立 ∧ 该节点前后渲染差异 ≥ ε,路径上没有模型。

所以 check 给你的不是措辞,是一组可以复核的数:实测值、像素差异、差异 PNG 和结论。

它补的是哪一半。 把批注投递给 agent 的那一半已经很拥挤了(spotnote 这类 Vite 插件、reticle 这类"让 agent 自证"的方案);Nodepact 不重复那一半,它让你指的那个节点自己的渲染差异成为 验收信号。检查面这一层和 chrome-devtools-mcp(让 agent 读 DOM、截图的那层 agent-to-DOM 能力) 是同一条 CDP 通路,Nodepact 直接用 chrome-remote-interface 连上去,不经过中间层。

Related MCP server: Chrome Ops MCP

快速开始(10 分钟)

需要一个 Node 22+ 的环境,和一次 Chrome 的重启。

1. 安装

# 方式 A:直接装发布包(第一次 tag 推送之后可用)
npm i -g https://github.com/SuperMarioYL/nodepact/releases/latest/download/nodepact.tgz

# 方式 B:从源码(任何时刻都能跑)
git clone https://github.com/SuperMarioYL/nodepact.git && cd nodepact
npm ci && npm run build
npm link          # 之后就能直接敲 nodepact

发布到 npm registry 之后,npm i -g nodepact 与 npx nodepact 是同一条路径的短写法。 Nodepact 不下载任何浏览器:它只连你已打开的那一个。

2. 让 Chrome 打开调试端口

# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222
# Linux
google-chrome --remote-debugging-port=9222

3. 起示例页面(这就是输入)

仓库里带了一个故意做错的设置页:提交按钮和邮箱输入框只隔了 4px。

python3 -m http.server 5173 --directory examples/demo-app
# 打开 http://localhost:5173

源码就在 examples/demo-app/index.html,改一行 CSS 就能看到判定翻转。

4. 挂上去、点选、写批注

$ nodepact init --url http://localhost:5173
attached to Chrome via CDP  ws://127.0.0.1:9222/devtools/page/4C2E9A7B...   page: /
config   nodepact.yaml
store    .nodepact/
next     nodepact annotate

$ nodepact annotate
click a node ...
picked #submit  fp 99ceb4
annotation (zh): 提交按钮和上面的邮箱输入框太近了
draft assertion: gap(#email, #submit) >= 12px      [y / edit / n]
accept? y
saved to .nodepact/steer.yaml   (node fp 99ceb4, baseline 68x46)
next: nodepact check

浮层出现在页面右下角:点节点,写下你的意见(中文就行),回车提交。如果你配了模型端点, annotate 会把这句话起草成一条断言给你确认;没配也能用,直接手写断言。

5. 改一行 CSS,刷新页面,看它翻成 DONE

# examples/demo-app/index.html
#   #email { margin-bottom: 4px; }  ->  #email { margin-bottom: 14px; }

# 然后让页面重新加载。check 读的是你 Chrome 里当前这个页面:
# python3 -m http.server 没有热更新,改完文件页面还是旧的,要在 Chrome 里按 Cmd-R(F5)刷新一次。
# 用带 HMR 的 dev server(Vite、Next 等)时这一步是自动的。
$ nodepact check
#submit  gap(#email, #submit) >= 12px
  before 4  after 4  assertion FAIL
  render diff 0px (0.00%)  noise 0.00%  epsilon 1.00%
  artifact .nodepact/diffs/submit.after.png
  verdict NOT_DONE  assertion does not hold: gap 4px >= 12px -> fail

# ...改完 CSS,并在 Chrome 里刷新页面(Cmd-R / F5)。不刷新的话第二次 check 仍是 NOT_DONE...

$ nodepact check
#submit  gap(#email, #submit) >= 12px
  before 4  after 14  assertion PASS
  render diff 165px (2.09%)  noise 0.00%  epsilon 1.00%
  artifact .nodepact/diffs/submit.after.png
  verdict DONE  assertion holds and the node changed by 2.09% (≥ epsilon 1.00%)

退出码:DONE 是 0,NOT_DONE / UNSTABLE 是 1,锚点或基线出问题是 2。可以直接进你的脚本或 CI。

6. 让 agent 自己循环到 DONE

nodepact mcp --install     # 打印 MCP 客户端配置片段,粘贴到你的 agent 配置里

配置好之后,你的 coding agent 自己调 nodepact.check,拿到 DONE / NOT_DONE 与差异产物路径, 自己决定要不要继续改。

演示

完整录屏:docs/demo.cast(asciinema v2 格式,asciinema play docs/demo.cast 回放)。

$ nodepact init --url http://localhost:5173
attached to Chrome via CDP  ws://127.0.0.1:9222/devtools/page/4C2E9A7B...   page: /
$ nodepact annotate
picked #submit  fp 99ceb4
annotation (zh): 提交按钮和上面的邮箱输入框太近了
draft assertion: gap(#email, #submit) >= 12px      [y / edit / n]
accept? y
saved to .nodepact/steer.yaml   (node fp 99ceb4, baseline 68x46)
$ nodepact check --json
{ "verdict": "NOT_DONE", "before": 4, "after": 4,
  "render_diff": { "changed_px": 0, "total_px": 7900, "ratio": 0 } }
# agent 改掉那一行 CSS 并让页面重新加载(静态服务器没有 HMR,刷新一下;HMR 的 dev server 自动),再调一次:
$ nodepact check --json
{ "verdict": "DONE", "before": 4, "after": 14,
  "render_diff": { "changed_px": 165, "total_px": 7900, "ratio": 0.0209 } }

这次运行的全部数字都记录在 docs/demo-results.json,README 只引用它:

观测项

值

锚点

#submit,指纹 99ceb4,基线 68x46

断言

gap(#email, #submit) >= 12px

修复前 / 修复后实测

4px → 14px

渲染差异

165px / 共 7900px = ratio 0.0209

阈值

epsilon 0.01,stability_floor 0.01,capture_margin 16px

判定

NOT_DONE → DONE(退出码 0)

这些数字来自一次真实运行(capture_scale = 1,devicePixelRatio = 1)。换个屏幕缩放,裁剪尺寸会变, 判定不会:判定看的是断言实测值与差异占比,不是像素的绝对数量。

工作原理

你的 Chrome(dev server 页面)  <--- CDP (--remote-debugging-port=9222)
      |  ^
overlay.js |  | Page.captureScreenshot(clip = 节点框 ± capture_margin)
      v  |      getBoundingClientRect / getComputedStyle
  nodepact CLI(node,单进程)
    |- anchor/   fingerprint + store   -> .nodepact/steer.yaml + .nodepact/baseline/*.png
    |- oracle/   assert.eval + diff(pixelmatch) + verdict -> .nodepact/diffs/*.png
    |- annotate/ draft -> 国产模型(OpenAI 兼容,仅起草断言,可离线跳过)
    \- mcp/      server -> nodepact.check / nodepact.list  <-- coding agent

最多两个进程:你自己开的 Chrome,和 nodepact 的 CLI / MCP 进程。没有守护进程、没有数据库、 没有云端——全部状态是 .nodepact/ 下的扁平文件。

判定是一个纯函数(src/oracle/verdict.ts):

DONE     ⟺ 断言成立 ∧ diff(基线, 现在).ratio ≥ ε
NOT_DONE   其余情况
UNSTABLE   该节点自己重渲染两次的差异 > stability_floor

判定里没有模型,所以同一份代码跑两次结论一定一样。锚点也不是选择器:除了 CSS 选择器,它还带一份 结构指纹(标签 + 稳定属性 + 最近三层祖先链 + 文本哈希)。DOM 漂移之后选择器可能失效,指纹还能把节点找回来; 如果两个候选打平,check 直接报 anchor_not_found,绝不猜。

稳定性是这套 oracle 的核心风险,所以它是明说的

动态内容、网页字体、抗锯齿都会让同一个节点的两次截图天然不同。Nodepact 的取舍是:

  • 几何类断言(gap / style)本身不受抗锯齿影响,它们是主判据;

  • 像素差异只作为"这个节点确实被动过"的辅证,阈值 ε 按节点框内像素占比算,且 includeAA: false;

  • 每次 check 都把实测值、ε、本次噪声一起打出来,让"噪声"和"真修复"可分辨;

  • 两次背靠背截图的差异超过 stability_floor 时,报 UNSTABLE,不假装 DONE;

  • 截图会在节点四周多裁 capture_margin(默认 16px):裁得太紧的截图会跟着节点一起移动, 一个只把节点挪开的修复(正是 gap 断言要求的那种)就会看不见差异。

断言 DSL

只有三种,一行一条(nodepact check 接受这种写法,steer.yaml 里存的是等价的块):

gap(#email, #submit) >= 12px     # 两个节点的边缘间距,单位 CSS 像素
gap(#email, #submit) <= 12px
style(display) == flex           # 锚定节点的 computed style
style(font-size) != 14px
changed()                        # 相对基线,这个节点确实变了

gap 取两个盒子边缘到边缘的距离:上下排布时取垂直间距,左右并排时取水平间距,重叠为 0。 style 读的是锚定节点(annotate 时你点的那个)的 getComputedStyle,属性名用小写 CSS 名。

steer.yaml 里存的是结构化的块,不是上面那行字符串:

version: 1
pairs:
  - anchor:
      id: submit
      selector: "#submit"
      fp: button#submit[type=button]|div>form#settings>main|t:b6fc31
      baseline_png: .nodepact/baseline/submit.png
      box: { x: 431, y: 226.59375, w: 68, h: 46 }
    annotation: 提交按钮和上面的邮箱输入框太近了
    assertion:
      kind: gap
      a: "#email"
      b: "#submit"
      op: ">="
      px: 12
    baseline_measure: 4
    created_at: 2026-10-26T02:41:00.000Z
    updated_at: null

为什么断言是块而不是一行? YAML 遇到 " #" 就结束一个纯量。手写 assertion: gap(#email, #submit) >= 12px 会被安静地解析成 gap(#email,——而手写这个文件正是它的用法。 所以选择器值一律加引号(a: "#email"),断言用块。改断言时记得把 baseline_measure 置为 null, 免得 check 打出一个过期的 before 值。

完整的示例文件:examples/steer.yaml。

配置

nodepact init 会写一份 nodepact.yaml;configs/nodepact.yaml 是同样内容的 带注释副本,所有值就是内置默认值。

键

默认

作用

cdp.host / cdp.port

127.0.0.1 / 9222

连哪个 DevTools 端点

cdp.url_match

null

只挂 URL 以它开头的那个页面;null 时要求只有一个打开页面

oracle.epsilon

0.01

渲染差异达到这个占比才算"节点真的变了",DONE 需要它

oracle.stability_floor

0.01

节点自重渲染噪声超过它,check 报 UNSTABLE

oracle.capture_scale

1

截图 clip 的缩放,1 让裁剪不受屏幕 DPR 影响

oracle.capture_margin

16

节点四周多裁的像素,让"只挪位置"的修复也能被看见

oracle.pixel_threshold

0.1

pixelmatch 的单像素颜色容差

model.provider / model.timeout_ms

deepseek / 20000

只用 annotate 起草断言时用到

providers.*

见下

OpenAI 兼容端点表

provider

模型

API Key 环境变量

base_url

deepseek

deepseek-chat

DEEPSEEK_API_KEY

https://api.deepseek.com/v1

qwen

qwen-plus

DASHSCOPE_API_KEY

https://dashscope.aliyuncs.com/compatible-mode/v1

glm

glm-5

ZHIPUAI_API_KEY

空——按你的网关填

kimi

moonshot-v1-auto

MOONSHOT_API_KEY

https://api.moonshot.cn/v1

Key 只在调用时从环境变量读,不落盘。GLM 的 OpenAI 兼容地址随部署不同(bigmodel.cn 与内网网关不一样), 所以不预置。模型名以各家控制台为准:这里没有在线校验模型名,写错了会在 annotate 时得到一个明确的 HTTP 错误,而 check 完全不受影响——判定不依赖模型。

接入 coding agent(MCP)

nodepact mcp --install
{
  "mcpServers": {
    "nodepact": {
      "command": "npx",
      "args": ["-y", "nodepact", "mcp", "--config", "/你的项目/nodepact.yaml", "--store", "/你的项目/.nodepact"]
    }
  }
}

两个工具:

  • nodepact.check(可选参数 node:锚点 id 或选择器;省略则检查全部)——重新渲染并返回 DONE / NOT_DONE / UNSTABLE、实测值、像素差异和产物路径。

  • nodepact.list——列出本项目里存的纠偏对(选择器、你的批注、断言、基线路径)。

一个典型的 agent 循环:调 nodepact.check → 拿到 NOT_DONE 和实测值(after: 4,要求 >= 12px)→ 改 CSS → 让页面重新加载(HMR 的 dev server 自动,静态服务器要刷新)→ 再调一次 → 拿到 DONE 和差异图。 中间不需要人。因为判定是确定性的,重复调用同一份代码得到同一个答案。

MCP 只是最后一公里:整套东西从 CLI 就能跑,不需要任何 agent。

与相邻工具的分工

做法

它负责的那一半

谁说了算

聊天里描述缺陷 / 贴截图

把意见传给人或 agent

agent 的措辞

批注类工具(spotnote 等)

把批注投递给 agent

agent 的措辞

reticle 一类方案

让 agent 自己验证自己

agent 自己的判据

整页视觉回归(Playwright / Percy)

CI 里的整页基线快照

基线像素

chrome-devtools-mcp

让 agent 读 DOM、截图

agent 的判断

Nodepact

把你指的那个节点的前后渲染差异变成验收信号

断言 + 渲染差异(无模型)

能力与边界(v0.1)

已经能做的:

  • init / annotate / check / label / mcp 五个命令,一条闭环;

  • 三类断言(gap / style / changed)+ zod 校验 + 结构化存储;

  • 锚点指纹重定位,两个候选打平时拒绝猜测;

  • 渲染差异、差异图、噪声与 ε 一起返回;DONE / NOT_DONE / UNSTABLE 三态判定;

  • 中文批注 → 断言草稿(国产模型,OpenAI 兼容,可离线跳过);

  • MCP:nodepact.check / nodepact.list。

明确不做的(v0.1 的边界,写在这里免得你踩空):

  • 只连 Chromium:CDP 是 Chromium 的协议,Firefox / Safari 不支持;

  • 只判定,不改码:不自动改 DOM、不自动修代码;

  • 不做 CI 与无头视觉回归:基线是你自己那台机器、你那个窗口的截图,所以 .nodepact/ 被 git 忽略, 换台机器要重新 annotate;

  • 不做浏览器商店扩展:只有 CLI + MCP + 注入式浮层;

  • 不做 Web 面板 / 云控制台 / 多用户 / 登录 / 团队服务端;

  • 断言不超过三类,不做布局树推理;不做跨页面、多路由的流程断言;

  • 不做 IDE 内嵌插件;不做托管的模型网关与计费;不做 Windows 原生打包。

付费

谁先付钱。 5–50 人的中后台前端 / 研发效能团队:他们在内部 UI 上跑 coding agent, 而断言库、差异图、批注都必须留在自己机器上(数据不出境 / 信创 / 等保)。对他们来说, "节点级验收信号"要落到内网才算数。

他们买的是什么——团队版。

  • 私有化部署支持:把 annotate 的断言起草接到你们自己的模型网关(自建 DeepSeek / Qwen / GLM / Kimi), 不走公网;上线陪跑与排障;

  • 跨项目共享断言库:一套 steer.yaml 约定与评审流程,在多项目、多仓库之间复用;

  • 审计留痕:哪条断言、锚在哪个节点、哪一次 check 翻成了 DONE,可导出;

  • 信创适配与开票:内网环境适配、对公转账与发票。

价格。 ¥30k–60k / 团队·年(30–50 席约 ¥600–1200 / 席·年)。前 3 个设计伙伴按 ¥10k 创始价合作, 换一次公开案例研究。

最小的一次"好"。 30 分钟屏幕共享,挂到你们自己的仓库上:你点一个节点、写一句中文批注, 你们 agent 的下一次提交把 check 翻成 DONE,带着差异 PNG。然后才谈"要不要接你们内网的模型网关、 断言库放你们自己机器上"。结算走对公转账 / 开票 + 自托管 license 文件;v0.1 没有云端控制台, 所以在第一张发票之前没有需要先建的东西。

咨询团队版:在仓库开一个 issue,标题写「团队版」。

内核是 MIT、全本地、可自托管;上面这些服务是它之上的那一层。

路线图

已交付(v0.1)

  • m1:CDP 挂载、浮层点选、基线抓取、check 对一条手写断言返回 PASS / FAIL 加渲染差异;

  • m2:断言 DSL + zod 校验;中文批注经国产模型起草断言,人确认后写入;拔掉模型端点判定不变;

  • m3:MCP 服务暴露 nodepact.check / nodepact.list,返回 verdict JSON 与产物路径。

细节见 CHANGELOG.md。

下一步(按需求排,不承诺日期)

  • 团队版要求的那些:内网模型网关接入、跨项目共享断言库、审计导出;

  • 跨字体 / 抗锯齿的差异去噪(如果设计伙伴反馈 UNSTABLE 太频繁);

  • 团队确认后的节点级验收门禁(现在的 v0.1 不做 CI)。

不做:见上面「能力与边界」——那份清单是 v0.1 的契约,不是待办。

反馈

  • Bug 与想法:GitHub Issues;

  • 国内镜像:gitee.com 上的同名仓库(同步 GitHub);

  • 中文长文会发在掘金 / 少数派,同一段 docs/demo.cast;英文侧会发一次 Show HN。

v0.1 的坦白:只连 Chromium、只判定不改码、不做 CI。如果你的场景不在这条线上,直说更有用。

许可

MIT。见 LICENSE。

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables coding agents and CI to verify rendered web UIs against design token usage, layout constraints, and accessibility rules, returning concise pass/fail verdicts with actionable findings.
    MIT
  • A
    license
    B
    quality
    B
    maintenance
    Enables AI coding agents to inspect and operate Chrome as a developer, including reading DevTools console/network data, evaluating JavaScript, and managing unpacked extensions during an edit-test-debug loop.
    24
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI coding agents to audit rendered web pages for design and UI defects by measuring geometry, styles, contrast and motion in a local browser and judging them against evidence-tiered rules. Findings come back as short, fixable items expressed in the project's own design tokens, while only sanitized numeric measurements leave the machine.
    Apache 2.0