@glittering0717/ai-browser 1.2.2 → 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.
@@ -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
@@ -91,6 +99,7 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
91
99
  if (ws.readyState === 1) ws.send(JSON.stringify(payload));
92
100
  };
93
101
 
102
+ const t0 = Date.now();
94
103
  try {
95
104
  switch (method) {
96
105
  case 'ui.get_tree': {
@@ -113,12 +122,23 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
113
122
  // - 这一层保护"以后新增的页面路径忘了套",并且**可以用 mock PageManager
114
123
  // 做单测**(真实 PageManager 依赖 Electron,vitest 里起不来)。
115
124
  // 同源意味着两层不会打架:预算一致,谁先到期谁报,语义相同。
116
- 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);
117
132
  try {
118
133
  const result = await withDeadline(
119
- pageManager.executeAction(params.action, params.target, params.params || {}, tabId),
134
+ pageManager.executeAction(params.action, params.target, actParamsObj, tabId),
120
135
  budget, `action:${params.action}`
121
136
  );
137
+ // 提升过就在回执里说明:调用方会知道"我平铺的参数被接受了",
138
+ // 而不是下次换个客户端又悄悄失效。
139
+ if (hoisted.hoisted.length && result && typeof result === 'object' && !result.params_hoisted) {
140
+ result.params_hoisted = hoisted.hoisted;
141
+ }
122
142
  send({ jsonrpc: '2.0', id, result });
123
143
  } catch (e) {
124
144
  if (e && e.__deadline) {
@@ -142,7 +162,7 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
142
162
  // navigate 现在返回 {ok, error?}:成功形状与旧的 {ok:true} 一致,
143
163
  // 失败时多一个原因。此前只有布尔,agent 拿到 ok:false 无从判断
144
164
  // 该重试还是换 URL;而"页面永不 did-finish-load"是会真实发生的。
145
- const r = await pageManager.navigate(params.url, tabId);
165
+ const r = await pageManager.navigate(params.url, tabId, { waitUntil: params.wait_until });
146
166
  send({ jsonrpc: '2.0', id, result: r && typeof r === 'object' ? r : { ok: !!r } });
147
167
  break;
148
168
  }
@@ -195,7 +215,12 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
195
215
  send({ jsonrpc: '2.0', id, error: { code: -32602, message: guardErr } });
196
216
  break;
197
217
  }
198
- 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 });
199
224
  send({ jsonrpc: '2.0', id, result: { value } });
200
225
  break;
201
226
  }
@@ -301,6 +326,24 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
301
326
  send({ jsonrpc: '2.0', id, result });
302
327
  break;
303
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
+ }
304
347
  case 'ui.peek': {
305
348
  // A (plan §9.5): safely reveal a folded/hoverable affordance and
306
349
  // return the AX diff. Non-committing (hover only).
@@ -359,14 +402,10 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
359
402
  const target = typeof params.target === 'string' ? params.target : null;
360
403
  const js = `(function(t, d, a){
361
404
  if (t) {
362
- var all = document.querySelectorAll('[data-ai-id]');
363
- for (var i = 0; i < all.length; i++) {
364
- if (all[i].getAttribute('data-ai-id') === t) {
365
- all[i].scrollIntoView({ behavior: 'instant', block: 'center' });
366
- return { ok: true };
367
- }
368
- }
369
- 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 };
370
409
  }
371
410
  window.scrollBy(0, d === 'down' ? a : -a);
372
411
  return { ok: true };
@@ -453,6 +492,15 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
453
492
  send({ jsonrpc: '2.0', id, result: r });
454
493
  break;
455
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
+ }
456
504
  case 'ui.quit': {
457
505
  send({ jsonrpc: '2.0', id, result: { ok: true } });
458
506
  // Defer so the response actually flushes before the WS closes.
@@ -463,7 +511,20 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
463
511
  send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found' } });
464
512
  break;
465
513
  }
514
+ // 成功的调用只进环形缓冲(不落盘):它的用处是"崩溃那一刻最近的上下文",
515
+ // 而不是给每次点击都记一行日志。
516
+ noteCall(method, params, Date.now() - t0, true);
466
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
+ });
467
528
  send({ jsonrpc: '2.0', id, error: { code: -32603, message: e.message } });
468
529
  }
469
530
  });
@@ -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 });