@glittering0717/ai-browser 2.1.6 → 2.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittering0717/ai-browser",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
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": {
@@ -79,7 +79,8 @@
79
79
  "coverage": "node tools/coverage_probe.cjs",
80
80
  "build": "node scripts/build_dmg.cjs",
81
81
  "verify:build": "node scripts/verify_build.cjs",
82
- "install:app": "node scripts/install_app.cjs"
82
+ "install:app": "node scripts/install_app.cjs",
83
+ "dialog": "node e2e/dialog_ws.cjs"
83
84
  },
84
85
  "dependencies": {
85
86
  "@modelcontextprotocol/sdk": "^1.29.0",
@@ -27,7 +27,11 @@ const args = process.argv.slice(2);
27
27
  const dry = args.includes('--dry');
28
28
  const bumpKind = args.find((a) => ['patch', 'minor', 'major'].includes(a)) || 'patch';
29
29
  const notesIdx = args.indexOf('--notes');
30
- const notesFile = notesIdx >= 0 ? args[notesIdx + 1] : null;
30
+ const notesArg = notesIdx >= 0 ? args[notesIdx + 1] : null;
31
+ // --notes 既接受仓库内相对路径,也接受绝对路径(写手稿时通常放在 /tmp)。
32
+ // path.join(ROOT, '/tmp/x') 会拼出 ROOT/tmp/x 这种不存在的路径,所以先判绝对。
33
+ const notesPath = notesArg ? (path.isAbsolute(notesArg) ? notesArg : path.join(ROOT, notesArg)) : null;
34
+ const notesFile = notesPath && fs.existsSync(notesPath) ? notesPath : null;
31
35
  // 全量测试 4 分钟起,发版时默认只跑"会拦住错误发版"的那几个:
32
36
  // 版本一致性、导入完整性、schema 预算。其余靠 CI / 平时。
33
37
  const fullTests = args.includes('--full-test');
@@ -125,8 +129,8 @@ mark('测试');
125
129
 
126
130
  // ---- 3. commit + tag ------------------------------------------------------
127
131
  step('提交并打 tag');
128
- const notes = notesFile && fs.existsSync(path.join(ROOT, notesFile))
129
- ? fs.readFileSync(path.join(ROOT, notesFile), 'utf8').trim()
132
+ const notes = notesFile
133
+ ? fs.readFileSync(notesFile, 'utf8').trim()
130
134
  : `v${to}`;
131
135
  git('add', 'package.json', 'server.json', 'src/shared/version.js');
132
136
  git('commit', '-m', `chore(release): v${to}`);
@@ -160,6 +164,46 @@ try {
160
164
  warn(`本地 commit 与 tag 都在,网络恢复后执行:git push origin main dev v${to}`);
161
165
  }
162
166
 
167
+ // ---- 4.5 GitHub Release ---------------------------------------------------
168
+ // **tag ≠ Release**。git tag 只是仓库里的一个 ref,GitHub 的 /releases 页面
169
+ // 读的是 Release 对象;只打 tag 不建 Release,页面会一直停在很久以前的版本
170
+ // (2026-10-24 用户就是在这里发现"releases 还是 2.1.1",而 tag 已经有 2.1.7)。
171
+ //
172
+ // 用 --generate-notes:GitHub 按 commit 自动生成 changelog,比手写更容易保持。
173
+ // 幂等:重复跑发版时该 tag 的 Release 已存在就跳过,不报错。
174
+ step('创建 GitHub Release');
175
+ let releaseOk = false;
176
+ {
177
+ const hasGh = spawnSync('gh', ['--version'], { encoding: 'utf8' }).status === 0;
178
+ if (!hasGh) {
179
+ warn('未找到 gh CLI,跳过 Release 创建。补救:gh release create v' + to + ' --generate-notes');
180
+ } else {
181
+ const exists = spawnSync('gh', ['release', 'view', `v${to}`], { cwd: ROOT, encoding: 'utf8' }).status === 0;
182
+ if (exists) {
183
+ ok(`v${to} 的 Release 已存在,跳过`);
184
+ releaseOk = true;
185
+ mark('github release');
186
+ } else {
187
+ const argv = ['release', 'create', `v${to}`, '--verify-tag', '--latest', '--title', `v${to}`];
188
+ if (notesFile) {
189
+ argv.push('--notes-file', notesFile);
190
+ } else {
191
+ argv.push('--generate-notes');
192
+ }
193
+ const r = spawnSync('gh', argv, { cwd: ROOT, encoding: 'utf8' });
194
+ const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
195
+ if (r.status === 0) {
196
+ ok('Release 已创建:' + out.split('\n').filter(Boolean).pop());
197
+ releaseOk = true;
198
+ mark('github release');
199
+ } else {
200
+ fail('创建 Release 失败:' + out.split('\n').filter(Boolean).slice(-3).join(' | '));
201
+ warn(`补救:gh release create v${to} --verify-tag --latest --generate-notes`);
202
+ }
203
+ }
204
+ }
205
+ }
206
+
163
207
  // ---- 5. 打包(桌面 App)---------------------------------------------------
164
208
  // 放在 npm 之前:打包失败就不该把版本发出去(npm 上发出去的版本撤不回来,
165
209
  // 只能靠再发一个版本补救)。但 MCP registry 又要求 npm 先有版本,所以顺序
@@ -201,6 +245,24 @@ step('打包桌面 App');
201
245
  console.log((v.stdout || '').split('\n').map((l) => ' ' + l).join('\n'));
202
246
  process.exit(1);
203
247
  }
