@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
@@ -11,13 +11,14 @@ export const MCP_TOOLS = [
11
11
  {
12
12
  name: 'browse_navigate',
13
13
  title: 'Navigate tab',
14
- description: 'Navigate the active/focused tab to a URL.',
14
+ description: 'Navigate the active/focused tab to a URL. Returns as soon as the requested milestone is reached (default: DOM ready) — it does not wait for the load event, which SPAs often never fire.',
15
15
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
16
16
  inputSchema: {
17
17
  type: 'object',
18
18
  properties: {
19
19
  url: { type: 'string', description: 'URL to navigate to.' },
20
- tab: { type: 'integer', description: 'Tab id (default: active tab).' }
20
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' },
21
+ wait_until: { type: 'string', enum: ['commit', 'domcontentloaded', 'load'], description: 'How far to wait. commit = navigation committed (fastest, content may not be painted); domcontentloaded = DOM ready (default, safe for SPAs); load = also subresources (can hang forever on pages that never fire load).' }
21
22
  },
22
23
  required: ['url']
23
24
  }
@@ -41,20 +42,21 @@ export const MCP_TOOLS = [
41
42
  {
42
43
  name: 'browse_act',
43
44
  title: 'Act on element',
44
- description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel.',
45
+ description: 'Act on an element by data-ai-id: click, type (append at caret), setContent (replace all), clear, focus, hover, scroll_to, upload, get_value (read-only), drag, press, wheel, paste.',
45
46
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
46
47
  inputSchema: {
47
48
  type: 'object',
48
49
  properties: {
49
- action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel'], description: 'Action.' },
50
- target: { type: 'string', description: 'Element data-ai-id from the semantic tree. Omit for press (keys go to the current focus); for drag you may also omit it and pass from_x/from_y to start from a bare point.' },
51
- text: { type: 'string', description: 'Text to type (type only).' },
50
+ action: { type: 'string', enum: ['click', 'type', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Action.' },
51
+ html: { type: 'string', description: 'paste: HTML to inject (preferred). Editors convert it through their own paste pipeline (Draft.js/ProseMirror/Quill), so headings/code blocks/bold/lists survive. Plain text loses all formatting.' },
52
+ target: { type: 'string', description: 'Element data-ai-id from the semantic tree (e.g. e:button-12, axf-3-1) — NOT the visible label. Omit for press (keys go to the current focus); for drag you may also omit it and pass from_x/from_y to start from a bare point. upload also accepts a CSS selector (descendant selectors with spaces included), which is the only way to reach upload widgets that have no semantic node.' },
53
+ text: { type: 'string', description: 'Text to type (type/setContent). For paste: plain-text fallback used when html is absent.' },
52
54
  value: { type: 'string', description: 'Value for select.' },
53
55
  offset: { type: 'integer', description: 'get_value: start offset in Unicode code points (default 0).' },
54
56
  limit: { type: 'integer', description: 'get_value: max code points to return (default 20000, hard cap 1000000).' },
55
57
  url: { type: 'string', description: 'For click: click the link whose href matches this URL instead of guessing the target label.' },
56
58
  keep_tab: { type: 'boolean', description: 'For click: keep the current tab active instead of auto-following a newly opened tab.' },
57
- file: { type: 'string', description: 'Absolute path to upload (upload only, target must be a file input).' },
59
+ file: { type: 'string', description: 'Absolute path to upload (upload only). target may be the file input itself, the control that opens the picker, or — when the control has no semantic node at all — a CSS selector such as ".upload-button". Works inside cross-process iframes.' },
58
60
  timeout_ms: { type: 'integer', description: 'Override the action deadline in ms (default 15000; type/setContent/drag/press scale with text length / duration / repeat; 1s floor, 300s cap). A timeout returns success=false, timed_out=true and warns the action may have partially applied.' },
59
61
  to_target: { type: 'string', description: 'drag: data-ai-id to drop onto (e.g. drag from one node\'s output to another node to connect them).' },
60
62
  dx: { type: 'number', description: 'drag: horizontal distance to move (use with dy instead of to_target).' },
@@ -79,13 +81,14 @@ export const MCP_TOOLS = [
79
81
  {
80
82
  name: 'browse_evaluate',
81
83
  title: 'Run page JavaScript',
82
- description: 'Run a JS expression in the page context and return its value.',
84
+ description: 'Run a JS expression in the page context and return its value. Promises are awaited (async IIFEs return their resolved value, not {}).',
83
85
  annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
84
86
  inputSchema: {
85
87
  type: 'object',
86
88
  properties: {
87
- js: { type: 'string', description: 'Page-context JS expression (not statements). Max 5000 chars.' },
88
- tab: { type: 'integer', description: 'Tab id (default: active tab).' }
89
+ js: { type: 'string', description: 'Page-context JS expression (not statements). Max 5000 chars. Promises are awaited.' },
90
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' },
91
+ frame_ref: { type: 'string', description: 'Run inside this cross-process frame (an axf-... id from the tree). Pass it explicitly when touching iframe content: building the selector by string concatenation defeats the automatic route and the top-frame run returns null.' }
89
92
  },
90
93
  required: ['js']
91
94
  }
@@ -122,6 +125,44 @@ export const MCP_TOOLS = [
122
125
  required: ['condition']
123
126
  }
124
127
  },
128
+ {
129
+ name: 'browse_auth_state',
130
+ title: 'Check login / auth-wall state',
131
+ description: 'Is this tab still logged in, or bounced to a login wall? auth_wall.detected=true means stop debugging and re-authenticate first. logged_in is heuristic; auth_wall is the signal to trust.',
132
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' }
137
+ }
138
+ }
139
+ },
140
+ {
141
+ name: 'browse_screenshot',
142
+ title: 'Screenshot the viewport',
143
+ description: 'Screenshot the viewport (base64 in data). Use FIRST when an action misbehaves: the element tree cannot show overlays, canvas, occlusion or glitches. Warns if a visible password input is on screen.',
144
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ tab: { type: 'integer', description: 'Tab id (default: active tab).' },
149
+ format: { type: 'string', enum: ['png', 'jpeg', 'webp'], description: 'Image format. Default png. jpeg at quality 85 is ~5x smaller — prefer it for long pages.' },
150
+ quality: { type: 'number', description: '0-100, jpeg/webp only (ignored for png). Default 85 for jpeg.' },
151
+ fullPage: { type: 'boolean', description: 'Capture the full scrollable page, not just the viewport. Cannot be combined with clip.' },
152
+ clip: {
153
+ type: 'object',
154
+ description: 'Capture a rectangle in CSS pixels: {x, y, width, height, scale?}. Use to zoom into one element when the viewport shot is too small to read.',
155
+ properties: {
156
+ x: { type: 'number' },
157
+ y: { type: 'number' },
158
+ width: { type: 'number' },
159
+ height: { type: 'number' },
160
+ scale: { type: 'number', description: 'Device scale factor, 0-4. Default 1.' }
161
+ }
162
+ }
163
+ }
164
+ }
165
+ },
125
166
  {
126
167
  name: 'browse_list_tabs',
127
168
  title: 'List tabs',
@@ -279,6 +320,19 @@ export const MCP_TOOLS = [
279
320
  },
280
321
  },
281
322
  },
323
+ {
324
+ name: 'browse_errors',
325
+ title: 'Read error log',
326
+ description: 'Read the main-process error log: uncaught exceptions, crashes, failed navigations, RPC errors. Each entry includes recent calls as context. Returns the on-disk JSONL path.',
327
+ annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
328
+ inputSchema: {
329
+ type: 'object',
330
+ properties: {
331
+ limit: { type: 'integer', description: 'Max entries to return (default 50).' },
332
+ kind: { type: 'string', description: 'Filter by kind: uncaught_exception / unhandled_rejection / render_process_gone / child_process_gone / nav_fail / ws_error.' },
333
+ },
334
+ },
335
+ },
282
336
  {
283
337
  name: 'browse_quit',
284
338
  title: 'Quit browser',
@@ -165,10 +165,22 @@ export class NetworkMonitor {
165
165
  this.captureEnabled = opts.captureEnabled !== undefined ? opts.captureEnabled : envFlag('AI_BROWSER_NETWORK_CAPTURE', true);
166
166
  this.sensitiveAllowed = opts.sensitiveAllowed !== undefined ? opts.sensitiveAllowed : envFlag('AI_BROWSER_NETWORK_SENSITIVE', false);
167
167
  this.globalCaptureBodies = 'api';
168
+ // 当前活跃 tab(由 page_manager 在 setActive / closeTab 时同步进来)。
169
+ // 只被 body 淘汰顺序用:null 表示不知道/没有活跃 tab,此时退回纯按新旧。
170
+ // 注意它可以是 null,所以下面 _enforceBodyLimit 里判等不能假设非空。
171
+ this.activeTabId = null;
168
172
  }
169
173
 
170
174
  // ==== 配置 ============================================================
171
175
 
176
+ /**
177
+ * 告知活跃 tab。page_manager 是唯一调用方(setActive / closeTab 换活跃页时)。
178
+ * 传 null = 没有活跃 tab(最后一个 tab 关掉了)。
179
+ */
180
+ setActiveTab(tabId) {
181
+ this.activeTabId = tabId == null ? null : tabId;
182
+ }
183
+
172
184
  setBodyFetcher(tabId, fn) { this._fetchers.set(tabId, fn); }
173
185
  clearBodyFetcher(tabId) { this._fetchers.delete(tabId); }
174
186
 
@@ -204,16 +216,36 @@ export class NetworkMonitor {
204
216
 
205
217
  _enforceRecordLimit(protectId) {
206
218
  while (this._records.size > this.limits.maxRecords) {
207
- let victim = null;
208
- for (const id of this._order) {
209
- if (id === protectId) continue;
210
- victim = id; break;
211
- }
219
+ const victim = this._evictionVictim(protectId);
212
220
  if (!victim) break;
213
221
  this._dropRecord(victim);
214
222
  }
215
223
  }
216
224
 
225
+ // 淘汰候选:**非活跃 tab 的最旧一条**优先;一条都没有(全是活跃 tab 的,或
226
+ // 根本不知道活跃 tab)才退到全局最旧。
227
+ //
228
+ // 为什么两处淘汰都要挑 tab:全局上限(记录数 / body 字节)是跨 tab 共享的,
229
+ // 而后台 tab 会持续累积。实测默认页 baidu.com 挂久了之后,agent 正在操作的
230
+ // 那个 tab 的 POST 请求体被挤成 evicted(e2e/network_ws.cjs N-04/N-05 红)——
231
+ // 请求确实发生了、body 也确实拿到过,只是被后台噪声顶掉,agent 读到的是
232
+ // evicted 而不是内容。让活跃 tab 有优先保留权,后台 tab 超限时先牺牲自己。
233
+ //
234
+ // activeTabId 为 null(无活跃 tab / 单测未接线)时,`tab_id !== null` 恒真,
235
+ // 直接返回全局最旧 —— 与改动前完全一致。
236
+ _evictionVictim(protectId) {
237
+ const active = this.activeTabId;
238
+ let fallback = null;
239
+ for (const id of this._order) {
240
+ if (id === protectId) continue;
241
+ const rec = this._records.get(id);
242
+ if (!rec) continue;
243
+ if (fallback === null) fallback = id;
244
+ if (rec.tab_id !== active) return id;
245
+ }
246
+ return fallback;
247
+ }
248
+
217
249
  _dropRecord(networkId) {
218
250
  const rec = this._records.get(networkId);
219
251
  if (!rec) return;
@@ -225,25 +257,35 @@ export class NetworkMonitor {
225
257
  this._droppedRecords += 1;
226
258
  }
227
259
 
228
- // body 超限:优先淘汰最旧的 response body,其次 request body;metadata 保留。
260
+ // body 超限淘汰:先 response body 后 request body(同一次超限里),同一种 body
261
+ // 内部按 _order 由旧到新。**并且先淘汰非活跃 tab 的**(见 _evictionVictim 的
262
+ // 注释:全局 32MB 配额跨 tab 共享,后台 tab 会把活跃 tab 的 body 顶成 evicted)。
263
+ // metadata 不淘汰,只丢 body 内容,记录仍然查得到。
264
+ //
265
+ // 单 tab 场景下行为不变(活跃 tab 就是唯一的 tab,排序等价于纯按新旧)。
229
266
  _enforceBodyLimit(protectId) {
230
267
  if (this._bodyBytes <= this.limits.maxBodyBytes) return;
231
- for (const pass of ['response', 'request']) {
232
- for (const id of this._order) {
233
- if (this._bodyBytes <= this.limits.maxBodyBytes) return;
234
- if (id === protectId) continue;
235
- const rec = this._records.get(id);
236
- if (!rec) continue;
237
- if (pass === 'response' && rec._respBody) {
238
- this._bodyBytes -= rec._respBodyBytes || 0;
239
- rec._respBody = null; rec._respBodyBytes = 0;
240
- rec.response_body_state = 'evicted';
241
- rec._respEvicted = true;
242
- } else if (pass === 'request' && rec._reqBody) {
243
- this._bodyBytes -= rec._reqBodyBytes || 0;
244
- rec._reqBody = null; rec._reqBodyBytes = 0;
245
- rec.request_body_state = 'evicted';
246
- rec._reqEvicted = true;
268
+ const active = this.activeTabId;
269
+ // 两轮:先把非活跃 tab 的 body 淘汰掉,仍然超限才动活跃 tab 自己的。
270
+ for (const phase of ['background', 'active']) {
271
+ for (const pass of ['response', 'request']) {
272
+ for (const id of this._order) {
273
+ if (this._bodyBytes <= this.limits.maxBodyBytes) return;
274
+ if (id === protectId) continue;
275
+ const rec = this._records.get(id);
276
+ if (!rec) continue;
277
+ if (phase === 'background' ? rec.tab_id === active : rec.tab_id !== active) continue;
278
+ if (pass === 'response' && rec._respBody) {
279
+ this._bodyBytes -= rec._respBodyBytes || 0;
280
+ rec._respBody = null; rec._respBodyBytes = 0;
281
+ rec.response_body_state = 'evicted';
282
+ rec._respEvicted = true;
283
+ } else if (pass === 'request' && rec._reqBody) {
284
+ this._bodyBytes -= rec._reqBodyBytes || 0;
285
+ rec._reqBody = null; rec._reqBodyBytes = 0;
286
+ rec.request_body_state = 'evicted';
287
+ rec._reqEvicted = true;
288
+ }
247
289
  }
248
290
  }
249
291
  }
@@ -283,7 +325,19 @@ export class NetworkMonitor {
283
325
  rec.response_body_state = overCap ? 'partial' : 'captured';
284
326
  }
285
327
  this._bodyBytes += bytes;
286
- this._enforceBodyLimit(rec.network_id);
328
+ // "刚存进来的这条不在本轮淘汰里"是给活跃 tab 的特权,不是普适规则。
329
+ //
330
+ // 后台 tab 的 body 没有这个权利:否则一条 40KB 的后台响应体会先把活跃
331
+ // tab 里的小 body 全部挤掉(实测 baidu 默认页的一条响应把活跃 tab 上
332
+ // "GET-PAYLOAD-OK" 这 28 字节的响应顶成 evicted —— e2e/network_ws.cjs
333
+ // N-12 间歇红)。让它自己先被牺牲,活跃 tab 的 body 就不受影响:后台的
334
+ // body 反正没人看,而"活跃 tab 里明明还在配额内的小 body 消失了"是硬伤。
335
+ //
336
+ // activeTabId == null(没接线 / 单 tab)时一律按活跃处理 —— 保持改动前的
337
+ // 行为,不让新增逻辑在没有 tab 语义的场景里改变语义。
338
+ const active = this.activeTabId;
339
+ const isActive = active == null || rec.tab_id === active;
340
+ this._enforceBodyLimit(isActive ? rec.network_id : null);
287
341
  return body;
288
342
  }
289
343
 
@@ -522,6 +576,10 @@ export class NetworkMonitor {
522
576
  max_records: this.limits.maxRecords,
523
577
  body_bytes: this._bodyBytes,
524
578
  max_body_bytes: this.limits.maxBodyBytes,
579
+ // 淘汰优先级看这个:非活跃 tab 的 body / 记录先被牺牲。null = 不知道活跃
580
+ // tab(此时按纯新旧淘汰)。暴露出来是因为"我的 body 怎么被 evicted 了"
581
+ // 这类问题只能靠它定位 —— 曾经它就是个黑盒。
582
+ active_tab: this.activeTabId,
525
583
  };
526
584
  }
527
585
 
@@ -531,6 +589,11 @@ export class NetworkMonitor {
531
589
  enabled: !!cfg.enabled,
532
590
  capture_bodies: cfg.capture_bodies,
533
591
  since: this._startedAt,
592
+ // 当前全局占用与淘汰优先级。body 被 evicted 时,先看这里就能分清是
593
+ // "自己这个 tab 太多了"还是"后台 tab 把自己顶了"。
594
+ body_bytes: this._bodyBytes,
595
+ max_body_bytes: this.limits.maxBodyBytes,
596
+ active_tab: this.activeTabId,
534
597
  limits: {
535
598
  max_records: this.limits.maxRecords,
536
599
  max_body_bytes: this.limits.maxBodyBytes,