@glittering0717/ai-browser 1.2.1 → 1.3.0

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.
Files changed (40) hide show
  1. package/README.md +155 -20
  2. package/bin/ai-browser.cjs +1 -1
  3. package/examples/AGENT_INSTRUCTIONS.md +22 -2
  4. package/llms-install.md +14 -4
  5. package/package.json +9 -5
  6. package/scripts/build_dmg.cjs +220 -0
  7. package/scripts/marketing/data_snapshot.sh +69 -0
  8. package/scripts/render_icon.cjs +118 -0
  9. package/src/main/axExtractor.js +103 -36
  10. package/src/main/error_log.js +245 -0
  11. package/src/main/index.js +487 -16
  12. package/src/main/mcp_server.js +39 -1
  13. package/src/main/mcp_tools.js +64 -10
  14. package/src/main/network_monitor.js +86 -23
  15. package/src/main/page_manager.js +1040 -115
  16. package/src/main/ws_server.js +127 -16
  17. package/src/preload/actions.cjs +61 -2
  18. package/src/preload/bridge.cjs +31 -1
  19. package/src/preload/extractor.cjs +520 -25
  20. package/src/renderer/error_banner.html +90 -0
  21. package/src/renderer/error_page.html +125 -0
  22. package/src/renderer/tab_bar.html +116 -56
  23. package/src/shared/act_params.cjs +53 -0
  24. package/src/shared/auth_wall.cjs +162 -0
  25. package/src/shared/config.js +8 -2
  26. package/src/shared/deadline.js +6 -0
  27. package/src/shared/deep_active.cjs +31 -0
  28. package/src/shared/error_banner.cjs +43 -0
  29. package/src/shared/find_ref.cjs +44 -0
  30. package/src/shared/frame_probe.cjs +112 -0
  31. package/src/shared/hit_point.cjs +80 -0
  32. package/src/shared/input_verify.js +47 -0
  33. package/src/shared/key_dispatch.cjs +37 -0
  34. package/src/shared/nav_wait.cjs +47 -0
  35. package/src/shared/protocol.js +15 -0
  36. package/src/shared/ref_guard.cjs +133 -0
  37. package/src/shared/screenshot_opts.cjs +130 -0
  38. package/src/shared/tree_diff.cjs +105 -0
  39. package/src/shared/version.js +1 -1
  40. package/tools/coverage_probe.cjs +239 -0
@@ -3,6 +3,14 @@ import { WebSocketServer } from 'ws';
3
3
  import { evaluateGuardError } from '../shared/guards.js';
4
4
  import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
5
5
  import { config } from '../shared/config.js';
6
+ // act 的参数形状归一(嵌套 params 与顶层平铺都接受)
7
+ import actParams from '../shared/act_params.cjs';
8
+ // 穿透影子根的 ref 查找。ui.scroll 的 target 也走它 —— 不穿透的话,目标在
9
+ // Web Component 里时会报 "target not found",而元素其实就在那儿。
10
+ import findRefUtil from '../shared/find_ref.cjs';
11
+ // 错误日志:RPC 层异常必须落盘(此前只回一句 JSON-RPC error,现场永久丢失),
12
+ // 每次调用的摘要进环形缓冲,作为崩溃时的上下文。
13
+ import { recordError, noteCall, recentErrors } from './error_log.js';
6
14
 
7
15
  // Each startWSServer() owns a private WebSocketServer. Keeping it local (not a
8
16
  // module global) means multiple servers can coexist — required by tests that
@@ -33,8 +41,27 @@ function findWaitTarget(tree, target) {
33
41
  return findInTree(tree, 'label', String(target));
34
42
  }
35
43
 
