@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.
- package/README.md +155 -20
- package/bin/ai-browser.cjs +1 -1
- package/examples/AGENT_INSTRUCTIONS.md +22 -2
- package/llms-install.md +14 -4
- package/package.json +9 -5
- package/scripts/build_dmg.cjs +220 -0
- package/scripts/marketing/data_snapshot.sh +69 -0
- package/scripts/render_icon.cjs +118 -0
- package/src/main/axExtractor.js +103 -36
- package/src/main/error_log.js +245 -0
- package/src/main/index.js +487 -16
- package/src/main/mcp_server.js +39 -1
- package/src/main/mcp_tools.js +64 -10
- package/src/main/network_monitor.js +86 -23
- package/src/main/page_manager.js +1040 -115
- package/src/main/ws_server.js +127 -16
- package/src/preload/actions.cjs +61 -2
- package/src/preload/bridge.cjs +31 -1
- package/src/preload/extractor.cjs +520 -25
- package/src/renderer/error_banner.html +90 -0
- package/src/renderer/error_page.html +125 -0
- package/src/renderer/tab_bar.html +116 -56
- package/src/shared/act_params.cjs +53 -0
- package/src/shared/auth_wall.cjs +162 -0
- package/src/shared/config.js +8 -2
- package/src/shared/deadline.js +6 -0
- package/src/shared/deep_active.cjs +31 -0
- package/src/shared/error_banner.cjs +43 -0
- package/src/shared/find_ref.cjs +44 -0
- package/src/shared/frame_probe.cjs +112 -0
- package/src/shared/hit_point.cjs +80 -0
- package/src/shared/input_verify.js +47 -0
- package/src/shared/key_dispatch.cjs +37 -0
- package/src/shared/nav_wait.cjs +47 -0
- package/src/shared/protocol.js +15 -0
- package/src/shared/ref_guard.cjs +133 -0
- package/src/shared/screenshot_opts.cjs +130 -0
- package/src/shared/tree_diff.cjs +105 -0
- package/src/shared/version.js +1 -1
- package/tools/coverage_probe.cjs +239 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// src/main/error_log.js — 主进程错误日志(JSONL,滚动,可回读)。
|
|
2
|
+
//
|
|
3
|
+
// 存在理由(2026-10-17 实测):主进程此前**一处错误处理都没有**。任何未捕获
|
|
4
|
+
// 异常都走 Electron 的默认路径 —— 弹一个原生对话框(标题就是 "A JavaScript
|
|
5
|
+
// error occurred in the main process"),栈只在框里、点掉就没了,进程随即退出。
|
|
6
|
+
// 用户看到"弹了个框说 js 崩溃",而事后**没有任何地方能查到它崩在哪** ——
|
|
7
|
+
// 连"刚才是不是真崩过"都证不了。这不是"日志不够详细",是根本没有。
|
|
8
|
+
//
|
|
9
|
+
// 三条设计约束:
|
|
10
|
+
// 1. **日志本身绝不能再抛异常**。所有磁盘操作 try/catch 吞掉:为了记录崩溃
|
|
11
|
+
// 而制造新崩溃,是最难查的那种 bug。
|
|
12
|
+
// 2. **同步写**(appendFileSync)。异步写等到 flush 时进程可能已经没了 ——
|
|
13
|
+
// 崩溃现场最需要的恰恰是最后一刻那几行。
|
|
14
|
+
// 3. **异常条目自带最近的调用上下文**。只有栈是不够的:栈会告诉你崩在
|
|
15
|
+
// `selectorAncestors`,但不会告诉你"是哪个 ui.act 在什么 target 上触发的"。
|
|
16
|
+
// 所以维护一个 WS 调用环形缓冲,落盘时把它一并附上。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
// 单文件上限与保留份数。2MB ×(1+4) 对纯文本日志是几个月量级的事件量;
|
|
22
|
+
// 再大就说明是循环报错,那种情况靠滚动还不如早点被看见。
|
|
23
|
+
// 允许 init 时覆盖:磁盘紧张的环境可以调小,测试也靠它把滚动路径跑起来。
|
|
24
|
+
const DEFAULT_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const DEFAULT_KEEP = 4;
|
|
26
|
+
let maxBytes = DEFAULT_MAX_BYTES;
|
|
27
|
+
let keepFiles = DEFAULT_KEEP;
|
|
28
|
+
// 内存里留最近多少条,供 ui.errors 直接读(不碰磁盘,快)
|
|
29
|
+
const RING_MAX = 200;
|
|
30
|
+
// 最近多少次 WS 调用参与"崩溃上下文"
|
|
31
|
+
const CALL_RING_MAX = 40;
|
|
32
|
+
// 单条日志里字符串字段的截断长度
|
|
33
|
+
const FIELD_MAX = 4000;
|
|
34
|
+
|
|
35
|
+
let logFile = null;
|
|
36
|
+
let logDir = null;
|
|
37
|
+
let wroteBytes = 0;
|
|
38
|
+
let dropped = 0;
|
|
39
|
+
|
|
40
|
+
const ring = [];
|
|
41
|
+
const callRing = [];
|
|
42
|
+
|
|
43
|
+
/** 把任意值压成"可安全 JSON.stringify 的一行摘要"。 */
|
|
44
|
+
function summarize(v, depth = 0) {
|
|
45
|
+
if (v === null || v === undefined) return v;
|
|
46
|
+
const t = typeof v;
|
|
47
|
+
if (t === 'string') return v.length > FIELD_MAX ? v.slice(0, FIELD_MAX) + '…(' + v.length + ' chars)' : v;
|
|
48
|
+
if (t === 'number' || t === 'boolean') return v;
|
|
49
|
+
if (t === 'bigint') return String(v);
|
|
50
|
+
if (t === 'function') return '[function ' + (v.name || 'anonymous') + ']';
|
|
51
|
+
if (t === 'symbol') return String(v);
|
|
52
|
+
if (v instanceof Error) {
|
|
53
|
+
return { name: v.name, message: v.message, stack: String(v.stack || '').slice(0, 8000) };
|
|
54
|
+
}
|
|
55
|
+
if (depth >= 3) return Array.isArray(v) ? '[array ' + v.length + ']' : '[object]';
|
|
56
|
+
if (Array.isArray(v)) {
|
|
57
|
+
const head = v.slice(0, 12).map((x) => summarize(x, depth + 1));
|
|
58
|
+
return v.length > 12 ? head.concat(['…+' + (v.length - 12)]) : head;
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
let n = 0;
|
|
62
|
+
for (const k of Object.keys(v)) {
|
|
63
|
+
if (n++ >= 20) { out['…'] = 'more keys omitted'; break; }
|
|
64
|
+
out[k] = summarize(v[k], depth + 1);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nowIso() {
|
|
70
|
+
return new Date().toISOString();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rotateIfNeeded(nextLen) {
|
|
74
|
+
try {
|
|
75
|
+
if (!logFile) return;
|
|
76
|
+
let size = wroteBytes;
|
|
77
|
+
if (!size) {
|
|
78
|
+
try { size = fs.statSync(logFile).size; } catch { size = 0; }
|
|
79
|
+
}
|
|
80
|
+
if (size + nextLen <= maxBytes) return;
|
|
81
|
+
// error.jsonl → error.jsonl.1 → error.jsonl.2 …
|
|
82
|
+
for (let i = keepFiles - 1; i >= 1; i--) {
|
|
83
|
+
const from = i === 1 ? logFile : logFile + '.' + (i - 1);
|
|
84
|
+
const to = logFile + '.' + i;
|
|
85
|
+
try { if (fs.existsSync(from)) fs.renameSync(from, to); } catch {}
|
|
86
|
+
}
|
|
87
|
+
// 轮转后旧内容已经在 .1,当前文件从零开始
|
|
88
|
+
try { if (fs.existsSync(logFile)) fs.unlinkSync(logFile); } catch {}
|
|
89
|
+
wroteBytes = 0;
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendLine(obj) {
|
|
94
|
+
const line = JSON.stringify(obj) + '\n';
|
|
95
|
+
rotateIfNeeded(Buffer.byteLength(line));
|
|
96
|
+
try {
|
|
97
|
+
fs.appendFileSync(logFile, line);
|
|
98
|
+
wroteBytes += Buffer.byteLength(line);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
// 写不下去就记账,靠 ui.errors 的 dropped 字段让调用方知道"这里缺了东西"
|
|
101
|
+
dropped += 1;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 初始化日志目录。**必须在 app ready 之前调用**,否则早期崩溃(最常见的那类)
|
|
107
|
+
* 落不了盘。
|
|
108
|
+
* @param {{dir: string, meta?: object}} opts dir 为日志目录;meta 附在启动行里
|
|
109
|
+
* (版本、端口、userData…),方便事后分辨"这份日志是哪个实例的"。
|
|
110
|
+
*/
|
|
111
|
+
export function initErrorLog(opts) {
|
|
112
|
+
const dir = (opts && opts.dir) || null;
|
|
113
|
+
if (!dir) return null;
|
|
114
|
+
logDir = dir;
|
|
115
|
+
if (opts && Number(opts.maxBytes) > 0) maxBytes = Number(opts.maxBytes);
|
|
116
|
+
if (opts && Number(opts.keep) > 0) keepFiles = Math.max(1, Number(opts.keep));
|
|
117
|
+
logFile = path.join(dir, 'error.jsonl');
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
120
|
+
try { wroteBytes = fs.statSync(logFile).size; } catch { wroteBytes = 0; }
|
|
121
|
+
// 进程边界的标记行:读日志时能一眼看出"这是哪一次启动",以及上一次
|
|
122
|
+
// 是不是异常结束(没有 shutdown 行就说明是硬退)
|
|
123
|
+
appendLine({
|
|
124
|
+
ts: nowIso(),
|
|
125
|
+
kind: 'boot',
|
|
126
|
+
pid: process.pid,
|
|
127
|
+
node: process.versions && process.versions.node,
|
|
128
|
+
electron: process.versions && process.versions.electron,
|
|
129
|
+
platform: process.platform + ' ' + process.arch,
|
|
130
|
+
...summarize((opts && opts.meta) || {}),
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
logFile = null;
|
|
134
|
+
}
|
|
135
|
+
return logFile;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getLogPath() {
|
|
139
|
+
return logFile;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function getLogDir() {
|
|
143
|
+
return logDir;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 记一条错误。永不抛。
|
|
148
|
+
* @param {string} kind 事件类别:uncaught_exception / unhandled_rejection /
|
|
149
|
+
* render_process_gone / child_process_gone / nav_fail / ws_error / …
|
|
150
|
+
* @param {object} info 任意附加字段(message/stack/url/tab/…)
|
|
151
|
+
*/
|
|
152
|
+
export function recordError(kind, info) {
|
|
153
|
+
const entry = {
|
|
154
|
+
ts: nowIso(),
|
|
155
|
+
kind: String(kind || 'unknown'),
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
uptime_ms: Math.round(process.uptime() * 1000),
|
|
158
|
+
...summarize(info || {}),
|
|
159
|
+
};
|
|
160
|
+
// 崩溃上下文:最近若干次 WS 调用。放在条目里而不是另起一行,读的时候
|
|
161
|
+
// 不用自己按时间拼。
|
|
162
|
+
if (callRing.length) entry.recent_calls = callRing.slice(-20);
|
|
163
|
+
ring.push(entry);
|
|
164
|
+
while (ring.length > RING_MAX) ring.shift();
|
|
165
|
+
appendLine(entry);
|
|
166
|
+
return entry;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 记一次 WS 调用(进环形缓冲,不落盘 —— 只作为崩溃时的上下文)。
|
|
171
|
+
* @param {string} method 方法名
|
|
172
|
+
* @param {object} params 原始参数(内部会摘要化,大字段自动截断)
|
|
173
|
+
* @param {number} ms 耗时
|
|
174
|
+
* @param {boolean|string} outcome true/false 或错误摘要
|
|
175
|
+
*/
|
|
176
|
+
export function noteCall(method, params, ms, outcome) {
|
|
177
|
+
try {
|
|
178
|
+
const brief = summarize(params || {});
|
|
179
|
+
// 只留能定位的信息:text/html 这类大字段压成"长度"
|
|
180
|
+
const slim = {};
|
|
181
|
+
if (brief && typeof brief === 'object') {
|
|
182
|
+
for (const k of Object.keys(brief)) {
|
|
183
|
+
if (k === 'text' || k === 'html') {
|
|
184
|
+
// 正文类字段只留长度:几十 KB 的稿子灌进日志,一条就把文件写满
|
|
185
|
+
const v = (params || {})[k];
|
|
186
|
+
slim[k] = typeof v === 'string' ? '<' + v.length + ' chars>' : brief[k];
|
|
187
|
+
} else if (k === 'js') {
|
|
188
|
+
// 脚本保留开头:它是定位"崩在哪段注入代码"的关键,只留长度等于没留。
|
|
189
|
+
// 但也不能整段进日志(内联 base64 图片会让一条日志到 MB 级)。
|
|
190
|
+
const v = (params || {})[k];
|
|
191
|
+
slim[k] = typeof v === 'string'
|
|
192
|
+
? (v.length > 200 ? v.slice(0, 200) + '…<' + v.length + ' chars>' : v)
|
|
193
|
+
: brief[k];
|
|
194
|
+
} else {
|
|
195
|
+
slim[k] = brief[k];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
callRing.push({
|
|
200
|
+
ts: nowIso(),
|
|
201
|
+
method: String(method || ''),
|
|
202
|
+
params: slim,
|
|
203
|
+
ms: typeof ms === 'number' ? ms : null,
|
|
204
|
+
outcome: outcome === undefined ? null : summarize(outcome),
|
|
205
|
+
});
|
|
206
|
+
while (callRing.length > CALL_RING_MAX) callRing.shift();
|
|
207
|
+
} catch {
|
|
208
|
+
/* 记调用上下文失败绝不影响调用本身 */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 读最近错误(内存里的本次进程记录)。文件才是权威历史 —— 进程一崩,这里
|
|
214
|
+
* 就没了,所以回执里带 path 让调用方自己去翻。
|
|
215
|
+
*/
|
|
216
|
+
export function recentErrors(limit = 50, kind = null) {
|
|
217
|
+
const n = Math.max(1, Math.min(RING_MAX, Number(limit) || 50));
|
|
218
|
+
// boot / shutdown 是进程边界标记,不是"错误"。只有明确按 kind 查时才返回它们。
|
|
219
|
+
const MARKERS = new Set(['boot', 'shutdown']);
|
|
220
|
+
let items = kind ? ring.filter((e) => e.kind === kind) : ring.filter((e) => !MARKERS.has(e.kind));
|
|
221
|
+
return {
|
|
222
|
+
path: logFile,
|
|
223
|
+
dir: logDir,
|
|
224
|
+
count_in_memory: items.length,
|
|
225
|
+
dropped_writes: dropped,
|
|
226
|
+
items: items.slice(-n),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 正常退出时补一行收尾 —— 日志里没有 shutdown 行 = 上次是硬退。 */
|
|
231
|
+
export function markShutdown(reason) {
|
|
232
|
+
recordError('shutdown', { reason: reason || 'normal' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 仅供测试:重置模块内状态,避免用例之间互相污染。
|
|
236
|
+
export function __resetForTests() {
|
|
237
|
+
logFile = null;
|
|
238
|
+
logDir = null;
|
|
239
|
+
wroteBytes = 0;
|
|
240
|
+
dropped = 0;
|
|
241
|
+
maxBytes = DEFAULT_MAX_BYTES;
|
|
242
|
+
keepFiles = DEFAULT_KEEP;
|
|
243
|
+
ring.length = 0;
|
|
244
|
+
callRing.length = 0;
|
|
245
|
+
}
|