@glittering0717/ai-browser 2.1.1 → 2.1.2
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 +4 -0
- package/package.json +2 -1
- package/scripts/marketing/data_snapshot.sh +1 -1
- package/scripts/release.cjs +204 -0
- package/src/main/axExtractor.js +90 -2
- package/src/main/mcp_tools.js +3 -3
- package/src/main/page_manager.js +154 -12
- package/src/preload/actions.cjs +33 -1
- package/src/shared/editors_probe.js +96 -0
- package/src/shared/effect_state.js +5 -0
- package/src/shared/page_probe.js +154 -0
- package/src/shared/version.js +1 -1
package/README.md
CHANGED
|
@@ -955,6 +955,10 @@ the single source for `evaluate` limits, enforced identically on both transports
|
|
|
955
955
|
Issues and PRs are welcome, especially reproduction cases where the semantic tree gets a page wrong —
|
|
956
956
|
those are the most useful bug reports this project can get.
|
|
957
957
|
|
|
958
|
+
Questions, "the tree got this page wrong" reports, and agent run logs are best posted in
|
|
959
|
+
[GitHub Discussions](https://github.com/Glittering/ai-browser/discussions) — that is where the most
|
|
960
|
+
useful feedback for this project tends to land.
|
|
961
|
+
|
|
958
962
|
What we try to hold the line on:
|
|
959
963
|
|
|
960
964
|
- **Keep the core generic.** Site-specific knowledge belongs outside `src/main` and `src/preload`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
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": {
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"scripts": {
|
|
56
56
|
"start": "node scripts/start.cjs",
|
|
57
57
|
"test": "vitest run",
|
|
58
|
+
"release": "node scripts/release.cjs",
|
|
58
59
|
"test:watch": "vitest",
|
|
59
60
|
"smoke": "node e2e/smoke_ws.cjs",
|
|
60
61
|
"capabilities": "node e2e/capabilities_ws.cjs",
|
|
@@ -33,7 +33,7 @@ NPM_DL=$(curl -s --max-time 15 "https://api.npmjs.org/downloads/point/last-week/
|
|
|
33
33
|
mcp_fetch() {
|
|
34
34
|
for i in 1 2 3; do
|
|
35
35
|
local out
|
|
36
|
-
out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers
|
|
36
|
+
out=$(curl -s --max-time 20 "https://registry.modelcontextprotocol.io/v0/servers/io.github.Glittering%2Fai-browser/versions" 2>/dev/null)
|
|
37
37
|
if [ -n "$out" ] && echo "$out" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
|
|
38
38
|
echo "$out"
|
|
39
39
|
return 0
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/release.cjs — 一条命令走完发版闭环:版本号 → 测试 → commit → tag →
|
|
3
|
+
// push(GitHub) → npm publish → MCP registry publish。
|
|
4
|
+
//
|
|
5
|
+
// 为什么要这个脚本(而不是每次手工敲 8 条命令):
|
|
6
|
+
// 发版最容易漏的不是编译,是**同步**。版本号有 4 处(package.json、
|
|
7
|
+
// server.json 两处、src/shared/version.js),GitHub 有 2 条分支 + 1 个 tag,
|
|
8
|
+
// 发布有 2 个仓库(npm、MCP registry)。漏一处不会报错,只会让用户在
|
|
9
|
+
// issue 里报一个不存在的版本、或者让 npx 拉到的还是旧版 —— 而且要到
|
|
10
|
+
// 下一次有人装的时候才发现。机器不会漏,人会。
|
|
11
|
+
//
|
|
12
|
+
// 用法:
|
|
13
|
+
// node scripts/release.cjs patch # 2.1.2 → 2.1.3
|
|
14
|
+
// node scripts/release.cjs minor --dry # 只看会做什么
|
|
15
|
+
// node scripts/release.cjs patch --notes notes.txt
|
|
16
|
+
//
|
|
17
|
+
// 设计原则:**每一步失败都不静默**。git 成功但 npm 失败时,脚本以非零退出并
|
|
18
|
+
// 打印"已完成的步骤 + 怎么补救",而不是假装整条链路都成功了。
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
22
|
+
const fs = require('node:fs');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const dry = args.includes('--dry');
|
|
28
|
+
const bumpKind = args.find((a) => ['patch', 'minor', 'major'].includes(a)) || 'patch';
|
|
29
|
+
const notesIdx = args.indexOf('--notes');
|
|
30
|
+
const notesFile = notesIdx >= 0 ? args[notesIdx + 1] : null;
|
|
31
|
+
// 全量测试 4 分钟起,发版时默认只跑"会拦住错误发版"的那几个:
|
|
32
|
+
// 版本一致性、导入完整性、schema 预算。其余靠 CI / 平时。
|
|
33
|
+
const fullTests = args.includes('--full-test');
|
|
34
|
+
|
|
35
|
+
const c = (s) => `\x1b[${s}m`;
|
|
36
|
+
const ok = (m) => console.log(`${c(32)}✓${c(0)} ${m}`);
|
|
37
|
+
const warn = (m) => console.log(`${c(33)}!${c(0)} ${m}`);
|
|
38
|
+
const fail = (m) => console.log(`${c(31)}✗${c(0)} ${m}`);
|
|
39
|
+
const step = (m) => console.log(`\n${c(36)}▸ ${m}${c(0)}`);
|
|
40
|
+
|
|
41
|
+
function run(cmd, argv, opts = {}) {
|
|
42
|
+
const r = spawnSync(cmd, argv, { cwd: ROOT, encoding: 'utf8', ...opts });
|
|
43
|
+
if (r.status !== 0 && !opts.allowFail) {
|
|
44
|
+
throw new Error(`${cmd} ${argv.join(' ')} 失败:\n${(r.stderr || r.stdout || '').trim()}`);
|
|
45
|
+
}
|
|
46
|
+
return r;
|
|
47
|
+
}
|
|
48
|
+
function git(...argv) { return run('git', argv); }
|
|
49
|
+
function gitOut(...argv) { return run('git', argv).stdout.trim(); }
|
|
50
|
+
|
|
51
|
+
const done = [];
|
|
52
|
+
function mark(m) { done.push(m); }
|
|
53
|
+
|
|
54
|
+
// ---- 0. 前置检查 ----------------------------------------------------------
|
|
55
|
+
step('前置检查');
|
|
56
|
+
const branch = gitOut('rev-parse', '--abbrev-ref', 'HEAD');
|
|
57
|
+
if (branch !== 'main' && branch !== 'dev') {
|
|
58
|
+
fail(`当前分支是 ${branch},发版只允许在 main 或 dev 上做`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
const dirty = gitOut('status', '--porcelain');
|
|
62
|
+
if (dirty) {
|
|
63
|
+
fail('工作区不干净,先提交或 stash:');
|
|
64
|
+
console.log(dirty.split('\n').map((l) => ' ' + l).join('\n'));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
ok(`分支 ${branch},工作区干净`);
|
|
68
|
+
|
|
69
|
+
// ---- 1. 版本号 ------------------------------------------------------------
|
|
70
|
+
step('计算并写入版本号');
|
|
71
|
+
const pkgPath = path.join(ROOT, 'package.json');
|
|
72
|
+
const serverPath = path.join(ROOT, 'server.json');
|
|
73
|
+
const verPath = path.join(ROOT, 'src', 'shared', 'version.js');
|
|
74
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
75
|
+
const from = pkg.version;
|
|
76
|
+
const [maj, min, pat] = from.split('.').map(Number);
|
|
77
|
+
const to = bumpKind === 'major' ? `${maj + 1}.0.0`
|
|
78
|
+
: bumpKind === 'minor' ? `${maj}.${min + 1}.0`
|
|
79
|
+
: `${maj}.${min}.${pat + 1}`;
|
|
80
|
+
console.log(` ${from} → ${to} (${bumpKind})`);
|
|
81
|
+
|
|
82
|
+
if (dry) {
|
|
83
|
+
warn('--dry:只打印,不写文件、不提交、不发布');
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rewrite(file, fn) {
|
|
88
|
+
const p = path.join(ROOT, file);
|
|
89
|
+
const before = fs.readFileSync(p, 'utf8');
|
|
90
|
+
const after = fn(before);
|
|
91
|
+
if (after === before) throw new Error(`${file} 没发生替换 —— 版本号格式变了?请人工确认`);
|
|
92
|
+
fs.writeFileSync(p, after);
|
|
93
|
+
}
|
|
94
|
+
rewrite('package.json', (s) => s.replace(`"version": "${from}"`, `"version": "${to}"`));
|
|
95
|
+
// server.json 里有两处(顶层 + packages[0]),必须都改:只改顶层会让
|
|
96
|
+
// registry 认为"清单说 2.1.3、实际包是 2.1.2",校验直接不通过。
|
|
97
|
+
rewrite('server.json', (s) => s.split(`"version": "${from}"`).join(`"version": "${to}"`));
|
|
98
|
+
rewrite('src/shared/version.js', (s) => s.replace(`VERSION = '${from}'`, `VERSION = '${to}'`));
|
|
99
|
+
ok('4 处版本号已同步(package.json / server.json×2 / version.js)');
|
|
100
|
+
mark('版本号');
|
|
101
|
+
|
|
102
|
+
// ---- 2. 测试 --------------------------------------------------------------
|
|
103
|
+
step(fullTests ? '全量测试' : '发版闸门测试');
|
|
104
|
+
const testFiles = fullTests
|
|
105
|
+
? []
|
|
106
|
+
: ['tests/test_version.js', 'tests/test_imports.js', 'tests/test_api_budget.js', 'tests/test_mcp_tools.js'];
|
|
107
|
+
const t = spawnSync('npx', ['vitest', 'run', ...testFiles, '--no-file-parallelism'], {
|
|
108
|
+
cwd: ROOT, encoding: 'utf8',
|
|
109
|
+
});
|
|
110
|
+
if (t.status !== 0) {
|
|
111
|
+
fail('测试未通过,发版中止。版本号改动已写在工作区,可用 git checkout 撤销。');
|
|
112
|
+
console.log((t.stdout || '').split('\n').slice(-25).join('\n'));
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
ok('测试通过');
|
|
116
|
+
mark('测试');
|
|
117
|
+
|
|
118
|
+
// ---- 3. commit + tag ------------------------------------------------------
|
|
119
|
+
step('提交并打 tag');
|
|
120
|
+
const notes = notesFile && fs.existsSync(path.join(ROOT, notesFile))
|
|
121
|
+
? fs.readFileSync(path.join(ROOT, notesFile), 'utf8').trim()
|
|
122
|
+
: `v${to}`;
|
|
123
|
+
git('add', 'package.json', 'server.json', 'src/shared/version.js');
|
|
124
|
+
git('commit', '-m', `chore(release): v${to}`);
|
|
125
|
+
ok(`commit chore(release): v${to}`);
|
|
126
|
+
mark('commit');
|
|
127
|
+
|
|
128
|
+
// tag 打在 main 上(历史 v2.1.x 都在 main)。dev 上发版就先 ff 到 main。
|
|
129
|
+
if (branch === 'dev') {
|
|
130
|
+
git('checkout', 'main');
|
|
131
|
+
try { git('merge', '--ff-only', 'dev'); } catch (e) {
|
|
132
|
+
fail('main 无法快进到 dev(有分叉),需要人工处理。tag 未创建。');
|
|
133
|
+
git('checkout', 'dev');
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
ok('main 已快进到 dev');
|
|
137
|
+
}
|
|
138
|
+
git('tag', '-a', `v${to}`, '-m', notes);
|
|
139
|
+
ok(`tag v${to}`);
|
|
140
|
+
mark('tag');
|
|
141
|
+
|
|
142
|
+
// ---- 4. push --------------------------------------------------------------
|
|
143
|
+
step('推送到 GitHub');
|
|
144
|
+
try {
|
|
145
|
+
git('push', 'origin', 'main');
|
|
146
|
+
git('push', 'origin', 'dev');
|
|
147
|
+
git('push', 'origin', `v${to}`);
|
|
148
|
+
ok('main / dev / tag 已推送');
|
|
149
|
+
mark('git push');
|
|
150
|
+
} catch (e) {
|
|
151
|
+
fail('推送失败(网络或权限):' + String(e.message).split('\n')[0]);
|
|
152
|
+
warn(`本地 commit 与 tag 都在,网络恢复后执行:git push origin main dev v${to}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---- 5. npm ---------------------------------------------------------------
|
|
156
|
+
step('发布到 npm');
|
|
157
|
+
const npm = spawnSync('npm', ['publish'], { cwd: ROOT, encoding: 'utf8' });
|
|
158
|
+
if (npm.status === 0) {
|
|
159
|
+
ok(`@glittering0717/ai-browser@${to} 已发布`);
|
|
160
|
+
mark('npm publish');
|
|
161
|
+
} else {
|
|
162
|
+
const out = `${npm.stdout || ''}${npm.stderr || ''}`;
|
|
163
|
+
// token 失效的特征:whoami 401、PUT 404(npm 用 404 掩盖"没权限")
|
|
164
|
+
if (/E401|E404|Unauthorized|Not found/i.test(out)) {
|
|
165
|
+
fail('npm 认证失效(401/404)。需要重新登录一次,之后本脚本可全自动:');
|
|
166
|
+
console.log(' npm login # 浏览器登录');
|
|
167
|
+
console.log(' 或:把新的 granular access token 写进 ~/.npmrc 的 //registry.npmjs.org/:_authToken');
|
|
168
|
+
} else if (/EPUBLISHCONFLICT|cannot publish over/i.test(out)) {
|
|
169
|
+
fail(`npm 上已存在 ${to},版本号需要再往上走一格`);
|
|
170
|
+
} else {
|
|
171
|
+
fail('npm publish 失败:' + out.split('\n').filter(Boolean).slice(-4).join(' | '));
|
|
172
|
+
}
|
|
173
|
+
warn(`补救:token 恢复后执行 npm publish (当前目录版本已是 ${to})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- 6. MCP registry ------------------------------------------------------
|
|
177
|
+
step('发布到 MCP registry');
|
|
178
|
+
const publisher = ['mcp-publisher', '/tmp/mcp-pub/mcp-publisher', '/opt/homebrew/bin/mcp-publisher']
|
|
179
|
+
.find((p) => { try { return fs.existsSync(p) || execFileSync('which', [p]).length > 0; } catch { return false; } });
|
|
180
|
+
if (!publisher) {
|
|
181
|
+
warn('未找到 mcp-publisher,跳过。安装:gh release download --repo modelcontextprotocol/registry \\');
|
|
182
|
+
console.log(' --pattern "mcp-publisher_darwin_arm64.tar.gz" && tar xzf ...');
|
|
183
|
+
} else {
|
|
184
|
+
const pub = spawnSync(publisher, ['publish'], { cwd: ROOT, encoding: 'utf8' });
|
|
185
|
+
if (pub.status === 0) {
|
|
186
|
+
ok('MCP registry 已发布');
|
|
187
|
+
mark('mcp registry');
|
|
188
|
+
} else {
|
|
189
|
+
const out = `${pub.stdout || ''}${pub.stderr || ''}`;
|
|
190
|
+
fail('MCP registry 发布失败:' + out.split('\n').filter(Boolean).slice(-6).join(' | '));
|
|
191
|
+
if (/auth|login|token|unauthor/i.test(out)) {
|
|
192
|
+
warn('需要先登录一次:mcp-publisher login github(device flow,点一下浏览器)');
|
|
193
|
+
}
|
|
194
|
+
warn('补救:登录后执行 mcp-publisher publish');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- 摘要 -----------------------------------------------------------------
|
|
199
|
+
console.log(`\n${c(36)}── v${to} 发版摘要 ──${c(0)}`);
|
|
200
|
+
console.log(' 已完成: ' + (done.length ? done.join(' → ') : '(无)'));
|
|
201
|
+
const skipped = ['版本号', '测试', 'commit', 'tag', 'git push', 'npm publish', 'mcp registry']
|
|
202
|
+
.filter((s) => !done.includes(s));
|
|
203
|
+
if (skipped.length) console.log(` 未完成: ${skipped.join(', ')}`);
|
|
204
|
+
process.exit(skipped.length ? 2 : 0);
|
package/src/main/axExtractor.js
CHANGED
|
@@ -42,7 +42,10 @@ function valueOf(pv) {
|
|
|
42
42
|
// The only "rule" we keep (plan §5): fold that into a semantic `textbox`.
|
|
43
43
|
function editorTypeFrom(role, editable) {
|
|
44
44
|
if (editable === 'richtext' || editable === 'contenteditable') return 'richtext';
|
|
45
|
-
|
|
45
|
+
// searchbox 与 textbox 同为可输入面,只是语义上被标成搜索框。此前只认
|
|
46
|
+
// textbox,导致 role=searchbox 的输入框**完全不出现在树的可编辑索引里** ——
|
|
47
|
+
// 于是"两个 editable"的场景里少算了一个,primary 反而可能指错。
|
|
48
|
+
if (role === 'textbox' || role === 'searchbox') {
|
|
46
49
|
if (editable === 'multiline') return 'textarea';
|
|
47
50
|
if (editable === 'plaintext') return 'textbox';
|
|
48
51
|
}
|
|
@@ -100,6 +103,14 @@ let nodeStore = null; // current normalize pass's node map (see normalizeAxTree)
|
|
|
100
103
|
// wrappers (§buildThroughIgnored); an AX node that a wrapper re-parents must not
|
|
101
104
|
// be emitted twice.
|
|
102
105
|
let seenNodes = null;
|
|
106
|
+
// 本次 pass 收集到的"可编辑节点"清单,pass 结束时汇总成 tree.editors(见
|
|
107
|
+
// summarizeEditors)。为什么要在树根上放索引:一个页面上有多个 editable 时,
|
|
108
|
+
// 树的结构本身**不告诉你哪个是正文**。2026-10-19 知乎实机:顶部搜索框和
|
|
109
|
+
// 正文编辑器都带 editable,按 editable 过滤先抓到搜索框(DOM 顺序在前),
|
|
110
|
+
// 第一次粘贴直接糊进搜索框,靠字数校验才拦住没提交。
|
|
111
|
+
// 面积是这里唯一可靠的区分信号(搜索框 300×36 vs 正文 720×600,差 30 倍),
|
|
112
|
+
// 而它已经在每个节点的 bounds 里 —— 不需要额外读 DOM。
|
|
113
|
+
let editables = null;
|
|
103
114
|
|
|
104
115
|
// Descendants of an AX node that is `ignored: true`. AX marks presentational
|
|
105
116
|
// wrappers (a `span` in a flex row, `<b>` inside a paragraph) as ignored, but
|
|
@@ -138,7 +149,10 @@ function build(node, idFor) {
|
|
|
138
149
|
},
|
|
139
150
|
};
|
|
140
151
|
if (node.backendDOMNodeId != null) tn.backendDOMNodeId = node.backendDOMNodeId;
|
|
141
|
-
if (editorType)
|
|
152
|
+
if (editorType) {
|
|
153
|
+
tn.editor_type = editorType;
|
|
154
|
+
noteEditable(tn, editorType, node);
|
|
155
|
+
}
|
|
142
156
|
|
|
143
157
|
if (role === 'textbox' || role === 'combobox' || role === 'listbox' || role === 'searchbox') {
|
|
144
158
|
let v = valueOf(node.value);
|
|
@@ -183,6 +197,74 @@ function build(node, idFor) {
|
|
|
183
197
|
return tn;
|
|
184
198
|
}
|
|
185
199
|
|
|
200
|
+
// 「这个输入框是不是搜索框」的判据。role=searchbox 是最强信号;其次是
|
|
201
|
+
// placeholder/label 里的词。两者都没有时就靠面积(搜索框永远比正文小得多)。
|
|
202
|
+
// 只用来**标注**,不用来排除 —— 排除错一个真编辑器,代价是 agent 找不到
|
|
203
|
+
// 能写的地方。
|
|
204
|
+
const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
|
|
205
|
+
|
|
206
|
+
// 面积下限:AX 里存在 1px 的表单元素(隐藏 input、脚手架占位),它们的
|
|
207
|
+
// bounds 是 0 或 1,算进清单里只会制造噪音。
|
|
208
|
+
const EDITABLE_MIN_AREA = 800;
|
|
209
|
+
// 「像个正文编辑器」的下限。200×100 是保守值:真实正文区都在 400×200 以上,
|
|
210
|
+
// 而搜索框在 300×36 量级。达不到就不给 primary —— 宁可让调用方自己看图,
|
|
211
|
+
// 也不要指错。
|
|
212
|
+
const PRIMARY_MIN_AREA = 20000;
|
|
213
|
+
|
|
214
|
+
function noteEditable(tn, editorType, node) {
|
|
215
|
+
if (!editables) return;
|
|
216
|
+
const b = tn.bounds || {};
|
|
217
|
+
const w = Number(b.width) || 0;
|
|
218
|
+
const h = Number(b.height) || 0;
|
|
219
|
+
const area = w * h;
|
|
220
|
+
if (area < EDITABLE_MIN_AREA) return;
|
|
221
|
+
const ph = String(valueOf(prop(nodeStore, node.nodeId, 'placeholder')) || '');
|
|
222
|
+
const label = String(tn.label || '');
|
|
223
|
+
const searchlike = tn.role === 'searchbox'
|
|
224
|
+
|| SEARCH_LIKE.test(ph)
|
|
225
|
+
|| SEARCH_LIKE.test(label);
|
|
226
|
+
editables.push({
|
|
227
|
+
ref: tn.id,
|
|
228
|
+
role: tn.role,
|
|
229
|
+
editor_type: editorType,
|
|
230
|
+
label: label.slice(0, 40),
|
|
231
|
+
...(ph ? { placeholder: ph.slice(0, 40) } : {}),
|
|
232
|
+
w,
|
|
233
|
+
h,
|
|
234
|
+
area,
|
|
235
|
+
readonly: (tn.states || []).indexOf('readonly') >= 0,
|
|
236
|
+
...(searchlike ? { searchlike: true } : {}),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 把本 pass 收集到的可编辑节点汇总成树根上的 `editors` 索引。
|
|
242
|
+
*
|
|
243
|
+
* 存在的唯一理由:**树的结构不告诉你哪个 editable 是正文**。按 DOM 顺序
|
|
244
|
+
* 过滤 `editable` 拿到的是页面上第一个可输入的东西 —— 在知乎是顶部搜索框,
|
|
245
|
+
* 在掘金是登录框。agent 没法从 `[textbox] editable=richtext` 这一行判断
|
|
246
|
+
* "这是不是我要写的那个",于是它要么猜,要么一个个试,要么把自己的正文
|
|
247
|
+
* 糊进搜索框(2026-10-19 真实发生,靠字数校验才拦住)。
|
|
248
|
+
*
|
|
249
|
+
* 输出按面积降序,`primary: true` 标给最大的那个非搜索非只读编辑区。
|
|
250
|
+
* 只有 1 个可编辑节点时**不输出**:没有歧义就不该多花 token。
|
|
251
|
+
*
|
|
252
|
+
* @returns {Array|undefined} 0/1 个节点返回 undefined
|
|
253
|
+
*/
|
|
254
|
+
export function summarizeEditors(list) {
|
|
255
|
+
if (!Array.isArray(list) || list.length < 2) return undefined;
|
|
256
|
+
const sorted = list.slice().sort((a, b) => b.area - a.area).slice(0, 8);
|
|
257
|
+
let primary = null;
|
|
258
|
+
for (const e of sorted) {
|
|
259
|
+
if (e.readonly || e.searchlike) continue;
|
|
260
|
+
if (e.area < PRIMARY_MIN_AREA) break; // 最大的可用编辑区都不像正文
|
|
261
|
+
primary = e.ref;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
// area 不外露:w/h 已经够了,重复字段只多花 token。
|
|
265
|
+
return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
|
|
266
|
+
}
|
|
267
|
+
|
|
186
268
|
/**
|
|
187
269
|
* `InlineTextBox` 是 CDP 把一个 `StaticText` 按**换行和样式**切出来的文字碎片:
|
|
188
270
|
* 一段两行的正文会变成两个 inline box,一个加粗的词再切一个。
|
|
@@ -225,6 +307,7 @@ export function normalizeAxTree(rawNodes, opts = {}) {
|
|
|
225
307
|
nodeStore = new Map(all.map((n) => [n.nodeId, n]));
|
|
226
308
|
seenNodes = new Set();
|
|
227
309
|
readValue = opts.readValue || null;
|
|
310
|
+
editables = [];
|
|
228
311
|
const idFor = opts.idFor || ((n) => 'ax-' + n.nodeId);
|
|
229
312
|
|
|
230
313
|
// Parent map over ALL nodes, so "is this a root?" accounts for ignored
|
|
@@ -249,6 +332,11 @@ export function normalizeAxTree(rawNodes, opts = {}) {
|
|
|
249
332
|
? built[0]
|
|
250
333
|
: { id: 'root', role: 'generic', label: '', states: ['visible'], actions: [], bounds: { x: 0, y: 0, width: 0, height: 0 }, children: built };
|
|
251
334
|
tree._debug = { layer: 'ax', version: VERSION_TAG };
|
|
335
|
+
// 有歧义时才给索引(见 summarizeEditors)。挂在树根而不是散在各节点上:
|
|
336
|
+
// 调用方读一次根就能挑好目标,不用把整棵树扫一遍再自己比面积。
|
|
337
|
+
const editors = summarizeEditors(editables);
|
|
338
|
+
if (editors) tree.editors = editors;
|
|
339
|
+
editables = null;
|
|
252
340
|
nodeStore = null;
|
|
253
341
|
seenNodes = null;
|
|
254
342
|
readValue = null;
|
package/src/main/mcp_tools.js
CHANGED
|
@@ -26,7 +26,7 @@ export const MCP_TOOLS = [
|
|
|
26
26
|
{
|
|
27
27
|
name: 'browse_get_tree',
|
|
28
28
|
title: 'Read semantic tree',
|
|
29
|
-
description: 'Return the page semantic
|
|
29
|
+
description: 'Return the page semantic tree (roles, labels, actions, bounds). Root adds editors (primary) when 2+ editable.',
|
|
30
30
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
31
31
|
inputSchema: {
|
|
32
32
|
type: 'object',
|
|
@@ -42,7 +42,7 @@ export const MCP_TOOLS = [
|
|
|
42
42
|
{
|
|
43
43
|
name: 'browse_act',
|
|
44
44
|
title: 'Act on element',
|
|
45
|
-
description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all.
|
|
45
|
+
description: 'Act on a data-ai-id element. type appends at caret, setContent replaces all. page_changed:false = dispatched but page unchanged (wrapper).',
|
|
46
46
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
47
47
|
inputSchema: {
|
|
48
48
|
type: 'object',
|
|
@@ -50,7 +50,7 @@ export const MCP_TOOLS = [
|
|
|
50
50
|
action: { type: 'string', enum: ['click', 'type', 'setContent', 'clear', 'focus', 'hover', 'scroll_to', 'upload', 'get_value', 'drag', 'press', 'wheel', 'paste'], description: 'Verb. Verb-specific params live in the group of the same name.' },
|
|
51
51
|
target: { type: 'string', description: 'data-ai-id from the tree (e.g. e:button-12) — NOT the visible label. Optional for press; drag may use drag.from; upload also takes a CSS selector.' },
|
|
52
52
|
text: { type: 'string', description: 'type: appended at the caret. setContent: replaces all. paste: plain fallback when html is absent.' },
|
|
53
|
-
html: { type: 'string', description: 'paste: HTML, preferred
|
|
53
|
+
html: { type: 'string', description: 'paste: HTML, preferred (keeps headings/bold). landed_chars:null = unreadable.' },
|
|
54
54
|
value: { type: 'string', description: 'Option value for a <select>.' },
|
|
55
55
|
file: { type: 'string', description: 'upload: absolute path. target may be the input, the trigger, or a CSS selector.' },
|
|
56
56
|
timeout_ms: { type: 'integer', description: 'Deadline ms (default 15000, 1s..300s). On timeout: success=false, timed_out=true, may have partially applied.' },
|
package/src/main/page_manager.js
CHANGED
|
@@ -15,6 +15,12 @@ import { WINDOW_OPEN_SHIM_SOURCE } from './window_open_shim_source.js';
|
|
|
15
15
|
import { pickOpenedTab, openedTabGraceMs } from '../shared/opened_tab.js';
|
|
16
16
|
// 动作回执自带的"页面到底动没动"三态,以及 ref 过期诊断(两个纯函数模块)。
|
|
17
17
|
import { withEffectState, isTargetMiss as isTargetMissError } from '../shared/effect_state.js';
|
|
18
|
+
// 前后快照探针:动作返回 success 只证明"命令发出去了",不证明"页面接住了"。
|
|
19
|
+
// 后者必须由掌握渲染进程的一侧给出(见 shared/page_probe.js 顶部事故说明)。
|
|
20
|
+
import pageProbe from '../shared/page_probe.js';
|
|
21
|
+
// 默认读法(DOM extractor)的"哪个 editable 才是正文"索引。AX 路径在
|
|
22
|
+
// normalizeAxTree 里已经自带,这条补的是默认路径 —— 见文件内注释。
|
|
23
|
+
import editorsProbe from '../shared/editors_probe.js';
|
|
18
24
|
import { newTracker, noteTree, diagnoseMissingRef } from '../shared/ref_epoch.js';
|
|
19
25
|
import { diffValues, previewOf, safeByteLength, DEFAULT_SNAPSHOT_JS, DIFF_MAX_ENTRIES, evictOldest } from './snapshot_diff.js';
|
|
20
26
|
import { withDeadline, actionDeadlineMs } from '../shared/deadline.js';
|
|
@@ -753,9 +759,45 @@ class PageManager {
|
|
|
753
759
|
// 「ref 过期了」(页面变过,重读树即可)和「ref 从来没对过」(改传参)。
|
|
754
760
|
// 见 shared/ref_epoch.js 顶部注释。
|
|
755
761
|
this._noteTreeRefs(tid, res && res.tree);
|
|
762
|
+
// 多编辑器的消歧索引(见 shared/editors_probe.js)。AX 路径的树根已经带
|
|
763
|
+
// `editors`,这里只补默认路径;focused_only 的子树没有"整页有哪几个编辑区"
|
|
764
|
+
// 这层语义,跳过。
|
|
765
|
+
if (res && res.tree && !res.tree.editors && !focusedOnly) {
|
|
766
|
+
const idx = await this._editorsIndex(tid, res.tree);
|
|
767
|
+
if (idx) res.tree.editors = idx;
|
|
768
|
+
}
|
|
756
769
|
return res;
|
|
757
770
|
}
|
|
758
771
|
|
|
772
|
+
/**
|
|
773
|
+
* 扫一遍页面上的可编辑区,按面积给出 primary。
|
|
774
|
+
* 失败静默返回 null —— 消歧索引是**增强**,读不到不该让 get_tree 失败。
|
|
775
|
+
*/
|
|
776
|
+
async _editorsIndex(tid, tree) {
|
|
777
|
+
const view = this._getView(tid);
|
|
778
|
+
if (!view) return null;
|
|
779
|
+
let rows = null;
|
|
780
|
+
try {
|
|
781
|
+
const d = view.webContents.debugger;
|
|
782
|
+
if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
|
|
783
|
+
const r = await d.sendCommand('Runtime.evaluate', {
|
|
784
|
+
expression: editorsProbe.EDITORS_PROBE_JS,
|
|
785
|
+
returnByValue: true,
|
|
786
|
+
});
|
|
787
|
+
rows = r && r.result && r.result.value;
|
|
788
|
+
} catch (_e) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
const known = new Set();
|
|
792
|
+
const walk = (n) => {
|
|
793
|
+
if (!n) return;
|
|
794
|
+
if (n.id) known.add(n.id);
|
|
795
|
+
for (const c of n.children || []) walk(c);
|
|
796
|
+
};
|
|
797
|
+
walk(tree);
|
|
798
|
+
return editorsProbe.buildEditorsIndex(rows, known);
|
|
799
|
+
}
|
|
800
|
+
|
|
759
801
|
/** per-tab 的 ref 版本追踪器(懒建,避免动构造函数)。 */
|
|
760
802
|
_refTracker(tid) {
|
|
761
803
|
if (!this._refTrackers) this._refTrackers = new Map();
|
|
@@ -1369,16 +1411,59 @@ class PageManager {
|
|
|
1369
1411
|
* 放在这一层而不是各个子路径里,是因为挂住的位置不止一个(AX handles、指针目标
|
|
1370
1412
|
* 解析、拖拽按键分发都可能),逐个补等于逐个漏。
|
|
1371
1413
|
*/
|
|
1414
|
+
/**
|
|
1415
|
+
* 动作前后各拍一次页面指纹(只读、不写属性、不触发事件)。
|
|
1416
|
+
*
|
|
1417
|
+
* 探针失效一律返回 null 并让调用方跳过 —— 观测能力不足不能反过来让动作失败。
|
|
1418
|
+
* 三种失效都真实发生过:debugger 没 attach、主线程被占住导致 CDP 不返回、
|
|
1419
|
+
* 页面返回了 __err。全部走静默降级,但**绝不用 null 冒充"没变化"**:
|
|
1420
|
+
* diffProbe 对 null 显式返回 changed:null,回执里就不会出现 page_changed。
|
|
1421
|
+
*/
|
|
1422
|
+
async _pageProbe(tid, action) {
|
|
1423
|
+
if (!pageProbe.shouldProbe(action)) return null;
|
|
1424
|
+
const view = this._getView(tid);
|
|
1425
|
+
if (!view) return null;
|
|
1426
|
+
try {
|
|
1427
|
+
const d = view.webContents.debugger;
|
|
1428
|
+
if (!d || typeof d.isAttached !== 'function' || !d.isAttached()) return null;
|
|
1429
|
+
// 1.5s 硬上限:主线程卡住时 sendCommand 永不返回,等它等于把 ui.act
|
|
1430
|
+
// 又挂死一次(这一层存在的全部意义就是不许挂)。
|
|
1431
|
+
const call = d.sendCommand('Runtime.evaluate', {
|
|
1432
|
+
expression: pageProbe.PAGE_PROBE_JS,
|
|
1433
|
+
returnByValue: true,
|
|
1434
|
+
});
|
|
1435
|
+
const timer = new Promise((r) => setTimeout(() => r(null), 1500));
|
|
1436
|
+
const r = await Promise.race([call.catch(() => null), timer]);
|
|
1437
|
+
const v = r && r.result && r.result.value;
|
|
1438
|
+
return v && !v.__err ? v : null;
|
|
1439
|
+
} catch (_e) {
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1372
1444
|
async executeAction(action, target, params = {}, tabId) {
|
|
1373
1445
|
const ms = actionDeadlineMs(action, params);
|
|
1374
1446
|
const tid = tabId !== undefined ? tabId : this.activeTab;
|
|
1447
|
+
// 动作前先拍一张。为什么拍在 withDeadline 外面:deadline 只应该盖住动作
|
|
1448
|
+
// 本身,探针有它自己的 1.5s 上限,混进同一个预算会让"动作超时"和
|
|
1449
|
+
// "探针慢"两种完全不同的故障共用一个错误码。
|
|
1450
|
+
const probeBefore = await this._pageProbe(tid, action);
|
|
1375
1451
|
try {
|
|
1376
1452
|
const res = await withDeadline(
|
|
1377
1453
|
this._executeActionInner(action, target, params, tabId),
|
|
1378
1454
|
ms,
|
|
1379
1455
|
`action:${action}`
|
|
1380
1456
|
);
|
|
1381
|
-
|
|
1457
|
+
let out = this._annotateActResult(res, action, target, tid);
|
|
1458
|
+
// 动作后拍第二张。settle 是必须的:paste/type 内部已经等过一轮,但 click
|
|
1459
|
+
// 触发的弹窗/路由要等框架提交 + 一帧渲染,立刻拍必然拿到旧指纹 ——
|
|
1460
|
+
// 那会把每一次成功的弹窗点击都误报成"页面没变",比不报更糟。
|
|
1461
|
+
if (probeBefore) {
|
|
1462
|
+
await new Promise((r) => setTimeout(r, pageProbe.settleMs(action)));
|
|
1463
|
+
const probeAfter = await this._pageProbe(tid, action);
|
|
1464
|
+
out = pageProbe.applyProbe(out, probeBefore, probeAfter, action);
|
|
1465
|
+
}
|
|
1466
|
+
return out;
|
|
1382
1467
|
} catch (e) {
|
|
1383
1468
|
if (e && e.__deadline) {
|
|
1384
1469
|
// 明确告诉 agent 两件事:① 它该重试/换策略;② 这个动作**可能已经部分生效**
|
|
@@ -2490,6 +2575,30 @@ class PageManager {
|
|
|
2490
2575
|
// 派发不会向下传播到子节点,监听器收不到,defaultPrevented 恒为 false。
|
|
2491
2576
|
// 实测:B站 read-editor 拆两步派发 → 编辑器完全无反应;合并 + 选定最深层
|
|
2492
2577
|
// 可编辑元素后成功。
|
|
2578
|
+
// 页面内"读回编辑器正文"的共用片段。参数是**已经在前面的表达式里定义好的
|
|
2579
|
+
// 变量名**(不是选择器),因为不同时机能拿到的句柄不同:paste 那一刻有
|
|
2580
|
+
// `ed`,之后重采样只能重新按 ref 查。
|
|
2581
|
+
//
|
|
2582
|
+
// 取"多个候选里最长的那个"而不是"第一个非空的":页面上同时存在搜索框、
|
|
2583
|
+
// 标题框、正文编辑器时,第一个非空的很可能是别的东西(知乎顶部搜索框就
|
|
2584
|
+
// 这么骗过一次粘性校验)。而正文的长度必然最大 —— 这条启发式在
|
|
2585
|
+
// 知乎/掘金/CSDN/OSChina/B站 五个站上都成立。
|
|
2586
|
+
const landedProbe = (v) =>
|
|
2587
|
+
`var cands=[],push=function(n,s){if(n&&cands.length<6)cands.push([n,s]);};` +
|
|
2588
|
+
`push(${v},'target');` +
|
|
2589
|
+
// 深层 activeElement(穿透 shadow root):编辑面常常在编辑器组件自己的
|
|
2590
|
+
// shadow DOM 里,document.activeElement 只到宿主元素。
|
|
2591
|
+
`try{var a=document.activeElement,g=0;while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement;push(a,'active');}catch(_e){}` +
|
|
2592
|
+
// 目标内部的第一个编辑面(Draft.js / ProseMirror 的正文都是容器里的子节点)
|
|
2593
|
+
`try{var q=${v}&&${v}.querySelector&&${v}.querySelector('[contenteditable=true],textarea,.cm-content,.ProseMirror,[data-lexical-editor=true]');push(q,'inner');}catch(_e){}` +
|
|
2594
|
+
// 上层编辑器容器:目标可能只是工具栏/占位层,正文在它的祖先里
|
|
2595
|
+
`try{var hh=${v}&&${v}.closest&&${v}.closest('.cm-editor,.cm-scroller,.CodeMirror,.ProseMirror,.DraftEditor-root,[contenteditable=true],form,article,main');push(hh,'host');}catch(_e){}` +
|
|
2596
|
+
`var best={n:0,src:'',p:''};` +
|
|
2597
|
+
`for(var i=0;i<cands.length;i++){var e=cands[i][0],s=cands[i][1],t='';` +
|
|
2598
|
+
`try{t=(e.value!=null)?String(e.value):String(e.innerText||e.textContent||'');}catch(_e){}` +
|
|
2599
|
+
`t=t.replace(/\\s+/g,' ');var L=t.replace(/ /g,'').length;` +
|
|
2600
|
+
`if(L>best.n)best={n:L,src:s,p:t.slice(0,200)};}`;
|
|
2601
|
+
|
|
2493
2602
|
const payload = JSON.stringify({ html, text });
|
|
2494
2603
|
const res = await run(
|
|
2495
2604
|
`var p=${payload};` +
|
|
@@ -2507,7 +2616,11 @@ class PageManager {
|
|
|
2507
2616
|
`dt.setData('text/plain',p.text||p.html.replace(/<[^>]+>/g,' ').replace(/ /g,' ').replace(/\\s+/g,' ').trim());` +
|
|
2508
2617
|
`var ev=new ClipboardEvent('paste',{clipboardData:dt,bubbles:true,cancelable:true,composed:true});` +
|
|
2509
2618
|
`var notPrevented=ed.dispatchEvent(ev);` +
|
|
2510
|
-
|
|
2619
|
+
// 同步读一次:CodeMirror 6 / ProseMirror / Quill 都在 paste handler 里
|
|
2620
|
+
// 同步写文档,这一刻就能读到;异步提交的(Draft.js 走 setTimeout)
|
|
2621
|
+
// 读不到,交给后面的延迟采样。
|
|
2622
|
+
landedProbe('ed') +
|
|
2623
|
+
`return {ok:true,prevented:!notPrevented,tag:ed.tagName,landed0:best};`
|
|
2511
2624
|
);
|
|
2512
2625
|
if (!res || !res.ok) {
|
|
2513
2626
|
return { success: false, error: (res && res.err) || 'paste failed', method: 'synthetic-clipboard-event' };
|
|
@@ -2522,19 +2635,45 @@ class PageManager {
|
|
|
2522
2635
|
consumed: false,
|
|
2523
2636
|
};
|
|
2524
2637
|
}
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2638
|
+
// 落盘校验:读回编辑器内容。
|
|
2639
|
+
//
|
|
2640
|
+
// 为什么不能只读一次、也不能只读 activeElement(2026-10-19 掘金实机):
|
|
2641
|
+
// 掘金(CodeMirror 6)粘贴成功、正文完整,回执却是 landed_chars: 0。
|
|
2642
|
+
// 根因是读回走了 deepActive —— 那一刻 activeElement 并不是编辑面(编辑器
|
|
2643
|
+
// 把焦点放在 .cm-scroller 上,真正的内容在 .cm-content),于是读到空。
|
|
2644
|
+
// 调用方看到 0 就再粘一次,正文变成两份。**假阴性比漏报贵得多**:它直接
|
|
2645
|
+
// 诱发重复写入,而重复写入是这件工具最不该制造的事故。
|
|
2646
|
+
//
|
|
2647
|
+
// 所以现在:① 以**刚粘贴的那个元素**为主,activeElement / 内部编辑面 /
|
|
2648
|
+
// 上层容器依次兜底;② 三次采样(同步 / +350ms / +900ms)取最大值,覆盖
|
|
2649
|
+
// 异步提交;③ 真读不到时给 landed_chars: null 而不是 0 —— 0 是"确定空",
|
|
2650
|
+
// null 是"读不到",这两种事实对调用方的决策完全相反。
|
|
2651
|
+
let landed = res.landed0 && res.landed0.n > 0 ? res.landed0 : null;
|
|
2652
|
+
let samples = 1;
|
|
2653
|
+
for (const wait of [350, 900]) {
|
|
2654
|
+
if (landed) break;
|
|
2655
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
2656
|
+
const again = await run(
|
|
2657
|
+
`var t=${findRefExpr(JSON.stringify(aiId))}||document.activeElement;if(!t)return {n:0,src:'none',p:''};` +
|
|
2658
|
+
landedProbe('t') + `return best;`
|
|
2659
|
+
).catch(() => null);
|
|
2660
|
+
samples++;
|
|
2661
|
+
if (again && again.n > 0) landed = again;
|
|
2662
|
+
}
|
|
2663
|
+
const landedLen = landed ? landed.n : 0;
|
|
2531
2664
|
if (!landedLen) {
|
|
2532
2665
|
return {
|
|
2533
2666
|
success: false,
|
|
2534
|
-
|
|
2667
|
+
// 措辞从 "nothing landed"(断言)改成 "could not be read back"(观测)。
|
|
2668
|
+
// 前者在掘金那种场景里是错的 —— 它会把一次成功的粘贴报成失败,而
|
|
2669
|
+
// agent 对 success:false 的标准反应就是重试一次。
|
|
2670
|
+
error: 'paste was consumed but the editor content could not be read back — this is NOT proof that nothing landed (CodeMirror-style editors keep the document outside the focused node). '
|
|
2671
|
+
+ 'Verify with get_value on the same ref before doing anything else; re-pasting on a live editor duplicates the content.',
|
|
2535
2672
|
method: 'synthetic-clipboard-event',
|
|
2536
2673
|
consumed: true,
|
|
2537
|
-
landed_chars:
|
|
2674
|
+
landed_chars: null,
|
|
2675
|
+
landed_verified: false,
|
|
2676
|
+
landed_samples: samples,
|
|
2538
2677
|
};
|
|
2539
2678
|
}
|
|
2540
2679
|
return {
|
|
@@ -2543,8 +2682,11 @@ class PageManager {
|
|
|
2543
2682
|
consumed: true,
|
|
2544
2683
|
in_frame: inFrame,
|
|
2545
2684
|
landed_chars: landedLen,
|
|
2546
|
-
|
|
2547
|
-
|
|
2685
|
+
landed_verified: true,
|
|
2686
|
+
// 读回来源:'target' 最可信;'host' 是容器兜底,可能把页面别处的文字
|
|
2687
|
+
// 算进来 —— 长度只用于"有没有落地",不做逐字比对,所以偏大是安全方向。
|
|
2688
|
+
landed_source: landed.src,
|
|
2689
|
+
...(html && !text ? { landed_preview: landed.p || '' } : {}),
|
|
2548
2690
|
};
|
|
2549
2691
|
}
|
|
2550
2692
|
|
package/src/preload/actions.cjs
CHANGED
|
@@ -160,10 +160,42 @@ function doPaste(el, params) {
|
|
|
160
160
|
if (notPrevented) {
|
|
161
161
|
return {
|
|
162
162
|
success: false,
|
|
163
|
+
consumed: false,
|
|
163
164
|
error: "paste event was not consumed by the page (defaultPrevented=false) — try a child node ref, or setContent for plain text"
|
|
164
165
|
};
|
|
165
166
|
}
|
|
166
|
-
|
|
167
|
+
// 读回口径与 page_manager._pasteViaCdp 完全一致:候选里取最长,读不到给
|
|
168
|
+
// null 而不是 0。为什么必须有:preload 这条路以前**只要页面 preventDefault
|
|
169
|
+
// 就报 success**,连"有没有内容"都不看 —— 这正是"工具说成功、页面没变"
|
|
170
|
+
// 最纯粹的一种来源。0 表示确定空(可安全重试),null 表示读不到(先核对)。
|
|
171
|
+
var best = { n: 0, src: "target", p: "" };
|
|
172
|
+
var cands = [editor];
|
|
173
|
+
try {
|
|
174
|
+
if (document.activeElement && document.activeElement !== editor) cands.push(document.activeElement);
|
|
175
|
+
} catch (_e) {}
|
|
176
|
+
try {
|
|
177
|
+
var q = editor.querySelector && editor.querySelector("[contenteditable=true],textarea,.cm-content,.ProseMirror");
|
|
178
|
+
if (q) cands.push(q);
|
|
179
|
+
} catch (_e) {}
|
|
180
|
+
for (var i = 0; i < cands.length; i++) {
|
|
181
|
+
var e = cands[i], t = "";
|
|
182
|
+
try { t = (e.value != null) ? String(e.value) : String(e.innerText || e.textContent || ""); } catch (_e) {}
|
|
183
|
+
t = t.replace(/\s+/g, " ");
|
|
184
|
+
var L = t.replace(/ /g, "").length;
|
|
185
|
+
if (L > best.n) best = { n: L, src: (i === 0 ? "target" : "candidate"), p: t.slice(0, 200) };
|
|
186
|
+
}
|
|
187
|
+
var out = {
|
|
188
|
+
success: true,
|
|
189
|
+
method: "synthetic-clipboard-event",
|
|
190
|
+
consumed: true,
|
|
191
|
+
landed_chars: best.n > 0 ? best.n : null,
|
|
192
|
+
landed_verified: best.n > 0,
|
|
193
|
+
};
|
|
194
|
+
if (best.n > 0) out.landed_source = best.src;
|
|
195
|
+
if (!best.n) {
|
|
196
|
+
out.effect_hint = "paste was consumed but the editor content could not be read back — verify with get_value before retrying; re-pasting on a live editor duplicates the content.";
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
167
199
|
}
|
|
168
200
|
|
|
169
201
|
// setContent — write to any editor type including Draft.js/ProseMirror
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// shared/editors_probe.js — DOM 侧的可编辑区清单,给**默认读法**补消歧信息。
|
|
2
|
+
//
|
|
3
|
+
// 为什么 AX 那边(main/axExtractor.js 的 summarizeEditors)已经有了一份、
|
|
4
|
+
// 这里还要再来一份:两条读法覆盖的是同一批页面,但 browse_get_tree 的**默认**
|
|
5
|
+
// 路径走 DOM extractor,不产出 `editable` 状态串,也就没有可编辑区索引。
|
|
6
|
+
// 只在 AX 路径修,等于告诉用户"想知道哪个是正文请加 ax:true" —— 而默认路径
|
|
7
|
+
// 恰恰是最常被用的那条。坑必须在自己所在的那条路上修掉。
|
|
8
|
+
//
|
|
9
|
+
// 只读、只碰四个属性(rect / placeholder / aria-label / data-ai-id),不写 DOM。
|
|
10
|
+
export const EDITORS_PROBE_JS = `(function(){
|
|
11
|
+
try{
|
|
12
|
+
var sel='input,textarea,[contenteditable=true],[contenteditable=""],[role=searchbox]';
|
|
13
|
+
var all=document.querySelectorAll(sel),out=[];
|
|
14
|
+
for(var i=0;i<all.length&&out.length<40;i++){
|
|
15
|
+
var e=all[i],r;
|
|
16
|
+
try{r=e.getBoundingClientRect();}catch(_e){continue;}
|
|
17
|
+
var w=Math.round(r.width),h=Math.round(r.height);
|
|
18
|
+
if(w*h<800) continue;
|
|
19
|
+
var tag=e.tagName.toLowerCase();
|
|
20
|
+
if(tag==='input'){
|
|
21
|
+
var ty=String(e.type||'text').toLowerCase();
|
|
22
|
+
if(/^(hidden|submit|button|checkbox|radio|file|reset|image|color|range)$/.test(ty)) continue;
|
|
23
|
+
}
|
|
24
|
+
var ce=e.getAttribute('contenteditable');
|
|
25
|
+
var et=(ce==='true'||ce==='')?'richtext':(tag==='textarea'?'textarea':'textbox');
|
|
26
|
+
var ph=String(e.getAttribute('placeholder')||'').slice(0,40);
|
|
27
|
+
out.push({
|
|
28
|
+
ref:String(e.getAttribute('data-ai-id')||''),
|
|
29
|
+
role:String(e.getAttribute('role')||'textbox'),
|
|
30
|
+
editor_type:et,w:w,h:h,
|
|
31
|
+
label:String(e.getAttribute('aria-label')||'').slice(0,40),
|
|
32
|
+
placeholder:ph,
|
|
33
|
+
readonly:!!e.readOnly,
|
|
34
|
+
type:tag==='input'?String(e.type||'text').toLowerCase():''
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}catch(e){return {__err:String((e&&e.message)||e)}}
|
|
39
|
+
})()`;
|
|
40
|
+
|
|
41
|
+
// 与 axExtractor 的 SEARCH_LIKE 必须一致 —— 两处口径分叉会让"同一个页面在
|
|
42
|
+
// ax:true 与默认读法下给出相反的 primary",那比没有索引更糟。这里不 import
|
|
43
|
+
// 是因为 axExtractor 属于 main 进程模块,而本文件也被 preload 侧测试引用。
|
|
44
|
+
export const SEARCH_LIKE = /搜索|查找|检索|search|find/i;
|
|
45
|
+
|
|
46
|
+
const MIN_AREA = 800;
|
|
47
|
+
const PRIMARY_MIN_AREA = 20000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把 DOM 探针的原始行转换成与 AX 路径**同构**的 editors 数组。
|
|
51
|
+
* 纯函数,可直接单测(不需要 Electron)。
|
|
52
|
+
*
|
|
53
|
+
* @param {Array} rows EDITORS_PROBE_JS 的返回值
|
|
54
|
+
* @param {Set} knownRefs 树里真实存在的 ref(探针是全局扫的,包含被剪枝的节点)
|
|
55
|
+
* @returns {Array|null} <2 个有效编辑区时返回 null
|
|
56
|
+
*/
|
|
57
|
+
export function buildEditorsIndex(rows, knownRefs) {
|
|
58
|
+
if (!Array.isArray(rows)) return null;
|
|
59
|
+
const known = knownRefs instanceof Set ? knownRefs : new Set(knownRefs || []);
|
|
60
|
+
const list = [];
|
|
61
|
+
for (const r of rows) {
|
|
62
|
+
if (!r || !r.ref || (known.size && !known.has(r.ref))) continue;
|
|
63
|
+
const w = Number(r.w) || 0;
|
|
64
|
+
const h = Number(r.h) || 0;
|
|
65
|
+
const area = w * h;
|
|
66
|
+
if (area < MIN_AREA) continue;
|
|
67
|
+
const ph = String(r.placeholder || '');
|
|
68
|
+
const label = String(r.label || '');
|
|
69
|
+
const searchlike = r.role === 'searchbox'
|
|
70
|
+
|| r.type === 'search'
|
|
71
|
+
|| SEARCH_LIKE.test(ph)
|
|
72
|
+
|| SEARCH_LIKE.test(label);
|
|
73
|
+
list.push({
|
|
74
|
+
ref: r.ref,
|
|
75
|
+
role: r.role || 'textbox',
|
|
76
|
+
editor_type: r.editor_type || 'textbox',
|
|
77
|
+
label: label.slice(0, 40),
|
|
78
|
+
...(ph ? { placeholder: ph.slice(0, 40) } : {}),
|
|
79
|
+
w, h, area,
|
|
80
|
+
readonly: !!r.readonly,
|
|
81
|
+
...(searchlike ? { searchlike: true } : {}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (list.length < 2) return null;
|
|
85
|
+
const sorted = list.sort((a, b) => b.area - a.area).slice(0, 8);
|
|
86
|
+
let primary = null;
|
|
87
|
+
for (const e of sorted) {
|
|
88
|
+
if (e.readonly || e.searchlike) continue;
|
|
89
|
+
if (e.area < PRIMARY_MIN_AREA) break;
|
|
90
|
+
primary = e.ref;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
return sorted.map(({ area, ...rest }) => (rest.ref === primary ? { ...rest, primary: true } : rest));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default { EDITORS_PROBE_JS, buildEditorsIndex, SEARCH_LIKE };
|
|
@@ -67,6 +67,11 @@ const PARTIAL_PATTERNS = [
|
|
|
67
67
|
/may have partially applied/i,
|
|
68
68
|
/not consumed by the page/i,
|
|
69
69
|
/consumed but nothing landed/i,
|
|
70
|
+
// 2026-10-19 之后 paste 的读回口径:读不到 ≠ 没落地(CodeMirror 把正文放在
|
|
71
|
+
// 焦点节点之外)。这条必须落在 partial 而不是 no-effect 里 —— 判成 none 会
|
|
72
|
+
// 告诉 agent"照原样重试是安全的",而在活着的编辑器上重试就是内容翻倍。
|
|
73
|
+
// 旧文案保留在上面,历史回执与日志仍要能被正确分类。
|
|
74
|
+
/could not be read back/i,
|
|
70
75
|
/interrupted|disconnected|target closed|debugger detached/i,
|
|
71
76
|
/deadline exceeded|timed? ?out/i,
|
|
72
77
|
];
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// shared/page_probe.js — "这一步到底改动了页面没有"必须由工具回答,不能让 agent 猜。
|
|
2
|
+
//
|
|
3
|
+
// 事故(2026-10-19,B站投稿):
|
|
4
|
+
// browse_act click 返回 success:true,agent 以为投递成功,继续往下走。
|
|
5
|
+
// 实际点中的是**外层** `<div class="submit-container">`,真正的可点元素是它
|
|
6
|
+
// 里面的 `<span class="submit-add">`。页面一个字节都没变,回执却是一片绿。
|
|
7
|
+
// 错误在第三步之后才暴露 —— 这时已经不可能靠"回头看"把因果连起来。
|
|
8
|
+
//
|
|
9
|
+
// 为什么这必须是原语而不是让调用方自查:
|
|
10
|
+
// 调用方(模型)没有"我刚才那一手到底生效了吗"的直觉。它拿到的 success
|
|
11
|
+
// 只说明**命令发出去了**,不说明**页面接住了**。中间这层语义只能由掌握
|
|
12
|
+
// 渲染进程的工具补上。否则每个站点、每个动作都要调用方自己写一遍快照对比,
|
|
13
|
+
// 而它连"该在什么时候拍快照"都不知道(拍早了页面还没渲染)。
|
|
14
|
+
//
|
|
15
|
+
// 设计取舍:
|
|
16
|
+
// 1. 只读、极轻。一次 evaluate 拿 5 个标量,不碰 AX 树、不截图。
|
|
17
|
+
// 动作用户关心的时延是"点击后多久有反馈",多 2 次 evaluate(各 ~1ms)
|
|
18
|
+
// 换掉一整类静默失败,值得。
|
|
19
|
+
// 2. 指纹取**全量文本 hash + DOM 节点数 + 焦点元素内容长度 + URL**。
|
|
20
|
+
// 只看文本会漏掉"弹窗打开但文案相同",只看节点数会漏掉"同位置替换"。
|
|
21
|
+
// 四个信号任一变化即视为 changed —— 宁可报 changed:true 放过,
|
|
22
|
+
// 不可报 false 冤枉(false 会诱导 agent 重复提交)。
|
|
23
|
+
// 3. changed:false 不等于失败。点击一个已选中的 radio、关闭一个已关的抽屉,
|
|
24
|
+
// 都是合法的无变化。所以这里只**陈述观测**,把结论留给调用方,并配一句
|
|
25
|
+
// 可执行的下一步。真正"该有变化却无变化"的判定放在 MUST_HAVE_EFFECT 里,
|
|
26
|
+
// 只对写类动作(输入/粘贴/上传)生效 —— 那些动作没有"合法无变化"的情形。
|
|
27
|
+
//
|
|
28
|
+
// 4. 主文档视角的边界:探针只读 top document。OOPIF 子帧里的变化它看不见,
|
|
29
|
+
// 因此 changed:false 的措辞是"主文档未变化",不是"什么都没发生"。这个区别
|
|
30
|
+
// 写进 hint 里,避免把 B站 read-editor 那种跨进程编辑器误判成失败。
|
|
31
|
+
|
|
32
|
+
// 页面内执行的探针。纯读,不写任何属性,不触发任何事件。
|
|
33
|
+
// 返回的字段都用单字母:它要跨 RPC 边界回传,越短越好,语义在下面注释里。
|
|
34
|
+
// t = body 全量文本长度(折叠空白后,与 DEFAULT_SNAPSHOT_JS 的口径一致)
|
|
35
|
+
// h = 该文本的 djb2 hash(base36)—— 长度相同的内容替换靠它抓
|
|
36
|
+
// d = 当前**可编辑**焦点元素的内容长度(深浅穿透 shadow root)—— 输入类动作的主信号。
|
|
37
|
+
// 只算 input/textarea/select/contenteditable:点击一个普通 div 也会让
|
|
38
|
+
// activeElement 变过去,若把它的 innerText 算进来,每次"点了个没反应的
|
|
39
|
+
// 按钮"都会因为焦点跑过去而报 page_changed:true —— 那等于把这个探针
|
|
40
|
+
// 最该抓的场景(点了没反应)全丢掉。实测于 2026-10-22 的 fixture 回归。
|
|
41
|
+
// n = DOM 元素总数(粗粒度结构变化:弹窗、列表插入)
|
|
42
|
+
// u = location.href(SPA 路由跳转)
|
|
43
|
+
export const PAGE_PROBE_JS = `(function(){
|
|
44
|
+
try{
|
|
45
|
+
var b=document.body||document.documentElement;
|
|
46
|
+
var t=(b&&(b.innerText||b.textContent))||'';
|
|
47
|
+
t=t.replace(/\\s+/g,' ');
|
|
48
|
+
var h=5381;
|
|
49
|
+
for(var i=0;i<t.length;i++)h=((h<<5)+h+t.charCodeAt(i))|0;
|
|
50
|
+
var a=document.activeElement,g=0;
|
|
51
|
+
try{ while(a&&a.shadowRoot&&a.shadowRoot.activeElement&&g++<8)a=a.shadowRoot.activeElement; }catch(_e){}
|
|
52
|
+
var dv='';
|
|
53
|
+
try{
|
|
54
|
+
if(a){
|
|
55
|
+
var tg=a.tagName;
|
|
56
|
+
var ed=(a.isContentEditable===true)||tg==='INPUT'||tg==='TEXTAREA'||tg==='SELECT';
|
|
57
|
+
if(ed) dv=(a.value!=null)?String(a.value):(a.innerText||a.textContent||'');
|
|
58
|
+
}
|
|
59
|
+
}catch(_e){}
|
|
60
|
+
var n=0;
|
|
61
|
+
try{ n=document.getElementsByTagName('*').length; }catch(_e){}
|
|
62
|
+
return {t:t.length,h:(h>>>0).toString(36),d:(dv||'').replace(/\\s+/g,'').length,n:n,u:String(location.href)};
|
|
63
|
+
}catch(e){ return {__err:String((e&&e.message)||e)}; }
|
|
64
|
+
})()`;
|
|
65
|
+
|
|
66
|
+
// 哪些动作值得拍前后快照。只读动作(get_value / focus / hover / scroll_to)
|
|
67
|
+
// 不拍:它们本来就不该改页面,拍了只是白花两次往返。
|
|
68
|
+
// wheel 也不拍:滚动会改变 scroll 位置但 body.innerText 不变,节点数也不变,
|
|
69
|
+
// 必然报 false —— 那是个稳定的假阴性,不如不报。
|
|
70
|
+
const PROBED_ACTIONS = new Set([
|
|
71
|
+
'click', 'type', 'setContent', 'clear', 'paste', 'upload', 'drag', 'press',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
// 这些动作**没有"合法无变化"的情形**:输入/粘贴/上传成功就一定有内容落地。
|
|
75
|
+
// 它们报 page_changed:false 时给强提示(并说明先读回再决定)。
|
|
76
|
+
const MUST_HAVE_EFFECT = new Set(['type', 'setContent', 'clear', 'paste', 'upload']);
|
|
77
|
+
|
|
78
|
+
export function shouldProbe(action) {
|
|
79
|
+
return PROBED_ACTIONS.has(String(action || ''));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 动作返回后到拍快照之间要等多久,让 React/Vue 的提交与路由跳转落地。
|
|
83
|
+
// 写类动作内部已经等过一轮(paste 350ms、type 有 input 校验),只需短等;
|
|
84
|
+
// click 触发弹窗/路由时框架往往在下一个 microtask + 一帧后才渲染,给足 220ms。
|
|
85
|
+
export function settleMs(action) {
|
|
86
|
+
return MUST_HAVE_EFFECT.has(String(action || '')) ? 80 : 220;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const HINT_WRITE =
|
|
90
|
+
'page_changed=false: the action returned success but the main document is byte-identical '
|
|
91
|
+
+ '(same text hash, same DOM node count, same focused-field length). The payload most likely '
|
|
92
|
+
+ 'never landed — a wrapper element was targeted, or the editor listens on a child surface. '
|
|
93
|
+
+ 'Read the element back with get_value BEFORE retrying: a second paste on a live editor '
|
|
94
|
+
+ 'duplicates the content instead of fixing it.';
|
|
95
|
+
|
|
96
|
+
const HINT_CLICK =
|
|
97
|
+
'page_changed=false: the action returned success but the main document did not change. Two '
|
|
98
|
+
+ 'common causes: (a) the click hit a wrapper element and the real handler is on a deeper '
|
|
99
|
+
+ 'node — re-read the tree and target the inner <span>/<a>/<button>; (b) the control was '
|
|
100
|
+
+ 'already in the requested state, in which case there is nothing to retry. If the effect was '
|
|
101
|
+
+ 'expected inside an out-of-process iframe, this probe cannot see it — verify with '
|
|
102
|
+
+ 'get_tree/screenshot instead.';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 比较前后两次探针。纯函数,无 Electron 依赖,可直接单测。
|
|
106
|
+
* @returns {{changed: boolean|null, delta: object|null}}
|
|
107
|
+
* changed=null 表示数据不可比(某次探针失败),调用方应忽略而不是当成 false。
|
|
108
|
+
*/
|
|
109
|
+
export function diffProbe(before, after) {
|
|
110
|
+
const ok = (p) => p && typeof p === 'object' && !p.__err && typeof p.h === 'string';
|
|
111
|
+
if (!ok(before) || !ok(after)) return { changed: null, delta: null };
|
|
112
|
+
const delta = {
|
|
113
|
+
text_chars: (Number(after.t) || 0) - (Number(before.t) || 0),
|
|
114
|
+
dom_nodes: (Number(after.n) || 0) - (Number(before.n) || 0),
|
|
115
|
+
focused_chars: (Number(after.d) || 0) - (Number(before.d) || 0),
|
|
116
|
+
};
|
|
117
|
+
const changed = before.h !== after.h
|
|
118
|
+
|| (Number(before.n) || 0) !== (Number(after.n) || 0)
|
|
119
|
+
|| (Number(before.d) || 0) !== (Number(after.d) || 0)
|
|
120
|
+
|| before.u !== after.u;
|
|
121
|
+
return { changed, delta };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 把观测结果写进动作回执。纯函数(返回新对象)。
|
|
126
|
+
*
|
|
127
|
+
* 只在 changed === false 时附加 page_delta 和 effect_hint:
|
|
128
|
+
* 正常路径(changed:true)不多说一个字,避免每步回执都多两行噪音。
|
|
129
|
+
*/
|
|
130
|
+
export function applyProbe(res, before, after, action) {
|
|
131
|
+
if (!res || typeof res !== 'object') return res;
|
|
132
|
+
const d = diffProbe(before, after);
|
|
133
|
+
if (d.changed === null) return res;
|
|
134
|
+
const out = { ...res, page_changed: d.changed };
|
|
135
|
+
if (d.changed) return out;
|
|
136
|
+
out.page_delta = d.delta;
|
|
137
|
+
const isWrite = MUST_HAVE_EFFECT.has(String(action || ''));
|
|
138
|
+
if (!out.effect_hint) {
|
|
139
|
+
out.effect_hint = isWrite ? HINT_WRITE : HINT_CLICK;
|
|
140
|
+
}
|
|
141
|
+
// 写类动作 + 主文档零变化 = 内容很可能没落地。把 effect_state 从 committed
|
|
142
|
+
// 降回 unknown,否则回执是自相矛盾的:success:true + effect_state:committed
|
|
143
|
+
// 等于告诉 agent"这一手已经生效,别重试",而观测说它没生效。
|
|
144
|
+
//
|
|
145
|
+
// 为什么是 unknown 而不是 none:探针只看主文档,OOPIF 子帧里的编辑器
|
|
146
|
+
// (B站 read-editor)变化它看不见。unknown + verify_first 是唯一诚实的选择。
|
|
147
|
+
if (isWrite && out.success === true) {
|
|
148
|
+
out.effect_state = 'unknown';
|
|
149
|
+
out.retry = 'verify_first';
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default { PAGE_PROBE_JS, shouldProbe, settleMs, diffProbe, applyProbe };
|
package/src/shared/version.js
CHANGED
|
@@ -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.
|
|
5
|
+
export const VERSION = '2.1.2';
|