248
+
249
+ // 把 dmg 挂到 Release 上 —— 否则 /releases 页面只有一段 changelog,用户还得
250
+ // 自己去找 App。上传放在校验之后:不一致的产物绝对不能对外发。
251
+ const dmg = path.join(ROOT, 'dist', `AI-Browser-${to}-arm64.dmg`);
252
+ if (releaseOk && fs.existsSync(dmg)) {
253
+ const up = spawnSync('gh', ['release', 'upload', `v${to}`, dmg, '--clobber'], {
254
+ cwd: ROOT, encoding: 'utf8',
255
+ });
256
+ if (up.status === 0) {
257
+ ok(`dmg 已挂到 Release(${(fs.statSync(dmg).size / 1024 / 1024).toFixed(1)} MB)`);
258
+ mark('release asset');
259
+ } else {
260
+ fail('dmg 上传失败:' + `${up.stdout || ''}${up.stderr || ''}`.trim().split('\n').slice(-2).join(' | '));
261
+ warn(`补救:gh release upload v${to} ${dmg} --clobber`);
262
+ }
263
+ } else if (!fs.existsSync(dmg)) {
264
+ warn('dmg 不存在,跳过上传');
265
+ }
204
266
  }
205
267
 
206
268
  // ---- 6. npm ---------------------------------------------------------------