44
+ // 进程启动时刻(秒)。供 ui.server_info 报出去 —— pid 会被复用,只比 pid
45
+ // 不够稳;pid + started_at 两个一起才足以判定"是不是同一个进程"。
46
+ const serverStartedAt = Math.floor(Date.now() / 1000);
47
+
36
48
  export function startWSServer(pageManager, port = config.wsPort, onQuit = null) {
37
- const wss = new WebSocketServer({ port });
49
+ // Bind loopback only — the protocol carries no auth. ws' default (host
50
+ // omitted) is 0.0.0.0, which would expose the browser — including all its
51
+ // persisted logins — to the entire local network. Override via
52
+ // AI_BROWSER_WS_HOST only with eyes open.
53
+ const wss = new WebSocketServer({ port, host: config.wsBindHost });
54
+
55
+ // Passing a host pushes Node onto the async DNS-lookup listen path, so
56
+ // wss.address() is null until 'listening'. Cache it and let callers await.
57
+ let bound = null;
58
+ const ready = new Promise((resolve, reject) => {
59
+ wss.once('listening', () => { bound = wss.address(); resolve(bound); });
60
+ wss.once('error', reject);
61
+ });
62
+ ready.then(() => {
63
+ console.log(`AI Browser WS server listening on ws://${bound.address}:${bound.port}`);
64
+ }).catch(() => {});
38
65
 
39
66
  wss.on('connection', (ws, _req) => {
40
67
  const sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -72,6 +99,7 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
72
99
  if (ws.readyState === 1) ws.send(JSON.stringify(payload));
73
100
  };
74
101
 
102
+ const t0 = Date.now();
75
103
  try {
76
104
  switch (method) {
77
105
  case 'ui.get_tree': {
@@ -94,12 +122,23 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
94
122
  // - 这一层保护"以后新增的页面路径忘了套",并且**可以用 mock PageManager
95
123
  // 做单测**(真实 PageManager 依赖 Electron,vitest 里起不来)。
96
124
  // 同源意味着两层不会打架:预算一致,谁先到期谁报,语义相同。
97
- const budget = actionDeadlineMs(params.action, params.params || {});
125
+ //
126
+ // 参数形状:`params:{...}` 是规范形状,顶层平铺的已知动作参数会被
127
+ // 提升(见 shared/act_params.cjs)。此前平铺的 text/html 被静默丢掉,
128
+ // 动作照常执行但输入为空 —— 调用方看到的是 "type called with empty text"。
129
+ const hoisted = actParams.hoistActParams(params);
130
+ const actParamsObj = hoisted.params;
131
+ const budget = actionDeadlineMs(params.action, actParamsObj);
98
132
  try {
99
133
  const result = await withDeadline(
100
- pageManager.executeAction(params.action, params.target, params.params || {}, tabId),
134
+ pageManager.executeAction(params.action, params.target, actParamsObj, tabId),
101
135
  budget, `action:${params.action}`
102
136
  );
137
+ // 提升过就在回执里说明:调用方会知道"我平铺的参数被接受了",
138
+ // 而不是下次换个客户端又悄悄失效。
139
+ if (hoisted.hoisted.length && result && typeof result === 'object' && !result.params_hoisted) {
140
+ result.params_hoisted = hoisted.hoisted;
141
+ }
103
142
  send({ jsonrpc: '2.0', id, result });
104
143
  } catch (e) {
105
144
  if (e && e.__deadline) {
@@ -123,7 +162,7 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
123
162
  // navigate 现在返回 {ok, error?}:成功形状与旧的 {ok:true} 一致,
124
163
  // 失败时多一个原因。此前只有布尔,agent 拿到 ok:false 无从判断
125
164
  // 该重试还是换 URL;而"页面永不 did-finish-load"是会真实发生的。
126
- const r = await pageManager.navigate(params.url, tabId);
165
+ const r = await pageManager.navigate(params.url, tabId, { waitUntil: params.wait_until });
127
166
  send({ jsonrpc: '2.0', id, result: r && typeof r === 'object' ? r : { ok: !!r } });
128
167
  break;
129
168
  }
@@ -176,7 +215,12 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
176
215
  send({ jsonrpc: '2.0', id, error: { code: -32602, message: guardErr } });
177
216
  break;
178
217
  }
179
- const value = await pageManager.evaluate(js, tabId);
218
+ // frame_ref: run inside that cross-process frame's context.
219
+ // Needed because the implicit route (a literal data-ai-id="axf-..."
220
+ // inside the JS) silently no-ops when the caller builds the selector
221
+ // by string concatenation — the JS then runs in the top frame and
222
+ // reads null back for an element that exists.
223
+ const value = await pageManager.evaluate(js, tabId, { frameRef: params.frame_ref || undefined });
180
224
  send({ jsonrpc: '2.0', id, result: { value } });
181
225
  break;
182
226
  }
@@ -196,11 +240,39 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
196
240
  send({ jsonrpc: '2.0', id, result: { tabs } });
197
241
  break;
198
242
  }
243
+ // 窗口首次显示的时刻与当时页面状态。
244
+ // 存在的理由:白屏类缺陷的根因是"窗口显示了但渲染器还没画出第一帧",
245
+ // 而这只能从窗口侧观测 —— WS 端口在 createWindow 中途就开了,比
246
+ // showInitialWindow 早,从 agent 侧轮询永远测不到那个时序。
247
+ // 一次性记录:窗口只首次显示一次,之后一直是它。
248
+ case 'ui.window_shown': {
249
+ send({ jsonrpc: '2.0', id, result: { shown: pageManager.windowShownAt || null } });
250
+ break;
251
+ }
199
252
  case 'ui.set_active_tab': {
200
253
  const ok = pageManager.setActive(params.tab);
201
254
  send({ jsonrpc: '2.0', id, result: { ok } });
202
255
  break;
203
256
  }
257
+ // 服务端的进程身份。存在的理由(2026-10-15 实测):调用方**无法确认
258
+ // 自己连上的是不是自己拉起的那个实例**。决定性实验——在"端口忙"预检
259
+ // 之后让一个冒牌 WS server 抢占端口,e2e 套件照常发出 ui.new_tab /
260
+ // ui.evaluate / ui.get_tree 却毫不知情:测的是别人的实例,结果毫无
261
+ // 意义,而且不报错,只给出一堆莫名其妙的失败。
262
+ // 有了 pid,调用方就能核对"是不是我 spawn 的那个进程"。
263
+ case 'ui.server_info': {
264
+ send({
265
+ jsonrpc: '2.0', id,
266
+ result: {
267
+ pid: process.pid,
268
+ // 进程启动时刻(秒)。pid 可被复用,两个数字一起才够稳。
269
+ started_at: serverStartedAt,
270
+ ws_port: port,
271
+ platform: process.platform,
272
+ },
273
+ });
274
+ break;
275
+ }
204
276
  // === Subscribe/unsubscribe (with response) ===
205
277
  case 'ui.subscribe': {
206
278
  const events = params.events || [];
@@ -254,6 +326,24 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
254
326
  send({ jsonrpc: '2.0', id, result });
255
327
  break;
256
328
  }
329
+ case 'ui.auth_state': {
330
+ // 登录态 / 登录墙自查。长任务里每个关键步骤前问一次,成本近似为零。
331
+ // auth_wall.detected=true 是硬事实(URL 命中登录域 / 页面上有密码框),
332
+ // 此时任何"操作没生效"的排查都该先停下 —— 你大概不在目标站点的工作页。
333
+ const result = await pageManager.authState(tabId);
334
+ send({ jsonrpc: '2.0', id, result });
335
+ break;
336
+ }
337
+ case 'ui.screenshot': {
338
+ // 视口截图(P0-1)。工具面此前只有元素树一只眼睛,树里没有的
339
+ // (浮层/canvas/遮挡/渲染错位)就完全不可见 —— 而它们正是操作
340
+ // 失败的主因。失败时"先看截图"从此有工具可依。
341
+ // 参数:{ tab, format: 'png'|'jpeg'|'webp', quality: 0-100,
342
+ // fullPage: bool, clip: {x,y,width,height,scale} }
343
+ const result = await pageManager.screenshot(tabId, params);
344
+ send({ jsonrpc: '2.0', id, result });
345
+ break;
346
+ }
257
347
  case 'ui.peek': {
258
348
  // A (plan §9.5): safely reveal a folded/hoverable affordance and
259
349
  // return the AX diff. Non-committing (hover only).
@@ -312,14 +402,10 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
312
402
  const target = typeof params.target === 'string' ? params.target : null;
313
403
  const js = `(function(t, d, a){
314
404
  if (t) {
315
- var all = document.querySelectorAll('[data-ai-id]');
316
- for (var i = 0; i < all.length; i++) {
317
- if (all[i].getAttribute('data-ai-id') === t) {
318
- all[i].scrollIntoView({ behavior: 'instant', block: 'center' });
319
- return { ok: true };
320
- }
321
- }
322
- return { ok: false, error: 'target not found' };
405
+ var el = ${findRefUtil.findRefExpr('t')};
406
+ if (!el) return { ok: false, error: 'target not found' };
407
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
408
+ return { ok: true };
323
409
  }
324
410
  window.scrollBy(0, d === 'down' ? a : -a);
325
411
  return { ok: true };
@@ -406,6 +492,15 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
406
492
  send({ jsonrpc: '2.0', id, result: r });
407
493
  break;
408
494
  }
495
+ // === ui.errors — 读主进程错误日志(本次进程的内存记录 + 文件路径)===
496
+ // 存在的理由:界面里弹一个"js 崩溃"的原生框,栈只活在框里,点掉就没了。
497
+ // 让调用方能主动问"刚才报过什么错",不用靠人复述。
498
+ // 历史(跨进程)在 path 指向的 JSONL 文件里;这里返回的是本次进程的。
499
+ case 'ui.errors': {
500
+ const r = recentErrors(params.limit, params.kind || null);
501
+ send({ jsonrpc: '2.0', id, result: { ok: true, ...r } });
502
+ break;
503
+ }
409
504
  case 'ui.quit': {
410
505
  send({ jsonrpc: '2.0', id, result: { ok: true } });
411
506
  // Defer so the response actually flushes before the WS closes.
@@ -416,7 +511,20 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
416
511
  send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
417
512
  break;
418
513
  }
514
+ // 成功的调用只进环形缓冲(不落盘):它的用处是"崩溃那一刻最近的上下文",
515
+ // 而不是给每次点击都记一行日志。
516
+ noteCall(method, params, Date.now() - t0, true);
419
517
  } catch (e) {
518
+ noteCall(method, params, Date.now() - t0, 'error: ' + ((e && e.message) || e));
519
+ // RPC 层异常**必须落盘**。此前它只变成一句 JSON-RPC error 回给调用方,
520
+ // 一旦客户端那边没截住(或用户只看到界面里闪一下),现场就永久丢了 ——
521
+ // 事后既查不到崩在哪,也证不了"刚才是不是真的报过错"。
522
+ recordError('ws_error', {
523
+ method,
524
+ params,
525
+ message: (e && e.message) || String(e),
526
+ stack: String((e && e.stack) || '').slice(0, 8000),
527
+ });
420
528
  send({ jsonrpc: '2.0', id, error: { code: -32603, message: e.message } });
421
529
  }
422
530
  });
@@ -426,10 +534,13 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
426
534
  });
427
535
  });
428
536
 
429
- console.log('AI Browser WS server listening on ws://localhost:' + port);
430
537
  return {
431
- // Actual bound port (for tests that pass port 0 to grab an ephemeral one).
432
- port: wss.address().port,
538
+ // Resolves once the socket is actually bound (port 0 → ephemeral port known).
539
+ ready,
540
+ // Actual bound port/address; null until ready.
541
+ get port() { return bound ? bound.port : null; },
542
+ // Security invariant: must be a loopback literal.
543
+ get host() { return bound ? bound.address : null; },
433
544
  close: () => {
434
545
  for (const client of wss.clients) {
435
546
  try { client.terminate(); } catch (e) {}
@@ -6,9 +6,31 @@
6
6
  // 3. EXECUTE ACTION — click, type, setContent, select, etc.
7
7
  // ============================================================
8
8
 
9
+ // 按 data-ai-id 找元素,**穿透影子根**。
10
+ //
11
+ // document.querySelector 不跨 shadow boundary:Web Component 里的元素(B站 opus
12
+ // 的 <bili-comments>、YouTube 的 <ytd-*>、各设计系统的 <ui-*>)即使抽取层给了
13
+ // data-ai-id,这里的顶层查询也找不到,报出来只是一句
14
+ // "Target not found for ref: xxx" —— 看起来像"元素不存在",实际是"查询路径够不着"。
15
+ // 实测(2026-10-17):B站帖子详情页的评论输入框就在 bili-comments 的 shadow root 里。
16
+ function findRef(root, elementId) {
17
+ var el = null;
18
+ try { el = root.querySelector('[data-ai-id="' + elementId + '"]'); } catch (e) { el = null; }
19
+ if (el) return el;
20
+ var all;
21
+ try { all = root.querySelectorAll("*"); } catch (e) { return null; }
22
+ for (var i = 0; i < all.length; i++) {
23
+ var sr = all[i].shadowRoot;
24
+ if (!sr) continue;
25
+ var hit = findRef(sr, elementId);
26
+ if (hit) return hit;
27
+ }
28
+ return null;
29
+ }
30
+
9
31
  function executeAction(elementId, action, params) {
10
32
  params = params || {};
11
- var el = document.querySelector('[data-ai-id="' + elementId + '"]');
33
+ var el = findRef(document, elementId);
12
34
  if (!el) return { success: false, error: "Target not found for ref: " + elementId };
13
35
 
14
36
  try {
@@ -36,6 +58,13 @@ function executeAction(elementId, action, params) {
36
58
  el.dispatchEvent(new Event("change", { bubbles: true }));
37
59
  return { success: true };
38
60
 
61
+ case "paste":
62
+ // 富文本粘贴(CDP 不可用时的顶层兜底;OOPIF 子帧 preload 到不了,
63
+ // 那条路只有 CDP 的 Frame.executeJavaScript 能走)。
64
+ // 合成 ClipboardEvent + DataTransfer:Draft.js / ProseMirror / Quill
65
+ // 的粘贴处理器会把 text/html 转成内部文档模型,与真人粘贴等效。
66
+ return doPaste(el, params);
67
+
39
68
  case "select":
40
69
  if (el.tagName.toLowerCase() !== "select") return { success: false, error: "Not a select element" };
41
70
  el.value = params.value || "";
@@ -107,6 +136,36 @@ function doType(el, text) {
107
136
  return { success: true, value: el.value.slice(0, 40) };
108
137
  }
109
138
 
139
+ // paste — rich-text injection via a synthetic ClipboardEvent.
140
+ // Same contract as page_manager._pasteViaCdp (params.html preferred over
141
+ // params.text); this is the preload-only fallback for when CDP is absent.
142
+ function doPaste(el, params) {
143
+ var html = typeof params.html === "string" ? params.html : "";
144
+ var text = typeof params.text === "string" ? params.text : "";
145
+ if (!html && !text) {
146
+ return { success: false, error: "paste called with neither html nor text — nothing to paste" };
147
+ }
148
+ var editor = el;
149
+ if (!(editor.contentEditable === "true" || editor.getAttribute("contenteditable") === "true")) {
150
+ var inner = el.querySelector("[contenteditable=true],input,textarea");
151
+ if (inner) editor = inner;
152
+ else return { success: false, error: "Target is not an editable element" };
153
+ }
154
+ editor.focus();
155
+ var dt = new DataTransfer();
156
+ if (html) dt.setData("text/html", html);
157
+ dt.setData("text/plain", text || html.replace(/<[^>]+>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim());
158
+ var ev = new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true, composed: true });
159
+ var notPrevented = editor.dispatchEvent(ev);
160
+ if (notPrevented) {
161
+ return {
162
+ success: false,
163
+ error: "paste event was not consumed by the page (defaultPrevented=false) — try a child node ref, or setContent for plain text"
164
+ };
165
+ }
166
+ return { success: true, method: "synthetic-clipboard-event" };
167
+ }
168
+
110
169
  // setContent — write to any editor type including Draft.js/ProseMirror
111
170
  function doSetContent(el, text, format) {
112
171
  var paras = text.split("\n");
@@ -187,4 +246,4 @@ function doSetContent(el, text, format) {
187
246
  return { success: true, chars: innerText.length, blocks: editor.querySelectorAll ? editor.querySelectorAll("div, p").length : 0 };
188
247
  }
189
248
 
190
- module.exports = { executeAction };
249
+ module.exports = { executeAction, findRef };
@@ -37,7 +37,25 @@ ipcRenderer.on("ai:extract", function(_event, params) {
37
37
  // Return empty fallback
38
38
  ipcRenderer.send("ai:tree", {
39
39
  tree: { id: "root", role: "generic", label: "[error: " + e.message + "]", states: ["visible"], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 } },
40
- context: { modals: [], forms: [], session: {}, stats: {} },
40
+ context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {} },
41
+ id: id
42
+ });
43
+ }
44
+ });
45
+
46
+ // Context-only request — 登录态 / 登录墙自查用(ui.auth_state)。
47
+ // 只回 context,不建整棵树:长任务里每个关键步骤前都要问一次,建树的成本
48
+ // (以及它带来的 token 成本)在这里纯属浪费。
49
+ ipcRenderer.on("ai:context", function(_event, params) {
50
+ params = params || {};
51
+ var id = params.id;
52
+ try {
53
+ var ctx = extractPageContext();
54
+ ipcRenderer.send("ai:tree", { tree: null, context: ctx, id: id });
55
+ } catch(e) {
56
+ ipcRenderer.send("ai:tree", {
57
+ tree: null,
58
+ context: { modals: [], forms: [], session: {}, auth_wall: null, stats: {} },
41
59
  id: id
42
60
  });
43
61
  }
@@ -56,6 +74,18 @@ ipcRenderer.on("ai:evaluate", function(_event, data) {
56
74
  data = data || {};
57
75
  try {
58
76
  var result = eval(data.js);
77
+ // awaitPromise 的 preload 侧对应物:CDP 路径会等 Promise,这条老路径原先
78
+ // 不等,于是 async IIFE 原样把 Promise 发回去,调用方看到 {}。页面里
79
+ // "等一个请求再返回"的探测脚本因此全变成空回执。thenable 才等,普通值
80
+ // 同步返回(不引入额外延迟)。
81
+ if (result && typeof result.then === "function") {
82
+ result.then(function(v) {
83
+ ipcRenderer.send("ai:evaluate_result", { value: v, id: data.id });
84
+ }, function(e) {
85
+ ipcRenderer.send("ai:evaluate_result", { error: e && e.message ? e.message : String(e), id: data.id });
86
+ });
87
+ return;
88
+ }
59
89
  ipcRenderer.send("ai:evaluate_result", { value: result, id: data.id });
60
90
  } catch(e) {
61
91
  ipcRenderer.send("ai:evaluate_result", { error: e.message, id: data.id });