@glittering0717/ai-browser 2.1.1 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -955,6 +955,10 @@ the single source for `evaluate` limits, enforced identically on both transports
955
955
  Issues and PRs are welcome, especially reproduction cases where the semantic tree gets a page wrong —
956
956
  those are the most useful bug reports this project can get.
957
957
 
958
+ Questions, "the tree got this page wrong" reports, and agent run logs are best posted in
959
+ [GitHub Discussions](https://github.com/Glittering/ai-browser/discussions) — that is where the most
960
+ useful feedback for this project tends to land.
961
+
958
962
  What we try to hold the line on:
959
963
 
960
964
  - **Keep the core generic.** Site-specific knowledge belongs outside `src/main` and `src/preload`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
5
5
  "main": "src/main/index.js",
6
6
  "bin": {
@@ -55,6 +55,7 @@
55
55
  "scripts": {
56
56
  "start": "node scripts/start.cjs",
57
57
  "test": "vitest run",
58
+ "release": "node scripts/release.cjs",
58
59
  "test:watch": "vitest",
59
60
  "smoke": "node e2e/smoke_ws.cjs",
60
61
  "capabilities": "node e2e/capabilities_ws.cjs",
@@ -33,7 +33,7 @@ NPM_DL=$(curl -s --max-time 15 "https://api.npmjs.org/downloads/point/last-week/
33
33
  mcp_fetch() {
34
34
  for i in 1 2 3; do
35
35
  local out
36
- out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers?search=ai-browser" 2>/dev/null)
36
+ out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers/io.github.Glittering%2Fai-browser/versions" 2>/dev/null)
37
37
  if [ -n "$out" ] && echo "$out" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
38
38
  echo "$out"
39
39
  return 0
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // scripts/release.cjs — 一条命令走完发版闭环:版本号 → 测试 → commit → tag →
3
+ // push(GitHub) → npm publish → MCP registry publish。
4
+ //
5
+ // 为什么要这个脚本(而不是每次手工敲 8 条命令):
6
+ // 发版最容易漏的不是编译,是**同步**。版本号有 4 处(package.json、
7
+ // server.json 两处、src/shared/version.js),GitHub 有 2 条分支 + 1 个 tag,
8
+ // 发布有 2 个仓库(npm、MCP registry)。漏一处不会报错,只会让用户在
9
+ // issue 里报一个不存在的版本、或者让 npx 拉到的还是旧版 —— 而且要到
10
+ // 下一次有人装的时候才发现。机器不会漏,人会。
11
+ //
12
+ // 用法:
13
+ // node scripts/release.cjs patch # 2.1.2 → 2.1.3
14
+ // node scripts/release.cjs minor --dry # 只看会做什么
15
+ // node scripts/release.cjs patch --notes notes.txt
16
+ //
17
+ // 设计原则:**每一步失败都不静默**。git 成功但 npm 失败时,脚本以非零退出并
18
+ // 打印"已完成的步骤 + 怎么补救",而不是假装整条链路都成功了。
19
+ 'use strict';
20
+
21
+ const { execFileSync, spawnSync } = require('node:child_process');
22
+ const fs = require('node:fs');
23
+ const path = require('node:path');
24
+
25
+ const ROOT = path.resolve(__dirname, '..');
26
+ const args = process.argv.slice(2);
27
+ const dry = args.includes('--dry');
28
+ const bumpKind = args.find((a) => ['patch', 'minor', 'major'].includes(a)) || 'patch';
29
+ const notesIdx = args.indexOf('--notes');
30
+ const notesFile = notesIdx >= 0 ? args[notesIdx + 1] : null;
31
+ // 全量测试 4 分钟起,发版时默认只跑"会拦住错误发版"的那几个:
32
+ // 版本一致性、导入完整性、schema 预算。其余靠 CI / 平时。
33
+ const fullTests = args.includes('--full-test');
34
+
35
+ const c = (s) => `\x1b[${s}m`;
36
+ const ok = (m) => console.log(`${c(32)}✓${c(0)} ${m}`);
37
+ const warn = (m) => console.log(`${c(33)}!${c(0)} ${m}`);
38
+ const fail = (m) => console.log(`${c(31)}✗${c(0)} ${m}`);
39
+ const step = (m) => console.log(`\n${c(36)}▸ ${m}${c(0)}`);
40
+
41
+ function run(cmd, argv, opts = {}) {
42
+ const r = spawnSync(cmd, argv, { cwd: ROOT, encoding: 'utf8', ...opts });
43
+ if (r.status !== 0 && !opts.allowFail) {
44
+ throw new Error(`${cmd} ${argv.join(' ')} 失败:\n${(r.stderr || r.stdout || '').trim()}`);
45
+ }
46
+ return r;
47
+ }
48
+ function git(...argv) { return run('git', argv); }
49
+ function gitOut(...argv) { return run('git', argv).stdout.trim(); }
50
+
51
+ // registry 的 github 登录可以吃环境变量,从而绕开 device flow ——
52
+ // device flow 在无 TTY / 后台执行时会静默卡在 "Logging in..." 不打印 URL,
53
+ // 看起来像卡死,实际是在等一个永远不会被打开的浏览器。
54
+ function ghToken() {
55
+ try { return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); }
56
+ catch { return ''; }
57
+ }
58
+
59
+ const done = [];
60
+ function mark(m) { done.push(m); }
61
+
62
+ // ---- 0. 前置检查 ----------------------------------------------------------
63
+ step('前置检查');
64
+ const branch = gitOut('rev-parse', '--abbrev-ref', 'HEAD');
65
+ if (branch !== 'main' && branch !== 'dev') {
66
+ fail(`当前分支是 ${branch},发版只允许在 main 或 dev 上做`);
67
+ process.exit(1);
68
+ }
69
+ const dirty = gitOut('status', '--porcelain');
70
+ if (dirty) {
71
+ fail('工作区不干净,先提交或 stash:');
72
+ console.log(dirty.split('\n').map((l) => ' ' + l).join('\n'));
73
+ process.exit(1);
74
+ }
75
+ ok(`分支 ${branch},工作区干净`);
76
+
77
+ // ---- 1. 版本号 ------------------------------------------------------------
78
+ step('计算并写入版本号');
79
+ const pkgPath = path.join(ROOT, 'package.json');
80
+ const serverPath = path.join(ROOT, 'server.json');
81
+ const verPath = path.join(ROOT, 'src', 'shared', 'version.js');
82
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
83
+ const from = pkg.version;
84
+ const [maj, min, pat] = from.split('.').map(Number);
85
+ const to = bumpKind === 'major' ? `${maj + 1}.0.0`
86
+ : bumpKind === 'minor' ? `${maj}.${min + 1}.0`
87
+ : `${maj}.${min}.${pat + 1}`;
88
+ console.log(` ${from} → ${to} (${bumpKind})`);
89
+
90
+ if (dry) {
91
+ warn('--dry:只打印,不写文件、不提交、不发布');
92
+ process.exit(0);
93
+ }
94
+
95
+ function rewrite(file, fn) {
96
+ const p = path.join(ROOT, file);
97
+ const before = fs.readFileSync(p, 'utf8');
98
+ const after = fn(before);
99
+ if (after === before) throw new Error(`${file} 没发生替换 —— 版本号格式变了?请人工确认`);
100
+ fs.writeFileSync(p, after);
101
+ }
102
+ rewrite('package.json', (s) => s.replace(`"version": "${from}"`, `"version": "${to}"`));
103
+ // server.json 里有两处(顶层 + packages[0]),必须都改:只改顶层会让
104
+ // registry 认为"清单说 2.1.3、实际包是 2.1.2",校验直接不通过。
105
+ rewrite('server.json', (s) => s.split(`"version": "${from}"`).join(`"version": "${to}"`));
106
+ rewrite('src/shared/version.js', (s) => s.replace(`VERSION = '${from}'`, `VERSION = '${to}'`));
107
+ ok('4 处版本号已同步(package.json / server.json×2 / version.js)');
108
+ mark('版本号');
109
+
110
+ // ---- 2. 测试 --------------------------------------------------------------
111
+ step(fullTests ? '全量测试' : '发版闸门测试');
112
+ const testFiles = fullTests
113
+ ? []
114
+ : ['tests/test_version.js', 'tests/test_imports.js', 'tests/test_api_budget.js', 'tests/test_mcp_tools.js'];
115
+ const t = spawnSync('npx', ['vitest', 'run', ...testFiles, '--no-file-parallelism'], {
116
+ cwd: ROOT, encoding: 'utf8',
117
+ });
118
+ if (t.status !== 0) {
119
+ fail('测试未通过,发版中止。版本号改动已写在工作区,可用 git checkout 撤销。');
120
+ console.log((t.stdout || '').split('\n').slice(-25).join('\n'));
121
+ process.exit(1);
122
+ }
123
+ ok('测试通过');
124
+ mark('测试');
125
+
126
+ // ---- 3. commit + tag ------------------------------------------------------
127
+ step('提交并打 tag');
128
+ const notes = notesFile && fs.existsSync(path.join(ROOT, notesFile))
129
+ ? fs.readFileSync(path.join(ROOT, notesFile), 'utf8').trim()
130
+ : `v${to}`;
131
+ git('add', 'package.json', 'server.json', 'src/shared/version.js');
132
+ git('commit', '-m', `chore(release): v${to}`);
133
+ ok(`commit chore(release): v${to}`);
134
+ mark('commit');
135
+
136
+ // tag 打在 main 上(历史 v2.1.x 都在 main)。dev 上发版就先 ff 到 main。
137
+ if (branch === 'dev') {
138
+ git('checkout', 'main');
139
+ try { git('merge', '--ff-only', 'dev'); } catch (e) {
140
+ fail('main 无法快进到 dev(有分叉),需要人工处理。tag 未创建。');
141
+ git('checkout', 'dev');
142
+ process.exit(1);
143
+ }
144
+ ok('main 已快进到 dev');
145
+ }
146
+ git('tag', '-a', `v${to}`, '-m', notes);
147
+ ok(`tag v${to}`);
148
+ mark('tag');
149
+
150
+ // ---- 4. push --------------------------------------------------------------
151
+ step('推送到 GitHub');
152
+ try {
153
+ git('push', 'origin', 'main');
154
+ git('push', 'origin', 'dev');
155
+ git('push', 'origin', `v${to}`);
156
+ ok('main / dev / tag 已推送');
157
+ mark('git push');
158
+ } catch (e) {
159
+ fail('推送失败(网络或权限):' + String(e.message).split('\n')[0]);
160
+ warn(`本地 commit 与 tag 都在,网络恢复后执行:git push origin main dev v${to}`);
161
+ }
162
+
163
+ // ---- 5. npm ---------------------------------------------------------------
164
+ step('发布到 npm');
165
+ const npm = spawnSync('npm', ['publish'], { cwd: ROOT, encoding: 'utf8' });
166
+ if (npm.status === 0) {
167
+ ok(`@glittering0717/ai-browser@${to} 已发布`);
168
+ mark('npm publish');
169
+ } else {
170
+ const out = `${npm.stdout || ''}${npm.stderr || ''}`;
171
+ // 三层认证失败的表现各不相同,逐条给可执行的下一步(2026-10-23 一天踩完):
172
+ // 401 / E404 on PUT → token 过期或被撤销(npm 用 404 掩盖"没权限")
173
+ // 403 + "bypass 2fa" → token 有效但建的时候没勾 Bypass 2FA
174
+ // EPUBLISHCONFLICT → 版本号已被占用
175
+ if (/bypass 2fa enabled is required/i.test(out)) {
176
+ fail('npm token 没有开 Bypass 2FA —— 账号开了 2FA 时,granular token 必须勾这个开关才能发布');
177
+ console.log(' https://www.npmjs.com/settings/glittering0717/tokens/new');
178
+ console.log(' Permissions: Read and write | Scope: @glittering0717 | 勾 Bypass 2FA');
179
+ } else if (/E401|E404|Unauthorized|Not found/i.test(out)) {
180
+ fail('npm 认证失效(401/404)。需要重新登录一次,之后本脚本可全自动:');
181
+ console.log(' npm login # 浏览器登录');
182
+ console.log(' 或:把新的 granular access token 写进 ~/.npmrc 的 //registry.npmjs.org/:_authToken');
183
+ } else if (/EPUBLISHCONFLICT|cannot publish over/i.test(out)) {
184
+ fail(`npm 上已存在 ${to},版本号需要再往上走一格`);
185
+ } else {
186
+ fail('npm publish 失败:' + out.split('\n').filter(Boolean).slice(-4).join(' | '));
187
+ }
188
+ warn(`补救:token 恢复后执行 npm publish (当前目录版本已是 ${to})`);
189
+ }
190
+
191
+ // ---- 6. MCP registry ------------------------------------------------------
192
+ step('发布到 MCP registry');
193
+ const publisher = ['mcp-publisher', '/tmp/mcp-pub/mcp-publisher', '/opt/homebrew/bin/mcp-publisher']
194
+ .find((p) => { try { return fs.existsSync(p) || execFileSync('which', [p]).length > 0; } catch { return false; } });
195
+ if (!publisher) {
196
+ warn('未找到 mcp-publisher,跳过。安装:gh release download --repo modelcontextprotocol/registry \\');
197
+ console.log(' --pattern "mcp-publisher_darwin_arm64.tar.gz" && tar xzf ...');
198
+ } else {
199
+ // registry 的登录态是一枚短期 JWT(几小时就过期)。过期表现是 401
200
+ // "token has invalid claims: token is expired" —— 不是"没登录",
201
+ // 所以 `login` 会直接成功但不会有人去重跑它。这里自动续一次:
202
+ // MCP_GITHUB_TOKEN 让 login 走非交互路径(device flow 在无 TTY 时会静默卡住)。
203
+ const doPublish = () => spawnSync(publisher, ['publish'], { cwd: ROOT, encoding: 'utf8' });
204
+ let pub = doPublish();
205
+ let pubOut = `${pub.stdout || ''}${pub.stderr || ''}`;
206
+ if (pub.status !== 0 && /expired|invalid or expired|status 401/i.test(pubOut)) {
207
+ warn('registry 登录已过期,自动续期一次…');
208
+ const relogin = spawnSync(publisher, ['login', 'github'], {
209
+ cwd: ROOT,
210
+ encoding: 'utf8',
211
+ env: { ...process.env, MCP_GITHUB_TOKEN: ghToken() },
212
+ });
213
+ const reloginOut = `${relogin.stdout || ''}${relogin.stderr || ''}`;
214
+ if (relogin.status === 0 && /logged in/i.test(reloginOut)) {
215
+ ok('已重新登录');
216
+ pub = doPublish();
217
+ pubOut = `${pub.stdout || ''}${pub.stderr || ''}`;
218
+ } else {
219
+ warn('自动续期失败(需要 gh CLI 已登录):' + reloginOut.trim().split('\n').pop());
220
+ }
221
+ }
222
+ if (pub.status === 0 && /successfully published/i.test(pubOut)) {
223
+ ok('MCP registry 已发布');
224
+ mark('mcp registry');
225
+ } else {
226
+ fail('MCP registry 发布失败:' + pubOut.split('\n').filter(Boolean).slice(-6).join(' | '));
227
+ if (/was not found \(status: 404\)/i.test(pubOut)) {
228
+ warn('registry 要求 npm 上先有该版本 —— 先把 npm 那步修好,再重跑本脚本');
229
+ }
230
+ warn('补救:mcp-publisher publish');
231
+ }
232
+ }
233
+
234
+ // ---- 摘要 -----------------------------------------------------------------
235
+ console.log(`\n${c(36)}── v${to} 发版摘要 ──${c(0)}`);
236
+ console.log(' 已完成: ' + (done.length ? done.join(' → ') : '(无)'));
237
+ const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', 'npm publish', 'mcp registry']
238
+ .filter((s) => !done.includes(s));
239
+ if (skipped.length) console.log(` 未完成: ${skipped.join(', ')}`);
240
+ process.exit(skipped.length ? 2 : 0);
@@ -42,7 +42,10 @@ function valueOf(pv) {
42
42
  // The only "rule" we keep (plan §5): fold that into a semantic `textbox`.
43
43
  function editorTypeFrom(role, editable) {
44
44
  if (editable === 'richtext' || editable === 'contenteditable') return 'richtext';
45
- if (role === 'textbox') {
45
+ // searchbox 与 textbox 同为可输入面,只是语义上被标成搜索框。此前只认
46
+ // textbox,导致 role=searchbox 的输入框**完全不出现在树的可编辑索引里** ——
47
+ // 于是"两个 editable"的场景里少算了一个,primary 反而可能指错。
48
+ if (role === 'textbox' || role === 'searchbox') {
46
49
  if (editable === 'multiline') return 'textarea';
47
50
  if (editable === 'plaintext') return 'textbox';
48
51
  }
@@ -100,6 +103,14 @@ let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
100
103
  // wrappers (§buildThroughIgnored); an AX node that a wrapper re-parents must not
101
104
  // be emitted twice.
102
105
  let seenNodes = null;
106
+ // 本次 pass 收集到的"可编辑节点"清单,pass 结束时汇总成 tree.editors(见
107
+ // summarizeEditors)。为什么要在树根上放索引:一个页面上有多个 editable 时,
108
+ // 树的结构本身**不告诉你哪个是正文**。2026-10-19 知乎实机:顶部搜索框和
109
+ // 正文编辑器都带 editable,按 editable 过滤先抓到搜索框(DOM 顺序在前),
110
+ // 第一次粘贴直接糊进搜索框,靠字数校验才拦住没提交。
111
+ // 面积是这里唯一可靠的区分信号(搜索框 300×36 vs 正文 720×600,差 30 倍),
112
+ // 而它已经在每个节点的 bounds 里 —— 不需要额外读 DOM。
113
+ let editables = null;
103
114
 
104
115
  // Descendants of an AX node that is `ignored: true`. AX marks presentational
105
116
  // wrappers (a `span` in a flex row, `<b>` inside a paragraph) as ignored, but
@@ -138,7 +149,10 @@ function build(node, idFor) {
138
149
  },
139
150
  };
140
151
  if (node.backendDOMNodeId != null) tn.backendDOMNodeId = node.backendDOMNodeId;
141
- if (editorType) tn.editor_type = editorType;
152
+ if (editorType) {
153
+ tn.editor_type = editorType;
154
+ noteEditable(tn, editorType, node);
155
+ }
142
156
 
143
157
  if (role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'searchbox') {
144
158
  let v = valueOf(node.value);
@@ -183,6 +197,74 @@ function build(node, idFor) {
183
197
  return tn;
184
198
  }
185
199
 
200
+ // 「这个输入框是不是搜索框」的判据。role=searchbox 是最强信号;其次是
201
+ // placeholder/label 里的词。两者都没有时就靠面积(搜索框永远比正文小得多)。
202
+ // 只用来**标注**,不用来排除 —— 排除错一个真编辑器,代价是 agent 找不到
203
+ // 能写的地方。
204
+ const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
205
+
206
+ // 面积下限:AX 里存在 1px 的表单元素(隐藏 input、脚手架占位),它们的
207
+ // bounds 是 0 或 1,算进清单里只会制造噪音。
208
+ const EDITABLE_MIN_AREA = 800;
209
+ // 「像个正文编辑器」的下限。200×100 是保守值:真实正文区都在 400×200 以上,
210
+ // 而搜索框在 300×36 量级。达不到就不给 primary —— 宁可让调用方自己看图,
211
+ // 也不要指错。
212
+ const PRIMARY_MIN_AREA = 20000;
213
+
214
+ function noteEditable(tn, editorType, node) {
215
+ if (!editables) return;
216
+ const b = tn.bounds || {};
217
+ const w = Number(b.width) || 0;
218
+ const h = Number(b.height) || 0;
219
+ const area = w * h;
220
+ if (area < EDITABLE_MIN_AREA) return;
221
+ const ph = String(valueOf(prop(nodeStore, node.nodeId, 'placeholder')) || '');
222
+ const label = String(tn.label || '');
223
+ const searchlike = tn.role === 'searchbox'
224
+ || SEARCH_LIKE.test(ph)
225
+ || SEARCH_LIKE.test(label);
226
+ editables.push({
227
+ ref: tn.id,
228
+ role: tn.role,
229
+ editor_type: editorType,
230
+ label: label.slice(0, 40),
231
+ ...(ph ? { placeholder: ph.slice(0, 40) } : {}),
232
+ w,
233
+ h,
234
+ area,
235
+ readonly: (tn.states || []).indexOf('readonly') >= 0,
236
+ ...(searchlike ? { searchlike: true } : {}),
237
+ });
238
+ }
239
+
240
+ /**
241
+ * 把本 pass 收集到的可编辑节点汇总成树根上的 `editors` 索引。
242
+ *
243
+ * 存在的唯一理由:**树的结构不告诉你哪个 editable 是正文**。按 DOM 顺序
244
+ * 过滤 `editable` 拿到的是页面上第一个可输入的东西 —— 在知乎是顶部搜索框,
245
+ * 在掘金是登录框。agent 没法从 `[textbox] editable=richtext` 这一行判断
246
+ * "这是不是我要写的那个",于是它要么猜,要么一个个试,要么把自己的正文
247
+ * 糊进搜索框(2026-10-19 真实发生,靠字数校验才拦住)。
248
+ *
249
+ * 输出按面积降序,`primary: true` 标给最大的那个非搜索非只读编辑区。
250
+ * 只有 1 个可编辑节点时**不输出**:没有歧义就不该多花 token。
251
+ *
252
+ * @returns {Array|undefined} 0/1 个节点返回 undefined
253
+ */
254
+ export function summarizeEditors(list) {
255
+ if (!Array.isArray(list) || list.length < 2) return undefined;
256
+ const sorted = list.slice().sort((a, b) => b.area - a.area).slice(0, 8);
257
+ let primary = null;
258
+ for (const e of sorted) {
259
+ if (e.readonly || e.searchlike) continue;
260
+ if (e.area < PRIMARY_MIN_AREA) break; // 最大的可用编辑区都不像正文
261
+ primary = e.ref;
262
+ break;
263
+ }
264
+ // area 不外露:w/h 已经够了,重复字段只多花 token。
265
+ return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
266
+ }
267
+
186
268
  /**
187
269
  * `InlineTextBox` 是 CDP 把一个 `StaticText` 按**换行和样式**切出来的文字碎片:
188
270
  * 一段两行的正文会变成两个 inline box,一个加粗的词再切一个。
@@ -225,6 +307,7 @@ export function normalizeAxTree(rawNodes, opts = {}) {
225
307
  nodeStore = new Map(all.map((n) => [n.nodeId, n]));
226
308
  seenNodes = new Set();
227
309
  readValue = opts.readValue || null;
310
+ editables = [];
228
311
  const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
229
312
 
230
313
  // Parent map over ALL nodes, so "is this a root?" accounts for ignored
@@ -249,6 +332,11 @@ export function normalizeAxTree(rawNodes, opts = {}) {
249
332
  ? built[0]
250
333
  : { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
251
334
  tree._debug = { layer: 'ax', version: VERSION_TAG };
335
+ // 有歧义时才给索引(见 summarizeEditors)。挂在树根而不是散在各节点上:
336
+ // 调用方读一次根就能挑好目标,不用把整棵树扫一遍再自己比面积。
337
+ const editors = summarizeEditors(editables);
338
+ if (editors) tree.editors = editors;
339
+ editables = null;
252
340
  nodeStore = null;
253
341
  seenNodes = null;
254
342
  readValue = null;
@@ -26,7 +26,7 @@ export const MCP_TOOLS = [
26
26
  {
27
27
  name: 'browse_get_tree',
28
28
  title: 'Read semantic tree',
29
- description: 'Return the page semantic UI tree (roles, labels, actions, bounds, urls).',
29
+ description: 'Return the page semantic tree (roles, labels, actions, bounds). Root adds editors (primary) when 2+ editable.',
30
30
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
31
31
  inputSchema: {
32
32
  type: 'object',
@@ -42,7 +42,7 @@ export const MCP_TOOLS = [
42
42
  {
43
43
  name: 'browse_act',
44
44
  title: 'Act on element',
45
- description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. Verb-specific params live in the group of the same name (click/read/press/wheel/drag).',
45
+ description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. page_changed:false = dispatched but page unchanged (wrapper).',
46
46
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
47
47
  inputSchema: {
48
48
  type: 'object',
@@ -50,7 +50,7 @@ export const MCP_TOOLS = [
50
50
  action: { type: 'string', enum: ['click', 'type', 'setContent', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Verb. Verb-specific params live in the group of the same name.' },
51
51
  target: { type: 'string', description: 'data-ai-id from the tree (e.g. e:button-12) — NOT the visible label. Optional for press; drag may use drag.from; upload also takes a CSS selector.' },
52
52
  text: { type: 'string', description: 'type: appended at the caret. setContent: replaces all. paste: plain fallback when html is absent.' },
53
- html: { type: 'string', description: 'paste: HTML, preferred — editors keep headings/lists/bold. Plain text loses formatting.' },
53
+ html: { type: 'string', description: 'paste: HTML, preferred (keeps headings/bold). landed_chars:null = unreadable.' },
54
54
  value: { type: 'string', description: 'Option value for a <select>.' },
55
55
  file: { type: 'string', description: 'upload: absolute path. target may be the input, the trigger, or a CSS selector.' },
56
56
  timeout_ms: { type: 'integer', description: 'Deadline ms (default 15000, 1s..300s). On timeout: success=false, timed_out=true, may have partially applied.' },
@@ -15,6 +15,12 @@ import { WINDOW_OPEN_SHIM_SOURCE } from './window_open_shim_source.js';
15
15
  import { pickOpenedTab, openedTabGraceMs } from '../shared/opened_tab.js';
16
16
  // 动作回执自带的"页面到底动没动"三态,以及 ref 过期诊断(两个纯函数模块)。
17
17
  import { withEffectState, isTargetMiss as isTargetMissError } from '../shared/effect_state.js';
18
+ // 前后快照探针:动作返回 success 只证明"命令发出去了",不证明"页面接住了"。
19
+ // 后者必须由掌握渲染进程的一侧给出(见 shared/page_probe.js 顶部事故说明)。
20
+ import pageProbe from '../shared/page_probe.js';
21
+ // 默认读法(DOM extractor)的"哪个 editable 才是正文"索引。AX 路径在
22
+ // normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
23
+ import editorsProbe from '../shared/editors_probe.js';
18
24
  import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
19
25
  import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
20
26
  import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
@@ -185,6 +191,11 @@ class PageManager {
185
191
  // 垫片登记完再导航,否则真实文档先到、垫片只对"下一次文档"生效,那个 tab
186
192
  // 就成了没有垫片的窗中窗(见 newTab 末尾长注释)。
187
193
  this._windowOpenShimReady = new Map();
194
+ // tabId -> 'pending' | 'loading' | 'ready' | 'blank':这个 tab 现在处于导航的
195
+ // 哪一步。存在的理由:新 tab 在占位页(原先是纯白 about:blank)停留时,
196
+ // 光看 URL 分不清"正在加载"和"卡住了/开错了"。listTabs 把它报出去,
197
+ // agent 才知道该等而不是该重试。
198
+ this._tabNavState = new Map();
188
199
  this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
189
200
  this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
190
201
  this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
@@ -753,9 +764,45 @@ class PageManager {
753
764
  // 「ref 过期了」(页面变过,重读树即可)和「ref 从来没对过」(改传参)。
754
765
  // 见 shared/ref_epoch.js 顶部注释。
755
766
  this._noteTreeRefs(tid, res && res.tree);
767
+ // 多编辑器的消歧索引(见 shared/editors_probe.js)。AX 路径的树根已经带
768
+ // `editors`,这里只补默认路径;focused_only 的子树没有"整页有哪几个编辑区"
769
+ // 这层语义,跳过。
770
+ if (res && res.tree && !res.tree.editors && !focusedOnly) {
771
+ const idx = await this._editorsIndex(tid, res.tree);
772
+ if (idx) res.tree.editors = idx;
773
+ }
756
774
  return res;
757
775
  }
758
776
 
777
+ /**
778
+ * 扫一遍页面上的可编辑区,按面积给出 primary。
779
+ * 失败静默返回 null —— 消歧索引是**增强**,读不到不该让 get_tree 失败。
780
+ */
781
+ async _editorsIndex(tid, tree) {
782
+ const view = this._getView(tid);
783
+ if (!view) return null;
784
+ let rows = null;
785
+ try {
786
+ const d = view.webContents.debugger;
787
+ if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
788
+ const r = await d.sendCommand('Runtime.evaluate', {
789
+ expression: editorsProbe.EDITORS_PROBE_JS,
790
+ returnByValue: true,
791
+ });
792
+ rows = r && r.result && r.result.value;
793
+ } catch (_e) {
794
+ return null;
795
+ }
796
+ const known = new Set();
797
+ const walk = (n) => {
798
+ if (!n) return;
799
+ if (n.id) known.add(n.id);
800
+ for (const c of n.children || []) walk(c);
801
+ };
802
+ walk(tree);
803
+ return editorsProbe.buildEditorsIndex(rows, known);
804
+ }
805
+
759
806
  /** per-tab 的 ref 版本追踪器(懒建,避免动构造函数)。 */
760
807
  _refTracker(tid) {
761
808
  if (!this._refTrackers) this._refTrackers = new Map();
@@ -1369,16 +1416,59 @@ class PageManager {
1369
1416
  * 放在这一层而不是各个子路径里,是因为挂住的位置不止一个(AX handles、指针目标
1370
1417
  * 解析、拖拽按键分发都可能),逐个补等于逐个漏。
1371
1418
  */
1419
+ /**
1420
+ * 动作前后各拍一次页面指纹(只读、不写属性、不触发事件)。
1421
+ *
1422
+ * 探针失效一律返回 null 并让调用方跳过 —— 观测能力不足不能反过来让动作失败。
1423
+ * 三种失效都真实发生过:debugger 没 attach、主线程被占住导致 CDP 不返回、
1424
+ * 页面返回了 __err。全部走静默降级,但**绝不用 null 冒充"没变化"**:
1425
+ * diffProbe 对 null 显式返回 changed:null,回执里就不会出现 page_changed。
1426
+ */
1427
+ async _pageProbe(tid, action) {
1428
+ if (!pageProbe.shouldProbe(action)) return null;
1429
+ const view = this._getView(tid);
1430
+ if (!view) return null;
1431
+ try {
1432
+ const d = view.webContents.debugger;
1433
+ if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
1434
+ // 1.5s 硬上限:主线程卡住时 sendCommand 永不返回,等它等于把 ui.act
1435
+ // 又挂死一次(这一层存在的全部意义就是不许挂)。
1436
+ const call = d.sendCommand('Runtime.evaluate', {
1437
+ expression: pageProbe.PAGE_PROBE_JS,
1438
+ returnByValue: true,
1439
+ });
1440
+ const timer = new Promise((r) => setTimeout(() => r(null), 1500));
1441
+ const r = await Promise.race([call.catch(() => null), timer]);
1442
+ const v = r && r.result && r.result.value;
1443
+ return v && !v.__err ? v : null;
1444
+ } catch (_e) {
1445
+ return null;
1446
+ }
1447
+ }
1448
+
1372
1449
  async executeAction(action, target, params = {}, tabId) {
1373
1450
  const ms = actionDeadlineMs(action, params);
1374
1451
  const tid = tabId !== undefined ? tabId : this.activeTab;
1452
+ // 动作前先拍一张。为什么拍在 withDeadline 外面:deadline 只应该盖住动作
1453
+ // 本身,探针有它自己的 1.5s 上限,混进同一个预算会让"动作超时"和
1454
+ // "探针慢"两种完全不同的故障共用一个错误码。
1455
+ const probeBefore = await this._pageProbe(tid, action);
1375
1456
  try {
1376
1457
  const res = await withDeadline(
1377
1458
  this._executeActionInner(action, target, params, tabId),
1378
1459
  ms,
1379
1460
  `action:${action}`
1380
1461
  );
1381
- return this._annotateActResult(res, action, target, tid);
1462
+ let out = this._annotateActResult(res, action, target, tid);
1463
+ // 动作后拍第二张。settle 是必须的:paste/type 内部已经等过一轮,但 click
1464
+ // 触发的弹窗/路由要等框架提交 + 一帧渲染,立刻拍必然拿到旧指纹 ——
1465
+ // 那会把每一次成功的弹窗点击都误报成"页面没变",比不报更糟。
1466
+ if (probeBefore) {
1467
+ await new Promise((r) => setTimeout(r, pageProbe.settleMs(action)));
1468
+ const probeAfter = await this._pageProbe(tid, action);
1469
+ out = pageProbe.applyProbe(out, probeBefore, probeAfter, action);
1470
+ }
1471
+ return out;
1382
1472
  } catch (e) {
1383
1473
  if (e && e.__deadline) {
1384
1474
  // 明确告诉 agent 两件事:① 它该重试/换策略;② 这个动作**可能已经部分生效**
@@ -2490,6 +2580,30 @@ class PageManager {
2490
2580
  // 派发不会向下传播到子节点,监听器收不到,defaultPrevented 恒为 false。
2491
2581
  // 实测:B站 read-editor 拆两步派发 → 编辑器完全无反应;合并 + 选定最深层
2492
2582
  // 可编辑元素后成功。
2583
+ // 页面内"读回编辑器正文"的共用片段。参数是**已经在前面的表达式里定义好的
2584
+ // 变量名**(不是选择器),因为不同时机能拿到的句柄不同:paste 那一刻有
2585
+ // `ed`,之后重采样只能重新按 ref 查。
2586
+ //
2587
+ // 取"多个候选里最长的那个"而不是"第一个非空的":页面上同时存在搜索框、
2588
+ // 标题框、正文编辑器时,第一个非空的很可能是别的东西(知乎顶部搜索框就
2589
+ // 这么骗过一次粘性校验)。而正文的长度必然最大 —— 这条启发式在
2590
+ // 知乎/掘金/CSDN/OSChina/B站 五个站上都成立。
2591
+ const landedProbe = (v) =>
2592
+ `var cands=[],push=function(n,s){if(n&&cands.length<6)cands.push([n,s]);};` +
2593
+ `push(${v},'target');` +
2594
+ // 深层 activeElement(穿透 shadow root):编辑面常常在编辑器组件自己的
2595
+ // shadow DOM 里,document.activeElement 只到宿主元素。
2596
+ `try{var a=document.activeElement,g=0;while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement;push(a,'active');}catch(_e){}` +
2597
+ // 目标内部的第一个编辑面(Draft.js / ProseMirror 的正文都是容器里的子节点)
2598
+ `try{var q=${v}&&${v}.querySelector&&${v}.querySelector('[contenteditable=true],textarea,.cm-content,.ProseMirror,[data-lexical-editor=true]');push(q,'inner');}catch(_e){}` +
2599
+ // 上层编辑器容器:目标可能只是工具栏/占位层,正文在它的祖先里
2600
+ `try{var hh=${v}&&${v}.closest&&${v}.closest('.cm-editor,.cm-scroller,.CodeMirror,.ProseMirror,.DraftEditor-root,[contenteditable=true],form,article,main');push(hh,'host');}catch(_e){}` +
2601
+ `var best={n:0,src:'',p:''};` +
2602
+ `for(var i=0;i<cands.length;i++){var e=cands[i][0],s=cands[i][1],t='';` +
2603
+ `try{t=(e.value!=null)?String(e.value):String(e.innerText||e.textContent||'');}catch(_e){}` +
2604
+ `t=t.replace(/\\s+/g,' ');var L=t.replace(/ /g,'').length;` +
2605
+ `if(L>best.n)best={n:L,src:s,p:t.slice(0,200)};}`;
2606
+
2493
2607
  const payload = JSON.stringify({ html, text });
2494
2608
  const res = await run(
2495
2609
  `var p=${payload};` +
@@ -2507,7 +2621,11 @@ class PageManager {
2507
2621
  `dt.setData('text/plain',p.text||p.html.replace(/<[^>]+>/g,' ').replace(/&nbsp;/g,' ').replace(/\\s+/g,' ').trim());` +
2508
2622
  `var ev=new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true,composed:true});` +
2509
2623
  `var notPrevented=ed.dispatchEvent(ev);` +
2510
- `return {ok:true,prevented:!notPrevented,tag:ed.tagName};`
2624
+ // 同步读一次:CodeMirror 6 / ProseMirror / Quill 都在 paste handler 里
2625
+ // 同步写文档,这一刻就能读到;异步提交的(Draft.js 走 setTimeout)
2626
+ // 读不到,交给后面的延迟采样。
2627
+ landedProbe('ed') +
2628
+ `return {ok:true,prevented:!notPrevented,tag:ed.tagName,landed0:best};`
2511
2629
  );
2512
2630
  if (!res || !res.ok) {
2513
2631
  return { success: false, error: (res && res.err) || 'paste failed', method: 'synthetic-clipboard-event' };
@@ -2522,19 +2640,45 @@ class PageManager {
2522
2640
  consumed: false,
2523
2641
  };
2524
2642
  }
2525
- // 落盘校验:读回编辑器内容。归一化沿用 P0-2 修过的口径(换行会被编辑器
2526
- // 膨胀,逐字比对必然失配)。只给 html 时无法文本比对,退而检查"有内容"。
2527
- await new Promise((r) => setTimeout(r, 350)); // 编辑器处理粘贴多为异步(React 提交)
2528
- const landed = await run(`var e=${deepActiveExpr()};return {text:(e&&(e.innerText||e.value||''))||'',html:(e&&e.innerHTML||'')};`);
2529
- const landedText = (landed && landed.text) || '';
2530
- const landedLen = landedText.replace(/\s+/g, '').length;
2643
+ // 落盘校验:读回编辑器内容。
2644
+ //
2645
+ // 为什么不能只读一次、也不能只读 activeElement(2026-10-19 掘金实机):
2646
+ // 掘金(CodeMirror 6)粘贴成功、正文完整,回执却是 landed_chars: 0。
2647
+ // 根因是读回走了 deepActive —— 那一刻 activeElement 并不是编辑面(编辑器
2648
+ // 把焦点放在 .cm-scroller 上,真正的内容在 .cm-content),于是读到空。
2649
+ // 调用方看到 0 就再粘一次,正文变成两份。**假阴性比漏报贵得多**:它直接
2650
+ // 诱发重复写入,而重复写入是这件工具最不该制造的事故。
2651
+ //
2652
+ // 所以现在:① 以**刚粘贴的那个元素**为主,activeElement / 内部编辑面 /
2653
+ // 上层容器依次兜底;② 三次采样(同步 / +350ms / +900ms)取最大值,覆盖
2654
+ // 异步提交;③ 真读不到时给 landed_chars: null 而不是 0 —— 0 是"确定空",
2655
+ // null 是"读不到",这两种事实对调用方的决策完全相反。
2656
+ let landed = res.landed0 && res.landed0.n > 0 ? res.landed0 : null;
2657
+ let samples = 1;
2658
+ for (const wait of [350, 900]) {
2659
+ if (landed) break;
2660
+ await new Promise((r) => setTimeout(r, wait));
2661
+ const again = await run(
2662
+ `var t=${findRefExpr(JSON.stringify(aiId))}||document.activeElement;if(!t)return {n:0,src:'none',p:''};` +
2663
+ landedProbe('t') + `return best;`
2664
+ ).catch(() => null);
2665
+ samples++;
2666
+ if (again && again.n > 0) landed = again;
2667
+ }
2668
+ const landedLen = landed ? landed.n : 0;
2531
2669
  if (!landedLen) {
2532
2670
  return {
2533
2671
  success: false,
2534
- error: 'paste was consumed but nothing landed — the editor may have rejected the payload (unsanitized HTML?). Read the editor content back to confirm.',
2672
+ // 措辞从 "nothing landed"(断言)改成 "could not be read back"(观测)。
2673
+ // 前者在掘金那种场景里是错的 —— 它会把一次成功的粘贴报成失败,而
2674
+ // agent 对 success:false 的标准反应就是重试一次。
2675
+ error: 'paste was consumed but the editor content could not be read back — this is NOT proof that nothing landed (CodeMirror-style editors keep the document outside the focused node). '
2676
+ + 'Verify with get_value on the same ref before doing anything else; re-pasting on a live editor duplicates the content.',
2535
2677
  method: 'synthetic-clipboard-event',
2536
2678
  consumed: true,
2537
- landed_chars: 0,
2679
+ landed_chars: null,
2680
+ landed_verified: false,
2681
+ landed_samples: samples,
2538
2682
  };
2539
2683
  }
2540
2684
  return {
@@ -2543,8 +2687,11 @@ class PageManager {
2543
2687
  consumed: true,
2544
2688
  in_frame: inFrame,
2545
2689
  landed_chars: landedLen,
2546
- // 只给 html 时调用方无法预知文本形态,把读回内容捎上,省一轮查询。
2547
- ...(html && !text ? { landed_preview: landedText.slice(0, 200) } : {}),
2690
+ landed_verified: true,
2691
+ // 读回来源:'target' 最可信;'host' 是容器兜底,可能把页面别处的文字
2692
+ // 算进来 —— 长度只用于"有没有落地",不做逐字比对,所以偏大是安全方向。
2693
+ landed_source: landed.src,
2694
+ ...(html && !text ? { landed_preview: landed.p || '' } : {}),
2548
2695
  };
2549
2696
  }
2550
2697
 
@@ -3129,6 +3276,8 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3129
3276
  const wc0 = view.webContents;
3130
3277
  const targetUrl = url || null;
3131
3278
  if (wc0 && !wc0.isDestroyed()) {
3279
+ // 还没发起真实导航:停在占位页等垫片注入。
3280
+ this._tabNavState.set(tabId, 'pending');
3132
3281
  // 垫片就绪信号:先挂上去,再去加载 about:blank。顺序不能反 ——
3133
3282
  // ai:window_open_nav 可能在这一行之后立刻到达(IPC 保序,但它不等我们)。
3134
3283
  let signalReady = () => {};
@@ -3139,19 +3288,38 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3139
3288
  if (proceeded) return;
3140
3289
  proceeded = true;
3141
3290
  Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
3142
- // 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在 about:blank。
3291
+ // 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在占位页。
3143
3292
  signalReady();
3144
3293
  if (targetUrl && wc0 && !wc0.isDestroyed()) {
3294
+ // 让"正在加载"可被观测:白屏最贵的地方不是慢,是慢得无法和
3295
+ // "开错了"区分(见 listTabs 的 state 字段)。
3296
+ this._tabNavState.set(tabId, 'loading');
3297
+ wc0.once('did-finish-load', () => {
3298
+ if (this._tabNavState.get(tabId) === 'loading') this._tabNavState.set(tabId, 'ready');
3299
+ });
3145
3300
  wc0.loadURL(targetUrl).catch(() => {});
3301
+ } else {
3302
+ // 无目标 URL 的占位 tab(window.open("") 的老站模式):它本来就
3303
+ // 要停在这里等站点赋 location,不算"卡住",但也不是就绪。
3304
+ this._tabNavState.set(tabId, 'blank');
3146
3305
  }
3147
3306
  });
3148
3307
  };
3149
- // 兜底:about:blank 的 did-finish-load 万一来不了(renderer 异常、
3308
+ // 兜底:占位页的 did-finish-load 万一来不了(renderer 异常、
3150
3309
  // 视图尚未 attach 等),也不能把真实导航永久挂住——1.2s 后照常放行,
3151
3310
  // 最坏退化成"首屏没有垫片"(与修复前一致),而不是打不开页面。
3152
3311
  const guard = setTimeout(proceed, 1200);
3153
3312
  if (guard.unref) guard.unref();
3154
- wc0.loadURL('about:blank').catch(() => {});
3313
+ // ⚠️ 占位页用 loading.html 而不是 about:blank(2026-10-24 修)。
3314
+ //
3315
+ // 这段等待原本是纯白的 about:blank。快站约 4s,慢站实测 70s(GitHub
3316
+ // OAuth 直到 ERR_CONNECTION_TIMED_OUT 才切到错误页)—— 用户和 agent
3317
+ // 看到的都是"弹出来一个空白页面",和"垫片没把导航发出去"那个老 bug
3318
+ // (超星"进入学习")在现象上完全一样,无法区分。
3319
+ // loading.html 只做展示(目标地址 + 转圈),不发请求不跳转,真实导航
3320
+ // 仍由上面的 proceed 发起。无目标 URL 的占位 tab(window.open("") 的
3321
+ // 老站模式)保持 about:blank:它本来就要停在这里等 location 赋值。
3322
+ wc0.loadURL(this._loadingPlaceholderUrl(targetUrl)).catch(() => {});
3155
3323
  wc0.once('did-finish-load', () => { clearTimeout(guard); proceed(); });
3156
3324
  }
3157
3325
 
@@ -3382,6 +3550,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3382
3550
  }
3383
3551
  this._windowOpenShimTabs.delete(tabId);
3384
3552
  this._windowOpenShimReady.delete(tabId);
3553
+ this._tabNavState.delete(tabId);
3385
3554
  // Release per-tab network resources so a closed tab can never leak its
3386
3555
  // debugger attachment or cached request entries / stale monitors.
3387
3556
  this._cleanupTabResources(tabId);
@@ -3437,6 +3606,12 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3437
3606
  url: view.webContents.getURL(),
3438
3607
  title: view.webContents.getTitle(),
3439
3608
  active: id === this.activeTab,
3609
+ // 'pending' = 停在占位页等脚本注入(真实导航还没发起)
3610
+ // 'loading' = 真实导航已发起、还没完成 —— 这时候页面多半还是空的,
3611
+ // 是"正在打开"而不是"开错了",等它,不要重试
3612
+ // 'blank' = window.open("") 的占位 tab,在等站点赋 location
3613
+ // 'ready' = 已就绪(没有记录时也按就绪处理)
3614
+ state: this._tabNavState.get(id) || 'ready',
3440
3615
  });
3441
3616
  }
3442
3617
  return list;
@@ -3826,6 +4001,20 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3826
4001
  // load about:blank,did-finish-load 后再调这里。
3827
4002
  // 幂等:每个 tab 只注册一次(_windowOpenShimTabs 记账);跨导航自动保留
3828
4003
  // 正是 addScriptToEvaluateOnNewDocument 的语义,不需要重复注册。
4004
+ // 新 tab 的占位页 URL:有目标地址时用 loading.html(把白屏变成可诊断的
4005
+ // "正在打开 <url>"),无目标地址时保持 about:blank(window.open("") 的
4006
+ // 占位 tab 要停在这里等站点赋 location,不能显示成"正在打开")。
4007
+ _loadingPlaceholderUrl(targetUrl) {
4008
+ if (!targetUrl) return 'about:blank';
4009
+ try {
4010
+ const pagePath = path.join(__dirname, '../renderer/loading.html');
4011
+ const q = new URLSearchParams({ url: String(targetUrl) });
4012
+ return 'file://' + pagePath + '?' + q.toString();
4013
+ } catch (_e) {
4014
+ return 'about:blank';
4015
+ }
4016
+ }
4017
+
3829
4018
  _ensureWindowOpenShim(tabId, view) {
3830
4019
  if (this._windowOpenShimTabs.has(tabId)) return Promise.resolve(true);
3831
4020
  this._windowOpenShimTabs.add(tabId);
@@ -160,10 +160,42 @@ function doPaste(el, params) {
160
160
  if (notPrevented) {
161
161
  return {
162
162
  success: false,
163
+ consumed: false,
163
164
  error: "paste event was not consumed by the page (defaultPrevented=false) — try a child node ref, or setContent for plain text"
164
165
  };
165
166
  }
166
- return { success: true, method: "synthetic-clipboard-event" };
167
+ // 读回口径与 page_manager._pasteViaCdp 完全一致:候选里取最长,读不到给
168
+ // null 而不是 0。为什么必须有:preload 这条路以前**只要页面 preventDefault
169
+ // 就报 success**,连"有没有内容"都不看 —— 这正是"工具说成功、页面没变"
170
+ // 最纯粹的一种来源。0 表示确定空(可安全重试),null 表示读不到(先核对)。
171
+ var best = { n: 0, src: "target", p: "" };
172
+ var cands = [editor];
173
+ try {
174
+ if (document.activeElement && document.activeElement !== editor) cands.push(document.activeElement);
175
+ } catch (_e) {}
176
+ try {
177
+ var q = editor.querySelector && editor.querySelector("[contenteditable=true],textarea,.cm-content,.ProseMirror");
178
+ if (q) cands.push(q);
179
+ } catch (_e) {}
180
+ for (var i = 0; i < cands.length; i++) {
181
+ var e = cands[i], t = "";
182
+ try { t = (e.value != null) ? String(e.value) : String(e.innerText || e.textContent || ""); } catch (_e) {}
183
+ t = t.replace(/\s+/g, " ");
184
+ var L = t.replace(/ /g, "").length;
185
+ if (L > best.n) best = { n: L, src: (i === 0 ? "target" : "candidate"), p: t.slice(0, 200) };
186
+ }
187
+ var out = {
188
+ success: true,
189
+ method: "synthetic-clipboard-event",
190
+ consumed: true,
191
+ landed_chars: best.n > 0 ? best.n : null,
192
+ landed_verified: best.n > 0,
193
+ };
194
+ if (best.n > 0) out.landed_source = best.src;
195
+ if (!best.n) {
196
+ out.effect_hint = "paste was consumed but the editor content could not be read back — verify with get_value before retrying; re-pasting on a live editor duplicates the content.";
197
+ }
198
+ return out;
167
199
  }
168
200
 
169
201
  // setContent — write to any editor type including Draft.js/ProseMirror
@@ -0,0 +1,75 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>正在打开…</title>
6
+ <!--
7
+ loading.html — 新标签页的加载占位页。
8
+
9
+ 为什么需要它:新建 tab 时必须先 load 一个真实文档才能注入 window.open 垫片
10
+ (CDP 命令在未导航的空 webContents 上无人处理会挂住),注入完成后才导航到
11
+ 真实 URL。这段间隔原本是 `about:blank` —— 也就是一片纯白。网络慢时它能持续
12
+ 几十秒(实测 GitHub OAuth 70s 才 ERR_CONNECTION_TIMED_OUT),用户和 agent
13
+ 看到的都是"弹出来一个空白页面",无法区分"正在加载"和"开错了"。
14
+
15
+ 这个页面本身不发请求、不跑脚本,只把"正在打开哪个地址"显示出来,把白屏
16
+ 变成一个可诊断的状态。它是纯展示:真实导航由主进程在垫片注入完成后发起。
17
+ -->
18
+ <style>
19
+ * { margin: 0; padding: 0; box-sizing: border-box; }
20
+ html, body { height: 100%; }
21
+ body {
22
+ background: #1e1e1e;
23
+ color: #d4d4d4;
24
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif;
25
+ font-size: 13px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ padding: 24px;
30
+ }
31
+ .box { max-width: 560px; width: 100%; }
32
+ .row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
33
+ .spinner {
34
+ width: 14px; height: 14px; flex: none;
35
+ border: 2px solid #3c3c3c;
36
+ border-top-color: #569cd6;
37
+ border-radius: 50%;
38
+ animation: spin 0.8s linear infinite;
39
+ }
40
+ @keyframes spin { to { transform: rotate(360deg); } }
41
+ h1 { font-size: 14px; font-weight: 600; color: #d4d4d4; }
42
+ .url {
43
+ background: #252526; border: 1px solid #3c3c3c; border-radius: 4px;
44
+ padding: 8px 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
45
+ font-size: 12px; color: #9cdcfd; word-break: break-all; margin-bottom: 14px;
46
+ min-height: 34px;
47
+ }
48
+ .hint { color: #858585; font-size: 12px; line-height: 1.7; }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <div class="box">
53
+ <div class="row">
54
+ <div class="spinner"></div>
55
+ <h1>正在打开…</h1>
56
+ </div>
57
+ <div class="url" id="u">about:blank</div>
58
+ <div class="hint">
59
+ 新标签页需要先准备好脚本注入环境,再跳转目标地址。<br>
60
+ 长时间停在这里通常意味着目标地址连接超时(常见于被墙或缓慢的第三方登录)。
61
+ </div>
62
+ </div>
63
+ <script>
64
+ // 只做展示:把目标地址从 query 里取出来显示。
65
+ // 不发起任何请求、不做跳转 —— 真实导航由主进程发起,避免和它抢。
66
+ (function () {
67
+ try {
68
+ var q = new URLSearchParams(location.search);
69
+ var u = q.get('url') || '';
70
+ if (u) document.getElementById('u').textContent = u;
71
+ } catch (e) {}
72
+ })();
73
+ </script>
74
+ </body>
75
+ </html>
@@ -0,0 +1,96 @@
1
+ // shared/editors_probe.js — DOM 侧的可编辑区清单,给**默认读法**补消歧信息。
2
+ //
3
+ // 为什么 AX 那边(main/axExtractor.js 的 summarizeEditors)已经有了一份、
4
+ // 这里还要再来一份:两条读法覆盖的是同一批页面,但 browse_get_tree 的**默认**
5
+ // 路径走 DOM extractor,不产出 `editable` 状态串,也就没有可编辑区索引。
6
+ // 只在 AX 路径修,等于告诉用户"想知道哪个是正文请加 ax:true" —— 而默认路径
7
+ // 恰恰是最常被用的那条。坑必须在自己所在的那条路上修掉。
8
+ //
9
+ // 只读、只碰四个属性(rect / placeholder / aria-label / data-ai-id),不写 DOM。
10
+ export const EDITORS_PROBE_JS = `(function(){
11
+ try{
12
+ var sel='input,textarea,[contenteditable=true],[contenteditable=""],[role=searchbox]';
13
+ var all=document.querySelectorAll(sel),out=[];
14
+ for(var i=0;i<all.length&&out.length<40;i++){
15
+ var e=all[i],r;
16
+ try{r=e.getBoundingClientRect();}catch(_e){continue;}
17
+ var w=Math.round(r.width),h=Math.round(r.height);
18
+ if(w*h<800) continue;
19
+ var tag=e.tagName.toLowerCase();
20
+ if(tag==='input'){
21
+ var ty=String(e.type||'text').toLowerCase();
22
+ if(/^(hidden|submit|button|checkbox|radio|file|reset|image|color|range)$/.test(ty)) continue;
23
+ }
24
+ var ce=e.getAttribute('contenteditable');
25
+ var et=(ce==='true'||ce==='')?'richtext':(tag==='textarea'?'textarea':'textbox');
26
+ var ph=String(e.getAttribute('placeholder')||'').slice(0,40);
27
+ out.push({
28
+ ref:String(e.getAttribute('data-ai-id')||''),
29
+ role:String(e.getAttribute('role')||'textbox'),
30
+ editor_type:et,w:w,h:h,
31
+ label:String(e.getAttribute('aria-label')||'').slice(0,40),
32
+ placeholder:ph,
33
+ readonly:!!e.readOnly,
34
+ type:tag==='input'?String(e.type||'text').toLowerCase():''
35
+ });
36
+ }
37
+ return out;
38
+ }catch(e){return {__err:String((e&&e.message)||e)}}
39
+ })()`;
40
+
41
+ // 与 axExtractor 的 SEARCH_LIKE 必须一致 —— 两处口径分叉会让"同一个页面在
42
+ // ax:true 与默认读法下给出相反的 primary",那比没有索引更糟。这里不 import
43
+ // 是因为 axExtractor 属于 main 进程模块,而本文件也被 preload 侧测试引用。
44
+ export const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
45
+
46
+ const MIN_AREA = 800;
47
+ const PRIMARY_MIN_AREA = 20000;
48
+
49
+ /**
50
+ * 把 DOM 探针的原始行转换成与 AX 路径**同构**的 editors 数组。
51
+ * 纯函数,可直接单测(不需要 Electron)。
52
+ *
53
+ * @param {Array} rows EDITORS_PROBE_JS 的返回值
54
+ * @param {Set} knownRefs 树里真实存在的 ref(探针是全局扫的,包含被剪枝的节点)
55
+ * @returns {Array|null} <2 个有效编辑区时返回 null
56
+ */
57
+ export function buildEditorsIndex(rows, knownRefs) {
58
+ if (!Array.isArray(rows)) return null;
59
+ const known = knownRefs instanceof Set ? knownRefs : new Set(knownRefs || []);
60
+ const list = [];
61
+ for (const r of rows) {
62
+ if (!r || !r.ref || (known.size && !known.has(r.ref))) continue;
63
+ const w = Number(r.w) || 0;
64
+ const h = Number(r.h) || 0;
65
+ const area = w * h;
66
+ if (area < MIN_AREA) continue;
67
+ const ph = String(r.placeholder || '');
68
+ const label = String(r.label || '');
69
+ const searchlike = r.role === 'searchbox'
70
+ || r.type === 'search'
71
+ || SEARCH_LIKE.test(ph)
72
+ || SEARCH_LIKE.test(label);
73
+ list.push({
74
+ ref: r.ref,
75
+ role: r.role || 'textbox',
76
+ editor_type: r.editor_type || 'textbox',
77
+ label: label.slice(0, 40),
78
+ ...(ph ? { placeholder: ph.slice(0, 40) } : {}),
79
+ w, h, area,
80
+ readonly: !!r.readonly,
81
+ ...(searchlike ? { searchlike: true } : {}),
82
+ });
83
+ }
84
+ if (list.length < 2) return null;
85
+ const sorted = list.sort((a, b) => b.area - a.area).slice(0, 8);
86
+ let primary = null;
87
+ for (const e of sorted) {
88
+ if (e.readonly || e.searchlike) continue;
89
+ if (e.area < PRIMARY_MIN_AREA) break;
90
+ primary = e.ref;
91
+ break;
92
+ }
93
+ return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
94
+ }
95
+
96
+ export default { EDITORS_PROBE_JS, buildEditorsIndex, SEARCH_LIKE };
@@ -67,6 +67,11 @@ const PARTIAL_PATTERNS = [
67
67
  /may have partially applied/i,
68
68
  /not consumed by the page/i,
69
69
  /consumed but nothing landed/i,
70
+ // 2026-10-19 之后 paste 的读回口径:读不到 ≠ 没落地(CodeMirror 把正文放在
71
+ // 焦点节点之外)。这条必须落在 partial 而不是 no-effect 里 —— 判成 none 会
72
+ // 告诉 agent"照原样重试是安全的",而在活着的编辑器上重试就是内容翻倍。
73
+ // 旧文案保留在上面,历史回执与日志仍要能被正确分类。
74
+ /could not be read back/i,
70
75
  /interrupted|disconnected|target closed|debugger detached/i,
71
76
  /deadline exceeded|timed? ?out/i,
72
77
  ];
@@ -0,0 +1,154 @@
1
+ // shared/page_probe.js — "这一步到底改动了页面没有"必须由工具回答,不能让 agent 猜。
2
+ //
3
+ // 事故(2026-10-19,B站投稿):
4
+ // browse_act click 返回 success:true,agent 以为投递成功,继续往下走。
5
+ // 实际点中的是**外层** `<div class="submit-container">`,真正的可点元素是它
6
+ // 里面的 `<span class="submit-add">`。页面一个字节都没变,回执却是一片绿。
7
+ // 错误在第三步之后才暴露 —— 这时已经不可能靠"回头看"把因果连起来。
8
+ //
9
+ // 为什么这必须是原语而不是让调用方自查:
10
+ // 调用方(模型)没有"我刚才那一手到底生效了吗"的直觉。它拿到的 success
11
+ // 只说明**命令发出去了**,不说明**页面接住了**。中间这层语义只能由掌握
12
+ // 渲染进程的工具补上。否则每个站点、每个动作都要调用方自己写一遍快照对比,
13
+ // 而它连"该在什么时候拍快照"都不知道(拍早了页面还没渲染)。
14
+ //
15
+ // 设计取舍:
16
+ // 1. 只读、极轻。一次 evaluate 拿 5 个标量,不碰 AX 树、不截图。
17
+ // 动作用户关心的时延是"点击后多久有反馈",多 2 次 evaluate(各 ~1ms)
18
+ // 换掉一整类静默失败,值得。
19
+ // 2. 指纹取**全量文本 hash + DOM 节点数 + 焦点元素内容长度 + URL**。
20
+ // 只看文本会漏掉"弹窗打开但文案相同",只看节点数会漏掉"同位置替换"。
21
+ // 四个信号任一变化即视为 changed —— 宁可报 changed:true 放过,
22
+ // 不可报 false 冤枉(false 会诱导 agent 重复提交)。
23
+ // 3. changed:false 不等于失败。点击一个已选中的 radio、关闭一个已关的抽屉,
24
+ // 都是合法的无变化。所以这里只**陈述观测**,把结论留给调用方,并配一句
25
+ // 可执行的下一步。真正"该有变化却无变化"的判定放在 MUST_HAVE_EFFECT 里,
26
+ // 只对写类动作(输入/粘贴/上传)生效 —— 那些动作没有"合法无变化"的情形。
27
+ //
28
+ // 4. 主文档视角的边界:探针只读 top document。OOPIF 子帧里的变化它看不见,
29
+ // 因此 changed:false 的措辞是"主文档未变化",不是"什么都没发生"。这个区别
30
+ // 写进 hint 里,避免把 B站 read-editor 那种跨进程编辑器误判成失败。
31
+
32
+ // 页面内执行的探针。纯读,不写任何属性,不触发任何事件。
33
+ // 返回的字段都用单字母:它要跨 RPC 边界回传,越短越好,语义在下面注释里。
34
+ // t = body 全量文本长度(折叠空白后,与 DEFAULT_SNAPSHOT_JS 的口径一致)
35
+ // h = 该文本的 djb2 hash(base36)—— 长度相同的内容替换靠它抓
36
+ // d = 当前**可编辑**焦点元素的内容长度(深浅穿透 shadow root)—— 输入类动作的主信号。
37
+ // 只算 input/textarea/select/contenteditable:点击一个普通 div 也会让
38
+ // activeElement 变过去,若把它的 innerText 算进来,每次"点了个没反应的
39
+ // 按钮"都会因为焦点跑过去而报 page_changed:true —— 那等于把这个探针
40
+ // 最该抓的场景(点了没反应)全丢掉。实测于 2026-10-22 的 fixture 回归。
41
+ // n = DOM 元素总数(粗粒度结构变化:弹窗、列表插入)
42
+ // u = location.href(SPA 路由跳转)
43
+ export const PAGE_PROBE_JS = `(function(){
44
+ try{
45
+ var b=document.body||document.documentElement;
46
+ var t=(b&&(b.innerText||b.textContent))||'';
47
+ t=t.replace(/\\s+/g,' ');
48
+ var h=5381;
49
+ for(var i=0;i<t.length;i++)h=((h<<5)+h+t.charCodeAt(i))|0;
50
+ var a=document.activeElement,g=0;
51
+ try{ while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement; }catch(_e){}
52
+ var dv='';
53
+ try{
54
+ if(a){
55
+ var tg=a.tagName;
56
+ var ed=(a.isContentEditable===true)||tg==='INPUT'||tg==='TEXTAREA'||tg==='SELECT';
57
+ if(ed) dv=(a.value!=null)?String(a.value):(a.innerText||a.textContent||'');
58
+ }
59
+ }catch(_e){}
60
+ var n=0;
61
+ try{ n=document.getElementsByTagName('*').length; }catch(_e){}
62
+ return {t:t.length,h:(h>>>0).toString(36),d:(dv||'').replace(/\\s+/g,'').length,n:n,u:String(location.href)};
63
+ }catch(e){ return {__err:String((e&&e.message)||e)}; }
64
+ })()`;
65
+
66
+ // 哪些动作值得拍前后快照。只读动作(get_value / focus / hover / scroll_to)
67
+ // 不拍:它们本来就不该改页面,拍了只是白花两次往返。
68
+ // wheel 也不拍:滚动会改变 scroll 位置但 body.innerText 不变,节点数也不变,
69
+ // 必然报 false —— 那是个稳定的假阴性,不如不报。
70
+ const PROBED_ACTIONS = new Set([
71
+ 'click', 'type', 'setContent', 'clear', 'paste', 'upload', 'drag', 'press',
72
+ ]);
73
+
74
+ // 这些动作**没有"合法无变化"的情形**:输入/粘贴/上传成功就一定有内容落地。
75
+ // 它们报 page_changed:false 时给强提示(并说明先读回再决定)。
76
+ const MUST_HAVE_EFFECT = new Set(['type', 'setContent', 'clear', 'paste', 'upload']);
77
+
78
+ export function shouldProbe(action) {
79
+ return PROBED_ACTIONS.has(String(action || ''));
80
+ }
81
+
82
+ // 动作返回后到拍快照之间要等多久,让 React/Vue 的提交与路由跳转落地。
83
+ // 写类动作内部已经等过一轮(paste 350ms、type 有 input 校验),只需短等;
84
+ // click 触发弹窗/路由时框架往往在下一个 microtask + 一帧后才渲染,给足 220ms。
85
+ export function settleMs(action) {
86
+ return MUST_HAVE_EFFECT.has(String(action || '')) ? 80 : 220;
87
+ }
88
+
89
+ const HINT_WRITE =
90
+ 'page_changed=false: the action returned success but the main document is byte-identical '
91
+ + '(same text hash, same DOM node count, same focused-field length). The payload most likely '
92
+ + 'never landed — a wrapper element was targeted, or the editor listens on a child surface. '
93
+ + 'Read the element back with get_value BEFORE retrying: a second paste on a live editor '
94
+ + 'duplicates the content instead of fixing it.';
95
+
96
+ const HINT_CLICK =
97
+ 'page_changed=false: the action returned success but the main document did not change. Two '
98
+ + 'common causes: (a) the click hit a wrapper element and the real handler is on a deeper '
99
+ + 'node — re-read the tree and target the inner <span>/<a>/<button>; (b) the control was '
100
+ + 'already in the requested state, in which case there is nothing to retry. If the effect was '
101
+ + 'expected inside an out-of-process iframe, this probe cannot see it — verify with '
102
+ + 'get_tree/screenshot instead.';
103
+
104
+ /**
105
+ * 比较前后两次探针。纯函数,无 Electron 依赖,可直接单测。
106
+ * @returns {{changed: boolean|null, delta: object|null}}
107
+ * changed=null 表示数据不可比(某次探针失败),调用方应忽略而不是当成 false。
108
+ */
109
+ export function diffProbe(before, after) {
110
+ const ok = (p) => p && typeof p === 'object' && !p.__err && typeof p.h === 'string';
111
+ if (!ok(before) || !ok(after)) return { changed: null, delta: null };
112
+ const delta = {
113
+ text_chars: (Number(after.t) || 0) - (Number(before.t) || 0),
114
+ dom_nodes: (Number(after.n) || 0) - (Number(before.n) || 0),
115
+ focused_chars: (Number(after.d) || 0) - (Number(before.d) || 0),
116
+ };
117
+ const changed = before.h !== after.h
118
+ || (Number(before.n) || 0) !== (Number(after.n) || 0)
119
+ || (Number(before.d) || 0) !== (Number(after.d) || 0)
120
+ || before.u !== after.u;
121
+ return { changed, delta };
122
+ }
123
+
124
+ /**
125
+ * 把观测结果写进动作回执。纯函数(返回新对象)。
126
+ *
127
+ * 只在 changed === false 时附加 page_delta 和 effect_hint:
128
+ * 正常路径(changed:true)不多说一个字,避免每步回执都多两行噪音。
129
+ */
130
+ export function applyProbe(res, before, after, action) {
131
+ if (!res || typeof res !== 'object') return res;
132
+ const d = diffProbe(before, after);
133
+ if (d.changed === null) return res;
134
+ const out = { ...res, page_changed: d.changed };
135
+ if (d.changed) return out;
136
+ out.page_delta = d.delta;
137
+ const isWrite = MUST_HAVE_EFFECT.has(String(action || ''));
138
+ if (!out.effect_hint) {
139
+ out.effect_hint = isWrite ? HINT_WRITE : HINT_CLICK;
140
+ }
141
+ // 写类动作 + 主文档零变化 = 内容很可能没落地。把 effect_state 从 committed
142
+ // 降回 unknown,否则回执是自相矛盾的:success:true + effect_state:committed
143
+ // 等于告诉 agent"这一手已经生效,别重试",而观测说它没生效。
144
+ //
145
+ // 为什么是 unknown 而不是 none:探针只看主文档,OOPIF 子帧里的编辑器
146
+ // (B站 read-editor)变化它看不见。unknown + verify_first 是唯一诚实的选择。
147
+ if (isWrite && out.success === true) {
148
+ out.effect_state = 'unknown';
149
+ out.retry = 'verify_first';
150
+ }
151
+ return out;
152
+ }
153
+
154
+ export default { PAGE_PROBE_JS, shouldProbe, settleMs, diffProbe, applyProbe };
@@ -2,4 +2,4 @@
2
2
  // Kept in JS (not imported from package.json) to avoid Node import-attribute
3
3
  // (JSON module) compatibility issues inside Electron. package.json "version"
4
4
  // is manually synced to this value; tests assert they match.
5
- export const VERSION = '2.1.1';
5
+ export const VERSION = '2.1.3';