@@ -319,7 +381,7 @@ if (process.platform !== 'darwin') {
319
381
  // ---- 摘要 -----------------------------------------------------------------
320
382
  console.log(`\n${c(36)}── v${to} 发版摘要 ──${c(0)}`);
321
383
  console.log(' 已完成: ' + (done.length ? done.join(' → ') : '(无)'));
322
- const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', '打包', '打包校验', 'npm publish', 'mcp registry', '安装']
384
+ const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', 'github release', '打包', '打包校验', 'release asset', 'npm publish', 'mcp registry', '安装']
323
385
  .filter((s) => !done.includes(s));
324
386
  if (skipped.length) console.log(` 未完成: ${skipped.join(', ')}`);
325
387
  process.exit(skipped.length ? 2 : 0);
@@ -18,6 +18,10 @@ import { withEffectState, isTargetMiss as isTargetMissError } from '../shared/ef
18
18
  // 前后快照探针:动作返回 success 只证明"命令发出去了",不证明"页面接住了"。
19
19
  // 后者必须由掌握渲染进程的一侧给出(见 shared/page_probe.js 顶部事故说明)。
20
20
  import pageProbe from '../shared/page_probe.js';
21
+ // 浏览器级对话框(alert / confirm / prompt / beforeunload)的策略。它们是
22
+ // Electron 画的原生 sheet,不在 DOM、不在 AX 树,而且**打开期间渲染器被阻塞**,
23
+ // 所以必须由主进程通过 CDP Page.handleJavaScriptDialog 应答 —— 见该模块头部说明。
24
+ import dialogPolicy from '../shared/dialog_policy.cjs';
21
25
  // 默认读法(DOM extractor)的"哪个 editable 才是正文"索引。AX 路径在
22
26
  // normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
23
27
  import editorsProbe from '../shared/editors_probe.js';
@@ -196,6 +200,14 @@ class PageManager {
196
200
  // 光看 URL 分不清"正在加载"和"卡住了/开错了"。listTabs 把它报出去,
197
201
  // agent 才知道该等而不是该重试。
198
202
  this._tabNavState = new Map();
203
+ // tabId -> 最近一次被处理掉的 JS 对话框:{type, message, accept, promptText,
204
+ // auto, at}。回执里带上它,agent 才知道"刚才那个动作中间弹了个确认框,
205
+ // 我替你按了确定"—— 否则它会以为点击之后什么都没发生。
206
+ this._lastDialog = new Map();
207
+ // 已挂上"对话框监听"的 tab(每个 tab 一次;Page.enable 是幂等的)
208
+ this._dialogWatched = new Set();
209
+ // tabId -> 当前**仍然打开**的对话框(manual 策略下会长期存在)
210
+ this._openDialogs = new Map();
199
211
  this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
200
212
  this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
201
213
  this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
@@ -1449,6 +1461,9 @@ class PageManager {
1449
1461
  async executeAction(action, target, params = {}, tabId) {
1450
1462
  const ms = actionDeadlineMs(action, params);
1451
1463
  const tid = tabId !== undefined ? tabId : this.activeTab;
1464
+ // 本次动作的起始时刻:用来判断"有没有对话框是在这一次动作里被应答的",
1465
+ // 并把结果写进回执(否则 agent 不知道点击中间弹过确认框)。
1466
+ const startedAt = Date.now();
1452
1467
  // 动作前先拍一张。为什么拍在 withDeadline 外面:deadline 只应该盖住动作
1453
1468
  // 本身,探针有它自己的 1.5s 上限,混进同一个预算会让"动作超时"和
1454
1469
  // "探针慢"两种完全不同的故障共用一个错误码。
@@ -1468,18 +1483,28 @@ class PageManager {
1468
1483
  const probeAfter = await this._pageProbe(tid, action);
1469
1484
  out = pageProbe.applyProbe(out, probeBefore, probeAfter, action);
1470
1485
  }
1486
+ const dlg = this._dialogSince(tid, startedAt);
1487
+ if (dlg) out = { ...out, dialog: dlg };
1471
1488
  return out;
1472
1489
  } catch (e) {
1473
1490
  if (e && e.__deadline) {
1491
+ // 超时的第一种解释是"主线程忙",但更常见也更致命的是**有对话框把渲染器
1492
+ // 冻住了**(alert/confirm 是原生 sheet,agent 看不见、也点不到)。
1493
+ // 这里先探测并按策略解开,再回报;不解开的话这条 tab 之后每条命令都会
1494
+ // 以同样的方式超时,agent 只会一路重试到放弃。
1495
+ const dlg = await this.recoverBlockedTab(tid).catch(() => null);
1474
1496
  // 明确告诉 agent 两件事:① 它该重试/换策略;② 这个动作**可能已经部分生效**
1475
1497
  // (超时时指针可能已经按下、字符可能已经输入了一半)—— 后者必须说,否则
1476
1498
  // agent 会以为页面没被动过而直接重试,造成双倍输入。
1477
1499
  return this._annotateActResult({
1478
1500
  success: false,
1479
- error: `${e.message} — the page's main thread is likely blocked; the action may have partially applied`,
1501
+ error: dlg
1502
+ ? `blocked by a browser-level JS dialog (${dlg.type}) — it is a native sheet, not page DOM, and the page is frozen while it is open. It has been ${dlg.accept ? 'accepted' : 'dismissed'} automatically; re-read the tree before retrying.`
1503
+ : `${e.message} — the page's main thread is likely blocked; the action may have partially applied`,
1480
1504
  timed_out: true,
1481
1505
  deadline_ms: ms,
1482
1506
  action,
1507
+ ...(dlg ? { dialog: dlg, retry: 'verify_first' } : {}),
1483
1508
  }, action, target, tid);
1484
1509
  }
1485
1510
  throw e;
@@ -3239,6 +3264,14 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3239
3264
  // Mask automation fingerprint: remove Electron from UA
3240
3265
  view.webContents.setUserAgent(config.userAgent);
3241
3266
 
3267
+ // beforeunload:Electron 侧的等价物。**不处理它会静默拦住导航** —— 既没有
3268
+ // 对话框、也没有报错,表现成"点了链接/调了 navigate,页面就是不动"。
3269
+ // 策略和 CDP 那条路共用一套(shared/dialog_policy.cjs):默认放行。
3270
+ view.webContents.on('will-prevent-unload', (e) => {
3271
+ const policy = this.dialogPolicy || dialogPolicy.policyFromEnv();
3272
+ if (dialogPolicy.allowUnload({ policy })) e.preventDefault();
3273
+ });
3274
+
3242
3275
  // Intercept window.open / new-window → create new tab instead
3243
3276
  view.webContents.setWindowOpenHandler(({ url: urlToOpen }) => {
3244
3277
  const childId = self.newTab(urlToOpen);
@@ -3551,6 +3584,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3551
3584
  this._windowOpenShimTabs.delete(tabId);
3552
3585
  this._windowOpenShimReady.delete(tabId);
3553
3586
  this._tabNavState.delete(tabId);
3587
+ // 对话框状态跟着 tab 走:留着会让下次同 id 的 tab 继承一个不存在的对话框。
3588
+ this._lastDialog.delete(tabId);
3589
+ this._openDialogs.delete(tabId);
3590
+ this._dialogWatched.delete(tabId);
3554
3591
  // Release per-tab network resources so a closed tab can never leak its
3555
3592
  // debugger attachment or cached request entries / stale monitors.
3556
3593
  this._cleanupTabResources(tabId);
@@ -3612,6 +3649,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3612
3649
  // 'blank' = window.open("") 的占位 tab,在等站点赋 location
3613
3650
  // 'ready' = 已就绪(没有记录时也按就绪处理)
3614
3651
  state: this._tabNavState.get(id) || 'ready',
3652
+ // 只有 manual 策略下才会长期存在。auto 模式下对话框在打开的瞬间就被
3653
+ // 应答了,所以正常情况这个字段不出现 —— 出现即意味着"有人需要点它"。
3654
+ ...(this._openDialogs.has(id) ? { dialog: this._openDialogs.get(id) } : {}),
3615
3655
  });
3616
3656
  }
3617
3657
  return list;
@@ -3973,6 +4013,127 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3973
4013
  }
3974
4014
  }
3975
4015
 
4016
+ // === 浏览器级对话框(alert / confirm / prompt / beforeunload)===
4017
+ //
4018
+ // 这类对话框由 Chromium 交给 embedder 渲染,Electron 画成原生 sheet:不在
4019
+ // DOM、不在 AX 树、点不到;**而且打开期间渲染器被阻塞**(Runtime.evaluate
4020
+ // 都不返回)。所以它必须由主进程应答 —— 页面侧的任何办法(JS 覆盖
4021
+ // window.confirm、语义树里找按钮)都治不了"渲染器已经停住"这件事。
4022
+ //
4023
+ // 应答走 CDP `Page.handleJavaScriptDialog`:该命令由浏览器进程处理,不需要
4024
+ // 渲染器,所以对话框阻塞页面时它照样生效。
4025
+ _ensureDialogWatch(tabId, view) {
4026
+ if (this._dialogWatched.has(tabId)) return;
4027
+ this._dialogWatched.add(tabId);
4028
+ const wc = view && view.webContents;
4029
+ if (!wc || wc.isDestroyed()) { this._dialogWatched.delete(tabId); return; }
4030
+ try {
4031
+ // 只有 Page 域启用后才会收到 javascriptDialogOpening。空文档上这条命令
4032
+ // 可能挂住,所以不 await(挂住也无害:只是这个 tab 收不到事件,动作超时
4033
+ // 时还有 recoverBlockedTab 的兜底探测)。
4034
+ wc.debugger.sendCommand('Page.enable').catch(() => {});
4035
+ } catch (_e) {
4036
+ this._dialogWatched.delete(tabId);
4037
+ }
4038
+ }
4039
+
4040
+ /** 当前策略(实例字段优先,便于测试/诊断;否则读环境变量)。 */
4041
+ _dialogPolicyNow() {
4042
+ return this.dialogPolicy || dialogPolicy.policyFromEnv();
4043
+ }
4044
+
4045
+ async _handleDialogOpening(tabId, params) {
4046
+ const dialog = {
4047
+ type: String((params && params.type) || 'alert'),
4048
+ message: String((params && params.message) || ''),
4049
+ defaultPrompt: params && params.defaultPrompt,
4050
+ at: Date.now(),
4051
+ };
4052
+ const decision = dialogPolicy.decideDialog(dialog, { policy: this._dialogPolicyNow() });
4053
+ if (!decision.auto) {
4054
+ // manual:不替调用方决定,把状态挂出去(listTabs 可见),由人来点。
4055
+ this._openDialogs.set(tabId, { type: dialog.type, message: dialog.message });
4056
+ return;
4057
+ }
4058
+ const handled = await this._answerDialog(tabId, decision.accept, decision.promptText);
4059
+ this._openDialogs.delete(tabId);
4060
+ if (!handled) return;
4061
+ this._lastDialog.set(tabId, {
4062
+ type: dialog.type,
4063
+ message: dialog.message,
4064
+ accept: !!decision.accept,
4065
+ ...(decision.accept && decision.promptText ? { prompt_text: decision.promptText } : {}),
4066
+ auto: true,
4067
+ at: dialog.at,
4068
+ });
4069
+ }
4070
+
4071
+ async _answerDialog(tabId, accept, promptText) {
4072
+ const view = this._getView(tabId);
4073
+ const wc = view && view.webContents;
4074
+ if (!wc || wc.isDestroyed()) return false;
4075
+ try {
4076
+ const params = { accept: !!accept };
4077
+ if (accept && typeof promptText === 'string' && promptText) params.promptText = promptText;
4078
+ await withDeadline(
4079
+ wc.debugger.sendCommand('Page.handleJavaScriptDialog', params),
4080
+ 2000,
4081
+ 'dialog_handle'
4082
+ );
4083
+ return true;
4084
+ } catch (_e) {
4085
+ return false; // "No dialog is showing" / debugger 未 attach:都不是错误
4086
+ }
4087
+ }
4088
+
4089
+ /**
4090
+ * 兜底:动作超时时**探测**是否有对话框把渲染器冻结了,并按策略应答。
4091
+ *
4092
+ * 为什么需要它:事件监听依赖 Page 域已启用。若某个 tab 从未启用(或启用被
4093
+ * 1500ms deadline 掐掉),我们就收不到 javascriptDialogOpening,但仍然必须
4094
+ * 有能力把它解开 —— 否则那条 tab 会永久卡死,只能重启浏览器。
4095
+ * CDP 对"没有对话框"会返回 "No dialog is showing",正好当探测用。
4096
+ *
4097
+ * @returns {null|{type,message,accept,auto,recovered:true}} 解开了就返回信息
4098
+ */
4099
+ async recoverBlockedTab(tabId) {
4100
+ const tid = tabId !== undefined ? tabId : this.activeTab;
4101
+ const decision = dialogPolicy.decideDialog({ type: 'confirm' }, { policy: this._dialogPolicyNow() });
4102
+ if (!decision.auto) return null; // manual 策略:不擅自替人点
4103
+ const view = this._getView(tid);
4104
+ const wc = view && view.webContents;
4105
+ if (!wc || wc.isDestroyed()) return null;
4106
+ try {
4107
+ await withDeadline(
4108
+ wc.debugger.sendCommand('Page.handleJavaScriptDialog', { accept: decision.accept }),
4109
+ 2000,
4110
+ 'dialog_probe'
4111
+ );
4112
+ } catch (_e) {
4113
+ return null; // 没有对话框(或 CDP 不可用)
4114
+ }
4115
+ const known = this._openDialogs.get(tid) || {};
4116
+ this._openDialogs.delete(tid);
4117
+ const info = {
4118
+ type: known.type || 'unknown',
4119
+ message: known.message || '',
4120
+ accept: !!decision.accept,
4121
+ auto: true,
4122
+ recovered: true,
4123
+ at: Date.now(),
4124
+ };
4125
+ this._lastDialog.set(tid, info);
4126
+ return info;
4127
+ }
4128
+
4129
+ /** 取"这次动作期间"处理过的对话框(回执用)。 */
4130
+ _dialogSince(tabId, since) {
4131
+ const d = this._lastDialog.get(tabId);
4132
+ if (!d || d.at < since) return null;
4133
+ const { at, ...rest } = d;
4134
+ return rest;
4135
+ }
4136
+
3976
4137
  _ensureCdp(tabId, view, domain) {
3977
4138
  try {
3978
4139
  const wc = view.webContents;
@@ -3983,6 +4144,9 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
3983
4144
  // Single shared listener per tab -> no duplicate broadcasts even when
3984
4145
  // many sessions subscribe to network/runtime events on the same tab.
3985
4146
  wc.debugger.on('message', (_event, method, params) => this._onCdpMessage(tabId, method, params));
4147
+ // 任何 tab 一旦挂上 CDP,就顺手盯住对话框:漏掉一个 alert 会让整条 tab
4148
+ // 永久卡死(渲染器被阻塞,后续每条命令都超时)。
4149
+ this._ensureDialogWatch(tabId, view);
3986
4150
  }
3987
4151
  const entry = this._cdpTabs.get(tabId);
3988
4152
  if (!entry.domains.has(domain)) {
@@ -4132,6 +4296,16 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
4132
4296
  }
4133
4297
 
4134
4298
  _onCdpMessage(tabId, method, params) {
4299
+ // 浏览器级对话框:**必须在最前面处理**,且不能受任何订阅条件限制 ——
4300
+ // 它决定这条 tab 会不会被永久冻结(见 _ensureDialogWatch 注释)。
4301
+ if (method === 'Page.javascriptDialogOpening') {
4302
+ this._handleDialogOpening(tabId, params).catch(() => {});
4303
+ return;
4304
+ }
4305
+ if (method === 'Page.javascriptDialogClosed') {
4306
+ this._openDialogs.delete(tabId);
4307
+ return;
4308
+ }
4135
4309
  // 抓包:请求侧(method / headers / POST body / initiator)与响应侧都落进
4136
4310
  // network_monitor;旧的网络事件推送行为保持不变。
4137
4311
  if (typeof method === 'string' && method.indexOf('Network.') === 0) {
@@ -142,14 +142,23 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
142
142
  send({ jsonrpc: '2.0', id, result });
143
143
  } catch (e) {
144
144
  if (e && e.__deadline) {
145
+ // 外层预算可能先于 page_manager 内层到期(内层还要等动作前探针)。
146
+ // 所以这条兜底路径也要能识别"是对话框把页面冻住了"—— 否则用户
147
+ // 看到的就是"点了删除,超时",而屏幕上那个原生确认框没人应答。
148
+ const dlg = await Promise.resolve()
149
+ .then(() => pageManager.recoverBlockedTab?.(tabId))
150
+ .catch(() => null);
145
151
  send({
146
152
  jsonrpc: '2.0', id,
147
153
  result: {
148
154
  success: false,
149
- error: `${e.message} — the page's main thread is likely blocked; the action may have partially applied`,
155
+ error: dlg
156
+ ? `blocked by a browser-level JS dialog (${dlg.type}) — native sheet, not page DOM; the page is frozen while it is open. It has been ${dlg.accept ? 'accepted' : 'dismissed'} automatically; re-read the tree before retrying.`
157
+ : `${e.message} — the page's main thread is likely blocked; the action may have partially applied`,
150
158
  timed_out: true,
151
159
  deadline_ms: budget,
152
160
  action: params.action,
161
+ ...(dlg ? { dialog: dlg, retry: 'verify_first' } : {}),
153
162
  },
154
163
  });
155
164
  } else {
@@ -0,0 +1,111 @@
1
+ // shared/dialog_policy.cjs — JavaScript 对话框(alert / confirm / prompt /
2
+ // beforeunload)的处理策略。
3
+ //
4
+ // # 为什么需要这一层(这是产品缺陷,不是站点适配)
5
+ //
6
+ // `alert()/confirm()` 不是页面 DOM 的一部分:Chromium 把它们交给 embedder
7
+ // 渲染,Electron 就画成**原生 sheet**(屏幕中间那个 Cancel / OK)。由此产生
8
+ // 三个后果,全都指向同一个事实——**agent 永远够不到它**:
9
+ //
10
+ // 1. 语义树里没有它(不在 DOM,也不在 AX 树),get_tree 看不见;
11
+ // 2. 截图里它是一层系统 UI,点击坐标不归页面渲染器管;
12
+ // 3. **对话框打开期间整个渲染器被阻塞**:Runtime.evaluate / Input.dispatch
13
+ // 全部挂住不返回,于是任何 act 都以"超时、主线程可能被阻塞"结束。
14
+ //
15
+ // 实测(2026-10-24,本地 fixture):点一个调用 confirm() 的按钮 → act 15s
16
+ // 超时,随后 evaluate 8s 超时;屏幕上是一个原生 "确定删除这条评论? Cancel/OK"。
17
+ // 用户侧表现就是"删不掉,还卡住"。
18
+ //
19
+ // 既然对话框属于**浏览器进程**,处理它的责任也必须在浏览器进程这边:
20
+ // 这一类对话框由本模块给出策略、由 page_manager 通过 CDP
21
+ // `Page.handleJavaScriptDialog` 直接应答(这条命令走浏览器进程,不需要
22
+ // 渲染器,所以对话框阻塞页面时它照样有效)。
23
+ //
24
+ // # 默认策略(auto)为什么是"接受"
25
+ //
26
+ // 对话框是对**上一个用户动作**的确认。agent 点"删除评论"时,点击本身就是
27
+ // 意图,再让它去点"确定"没有任何信息增量 —— 拒绝反而会让删除永远失败
28
+ // (这正是用户报的现象)。所以 auto 接受 alert/confirm/beforeunload;
29
+ // prompt 用页面自带的默认值提交(等于人直接按 OK),并把提交的值回报给
30
+ // 调用方。策略可整体覆盖:AI_BROWSER_DIALOG_POLICY=auto|accept|dismiss|manual。
31
+ //
32
+ // manual 只用于诊断:不自动应答,对话框保持打开,由人来点。
33
+
34
+ 'use strict';
35
+
36
+ const POLICIES = ['auto', 'accept', 'dismiss', 'manual'];
37
+
38
+ /** 环境变量/参数里的策略值归一化;非法值回落到 auto(不因为配置写错就卡死页面)。 */
39
+ function normalizePolicy(raw) {
40
+ const s = String(raw == null ? '' : raw).trim().toLowerCase();
41
+ return POLICIES.includes(s) ? s : 'auto';
42
+ }
43
+
44
+ /** 从环境变量读策略。每次读而不是缓存:便于诊断时改环境重启即可。 */
45
+ function policyFromEnv(env = process.env) {
46
+ return normalizePolicy(env && env.AI_BROWSER_DIALOG_POLICY);
47
+ }
48
+
49
+ /**
50
+ * 决定一个对话框怎么应答。
51
+ *
52
+ * @param {{type?:string, message?:string, defaultPrompt?:string}} dialog
53
+ * CDP `Page.javascriptDialogOpening` 的载荷。
54
+ * @param {{policy?:string}} [opts]
55
+ * @returns {{accept:boolean, promptText?:string, auto:boolean, reason:string}}
56
+ * auto=false 表示"没有人替它做决定"(manual 策略),调用方需要把它暴露出去。
57
+ */
58
+ function decideDialog(dialog, opts = {}) {
59
+ const policy = normalizePolicy(opts.policy);
60
+ const type = String((dialog && dialog.type) || 'alert');
61
+
62
+ if (policy === 'manual') {
63
+ return { accept: false, auto: false, reason: 'manual: left open for a human/caller' };
64
+ }
65
+ if (policy === 'accept') {
66
+ return { accept: true, auto: true, promptText: promptAnswer(dialog), reason: 'policy=accept' };
67
+ }
68
+ if (policy === 'dismiss') {
69
+ return { accept: false, auto: true, reason: 'policy=dismiss' };
70
+ }
71
+
72
+ // auto:按"人看到这个对话框会怎么做"来判断
73
+ switch (type) {
74
+ case 'beforeunload':
75
+ // 触发它的动作(点链接 / navigate / 关标签)本身就是"我要走",人也是点
76
+ // "离开"。拒绝会让 unload 卡死,页面再也导航不了。
77
+ return { accept: true, auto: true, reason: 'auto: beforeunload → leave (the action asked for it)' };
78
+ case 'alert':
79
+ // 只有一个按钮,没有选择空间。
80
+ return { accept: true, auto: true, reason: 'auto: alert → ok' };
81
+ case 'confirm':
82
+ return { accept: true, auto: true, reason: 'auto: confirm → accepted (the action that raised it was the intent)' };
83
+ case 'prompt':
84
+ // 文本无法猜,用页面给的默认值提交(等价人直接按 OK),并把值回报。
85
+ return { accept: true, auto: true, promptText: promptAnswer(dialog), reason: 'auto: prompt → accepted with default text' };
86
+ default:
87
+ return { accept: true, auto: true, reason: `auto: ${type} → accepted` };
88
+ }
89
+ }
90
+
91
+ function promptAnswer(dialog) {
92
+ const d = dialog && dialog.defaultPrompt;
93
+ return typeof d === 'string' ? d : '';
94
+ }
95
+
96
+ /**
97
+ * 导航时是否放行 beforeunload(Electron 的 will-prevent-unload)。
98
+ * 只有明确要求"别自动通过"的策略才拦:manual / dismiss。
99
+ */
100
+ function allowUnload(opts = {}) {
101
+ const policy = normalizePolicy(opts.policy);
102
+ return policy === 'auto' || policy === 'accept';
103
+ }
104
+
105
+ module.exports = {
106
+ POLICIES,
107
+ normalizePolicy,
108
+ policyFromEnv,
109
+ decideDialog,
110
+ allowUnload,
111
+ };
@@ -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.6';
5
+ export const VERSION = '2.1.8';