comate-ui-inspector 0.1.1 → 0.1.2-alpha.4

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 CHANGED
@@ -6,9 +6,10 @@
6
6
 
7
7
  - `extension/`:Chrome/Edge MV3 扩展(`manifest.json` + MAIN/ISOLATED 内容脚本 + background SW)。
8
8
  - `extension/native-host/host.mjs`:Native Messaging host(浏览器按需拉起,零依赖)。
9
- - `mcp/server.mjs`:MCP stdio server,`clear_pending_edits` = 取消选中。
9
+ - `mcp/server.mjs`:MCP stdio server,`clear_pending_edits` = 取消选中,`notify_browser` = 推送页面 toast。
10
+ - `bridge/server.mjs`:HTTP bridge(远程开发用),绑 127.0.0.1,`/ingest` `/state`,读写同一份 `~/.ui-inspector/*.json`。
10
11
  - `hooks/inject-ui-inspector.mjs`:Comate `UserPromptSubmit` hook,提交时自动注入全部选中元素。
11
- - `cli.mjs`:命令入口,提供 `install` / `uninstall` / `status`(发布为 npm 包后暴露为 `ui-inspector` 命令)。
12
+ - `cli.mjs`:命令入口,提供 `install` / `uninstall` / `status` / `bridge`(发布为 npm 包后暴露为 `ui-inspector` 命令)。
12
13
 
13
14
  数据落在 `~/.ui-inspector/pending.json`(用户主目录、一台机器一份)。hook 注册为**个人级**(`~/.comate/hooks.json`,对所有项目生效),所以**整机只装一次、所有仓库通用**,命令在哪个目录跑都行。
14
15
 
@@ -23,6 +24,39 @@ ui-inspector install # 注册 native host + 个人级 hook,并打印
23
24
 
24
25
  1. 把打印的 **MCP 配置**贴进 Comate 的 MCP 设置,**reload Comate**。
25
26
  2. `chrome://extensions` → 开发者模式 →「加载已解压的扩展程序」→ 选打印出来的 **扩展目录**(只需一次)。
27
+ 3. 点扩展图标打开弹窗,**配置生效域名与传输通路**(见下)。
28
+
29
+ ## 配置:域名与传输通路(扩展弹窗)
30
+
31
+ 目标域名和传输方式不写死在代码里,装好扩展后点图标在弹窗里配:
32
+
33
+ - **生效域名**:默认已含 `localhost` / `127.0.0.1`;项目域名(如 `https://local.oreateai.com`)自己加,添加时浏览器会弹权限申请,允许后即在该域名注入选取脚本。
34
+ - **传输通路**:
35
+ - `本地(Native Messaging)`——默认,浏览器按需拉起 `host.mjs`,零常驻。
36
+ - `远程(HTTP bridge)`——填 bridge 地址(默认 `http://127.0.0.1:8917`),用于远程开发(见下)。
37
+
38
+ ## 远程开发(HTTP bridge)
39
+
40
+ 浏览器在本地、代码与 Comate 在远程开发机时,Native Messaging 跨不了机,改用 HTTP bridge。流程精简为两条命令:
41
+
42
+ ```bash
43
+ # 远程机上(和 Comate hook/MCP 同机):装 hook/MCP 并后台起 bridge
44
+ ui-inspector install --remote # --port 默认 8917;跳过无浏览器的 native host
45
+ # 本地笔记本:把 bridge 端口 + dev server 端口一起转发到本地 127.0.0.1
46
+ ui-inspector link <remote-ssh-目标> --port <dev端口>
47
+ ```
48
+
49
+ `install --remote` 会打印 MCP 配置(贴进**远程**的 Comate 并 reload),并把 bridge 后台拉起(日志在 `~/.ui-inspector/bridge.log`)。`link` 本质是 `ssh -N -L 8917:127.0.0.1:8917 -L <dev端口>:127.0.0.1:<dev端口> <remote>`,保持前台、Ctrl-C 断开。
50
+
51
+ 链路断了(ssh 掉线 / 远程 bridge 或 dev server 停了)时,先在本地自检:
52
+
53
+ ```bash
54
+ ui-inspector doctor <remote> --port <dev端口> # 定位断在哪层并打印对应恢复命令
55
+ ```
56
+
57
+ 恢复顺序:先确保远程 bridge 活(`ui-inspector bridge` / `install --remote`)→ 再接本地隧道(`ui-inspector link`)→ 再看远程 dev server。
58
+
59
+ 本地仍需一次:加载扩展(`chrome://extensions` 选 `extension` 目录),在弹窗把传输切到 `远程(HTTP bridge)`、加上你的 dev 域名。bridge 只绑 127.0.0.1、不暴露到网络,访问全程走 SSH 转发,所以 https 页面连 `127.0.0.1` 不触发混合内容拦截。
26
60
 
27
61
  ## 卸载 / 状态
28
62
 
@@ -49,7 +83,7 @@ npm publish # 发到公网 npm(package.json 的 publishConfig 已
49
83
 
50
84
  ## 说明 / 限制
51
85
 
52
- - 扩展默认只在 `local.oreateai.com` / `localhost` / `127.0.0.1` 生效;别的开发域名改 `extension/manifest.json` 的 `content_scripts[].matches`。
86
+ - 扩展生效域名在**弹窗**里配(默认 `localhost` / `127.0.0.1`,项目域名自行添加);不再写死在 `manifest.json`,改用 `chrome.scripting` 按配置动态注册。
53
87
  - 只有 **dev 构建** 拿得到组件源码路径(Vue dev 的 `__file`);生产构建组件链为空,退回选择器/文本定位。
54
88
  - **Windows 暂不支持**(native host 需 `.bat` 包装器)。
55
89
  - 扩展 ID 用 `manifest.json` 的公钥 `key` 钉死,所有人加载后一致;私钥未使用、未入库。
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * ui-inspector HTTP bridge(零依赖)——远程开发用。
4
+ *
5
+ * 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
6
+ * ~/.ui-inspector/*.json。本地浏览器扩展(transport=http)经 SSH 端口转发访问本服务:
7
+ * POST /ingest {targets:[...]} → 覆盖写 pending.json,回 {ok,pending}
8
+ * GET /state → 回 {ok,count,status}
9
+ *
10
+ * 安全:只绑 127.0.0.1,不暴露到网络;远程访问一律走 `ssh -L` 把端口转发到本地 127.0.0.1。
11
+ */
12
+ import {createServer} from 'node:http';
13
+ import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
+ import {dirname, resolve} from 'node:path';
15
+ import {homedir} from 'node:os';
16
+
17
+ const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
+ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
+ const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
20
+
21
+ function readStore() {
22
+ try {
23
+ const parsed = JSON.parse(readFileSync(storeFile, 'utf8'));
24
+ return Array.isArray(parsed) ? parsed : [];
25
+ } catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ function readStatus() {
31
+ try {
32
+ return JSON.parse(readFileSync(statusFile, 'utf8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ function writeStore(list) {
39
+ mkdirSync(dirname(storeFile), {recursive: true});
40
+ writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
41
+ }
42
+
43
+ function cors(res) {
44
+ res.setHeader('Access-Control-Allow-Origin', '*');
45
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
46
+ res.setHeader('Access-Control-Allow-Headers', 'content-type');
47
+ }
48
+
49
+ function json(res, code, obj) {
50
+ cors(res);
51
+ res.writeHead(code, {'content-type': 'application/json'});
52
+ res.end(JSON.stringify(obj));
53
+ }
54
+
55
+ const server = createServer((req, res) => {
56
+ if (req.method === 'OPTIONS') {
57
+ cors(res);
58
+ res.writeHead(204);
59
+ res.end();
60
+ return;
61
+ }
62
+ const url = new URL(req.url, 'http://127.0.0.1');
63
+ if (req.method === 'POST' && url.pathname === '/ingest') {
64
+ let body = '';
65
+ req.on('data', chunk => (body += chunk));
66
+ req.on('end', () => {
67
+ try {
68
+ const {targets} = JSON.parse(body || '{}');
69
+ const now = new Date().toISOString();
70
+ const list = (Array.isArray(targets) ? targets : []).map(target => ({target, receivedAt: now}));
71
+ writeStore(list);
72
+ json(res, 200, {ok: true, pending: list.length});
73
+ } catch (error) {
74
+ json(res, 400, {ok: false, error: String(error)});
75
+ }
76
+ });
77
+ return;
78
+ }
79
+ if (req.method === 'GET' && url.pathname === '/state') {
80
+ json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
81
+ return;
82
+ }
83
+ json(res, 404, {ok: false, error: 'not found'});
84
+ });
85
+
86
+ server.listen(PORT, '127.0.0.1', () => {
87
+ process.stdout.write(`ui-inspector bridge listening on http://127.0.0.1:${PORT}\n`);
88
+ });
package/cli.mjs CHANGED
@@ -3,15 +3,22 @@
3
3
  * ui-inspector CLI。发布到 npm 后全局安装即可获得 `ui-inspector` 命令:
4
4
  * npm i -g comate-ui-inspector
5
5
  * ui-inspector install # 注册 native host + 写个人级 hook + 打印 MCP 配置与扩展目录
6
+ * ui-inspector install --remote # 远程开发机:装 hook/MCP 并后台起 bridge(跳过无浏览器的 native host)
7
+ * ui-inspector link <remote> --port <devport> # 本地:SSH 把 bridge 端口 + dev 端口转发到本地 127.0.0.1
8
+ * ui-inspector doctor <remote> --port <devport> # 本地:自检远程链路断在哪层并打印恢复命令
6
9
  * ui-inspector uninstall # 移除 native host 清单 + 个人级 hook(--purge 连 ~/.ui-inspector 一起删)
7
10
  * ui-inspector status # 查看安装状态
11
+ * ui-inspector bridge # 启动 HTTP bridge(远程开发用,--port 默认 8917)
8
12
  *
9
13
  * 全部按「整机一份、个人级」接入,不绑定任何仓库。仅 macOS / Linux。
14
+ * 目标域名与传输通路(native/http)在扩展弹窗里配置,不写死在代码库。
10
15
  */
11
- import {chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
16
+ import {chmodSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
12
17
  import {dirname, join, resolve} from 'node:path';
13
18
  import {fileURLToPath} from 'node:url';
14
19
  import {homedir, platform} from 'node:os';
20
+ import {spawn} from 'node:child_process';
21
+ import http from 'node:http';
15
22
 
16
23
  const HOST_NAME = 'com.oreate.ui_inspector';
17
24
  const EXT_ID = 'akpiagdjfeeaioifjepbmonpallececo';
@@ -53,7 +60,25 @@ function readHooks() {
53
60
  function out(text) {
54
61
  process.stdout.write(`${text}\n`);
55
62
  }
56
- function install() {
63
+ function installHookAndMcp() {
64
+ const hookCommand = `node "${hookPath}"`;
65
+ const config = readHooks() || {hooks: {}};
66
+ config.hooks = config.hooks || {};
67
+ const list = Array.isArray(config.hooks.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
68
+ if (!JSON.stringify(list).includes('inject-ui-inspector')) {
69
+ list.push({hooks: [{type: 'command', command: hookCommand, timeout: 10}]});
70
+ config.hooks.UserPromptSubmit = list;
71
+ mkdirSync(dirname(hooksFile), {recursive: true});
72
+ writeFileSync(hooksFile, `${JSON.stringify(config, null, 4)}\n`);
73
+ out(`hook installed -> ${hooksFile}`);
74
+ } else {
75
+ out('hook already present, skip');
76
+ }
77
+ out('\n== 把下面加进 Comate 的 MCP 配置(stdio),然后 reload Comate ==');
78
+ out(JSON.stringify({mcpServers: {'ui-inspector': {command: 'node', args: [serverPath]}}}, null, 4));
79
+ }
80
+
81
+ function installNativeHost() {
57
82
  chmodSync(hostPath, 0o755);
58
83
  // 用壳脚本硬编码 node 绝对路径:Chrome 拉起 native host 时 PATH 极简(不含 nvm 等目录),
59
84
  // 直接 `#!/usr/bin/env node` 会找不到 node 导致 host 起不来;壳脚本规避这点。
@@ -76,26 +101,63 @@ function install() {
76
101
  out(`skip ${dir}: ${String(error)}`);
77
102
  }
78
103
  }
104
+ }
79
105
 
80
- const hookCommand = `node "${hookPath}"`;
81
- const config = readHooks() || {hooks: {}};
82
- config.hooks = config.hooks || {};
83
- const list = Array.isArray(config.hooks.UserPromptSubmit) ? config.hooks.UserPromptSubmit : [];
84
- if (!JSON.stringify(list).includes('inject-ui-inspector')) {
85
- list.push({hooks: [{type: 'command', command: hookCommand, timeout: 10}]});
86
- config.hooks.UserPromptSubmit = list;
87
- mkdirSync(dirname(hooksFile), {recursive: true});
88
- writeFileSync(hooksFile, `${JSON.stringify(config, null, 4)}\n`);
89
- out(`hook installed -> ${hooksFile}`);
90
- } else {
91
- out('hook already present, skip');
92
- }
106
+ // 后台拉起 bridge 并与本进程脱钩,install --remote 用:装完 hook/MCP 顺手把 bridge 跑起来。
107
+ function startBridgeDetached(port) {
108
+ mkdirSync(pendingDir, {recursive: true});
109
+ const logFile = resolve(pendingDir, 'bridge.log');
110
+ const fd = openSync(logFile, 'a');
111
+ const child = spawn(process.execPath, [resolve(pkgRoot, 'bridge/server.mjs')], {
112
+ detached: true,
113
+ stdio: ['ignore', fd, fd],
114
+ env: {...process.env, UI_INSPECTOR_BRIDGE_PORT: String(port)},
115
+ });
116
+ child.unref();
117
+ out(`bridge 已后台启动(pid ${child.pid},端口 ${port}),日志:${logFile}`);
118
+ out(` 停止:kill ${child.pid}`);
119
+ }
93
120
 
94
- out('\n== 把下面加进 Comate 的 MCP 配置(stdio),然后 reload Comate ==');
95
- out(JSON.stringify({mcpServers: {'ui-inspector': {command: 'node', args: [serverPath]}}}, null, 4));
121
+ function install({remote, port}) {
122
+ installHookAndMcp();
123
+ if (remote) {
124
+ out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
125
+ startBridgeDetached(port);
126
+ out(`\n本地笔记本上执行:ui-inspector link <这台机器的 ssh 目标> --port <dev端口>`);
127
+ return;
128
+ }
129
+ installNativeHost();
96
130
  out('\n== 加载浏览器扩展(chrome://extensions → 开发者模式 → 加载已解压 → 选下面这个目录) ==');
97
131
  out(extensionDir);
98
- out('\n完成后:按住 Alt 点元素多选 → 回 Comate 写修改意见。');
132
+ out('\n加载后点扩展图标打开弹窗,配置「生效域名」(如 https://local.oreateai.com)与「传输通路」。');
133
+ out('完成后:按住 Alt 点元素多选 → 回 Comate 写修改意见。');
134
+ }
135
+
136
+ // 本地笔记本:把远程 bridge 端口 + dev server 端口都转发到本地 127.0.0.1。
137
+ function link({remote, devPort, bridgePort}) {
138
+ if (!remote) {
139
+ out('用法:ui-inspector link <remote-ssh-目标> --port <dev端口> [--bridge-port 8917]');
140
+ out('例: ui-inspector link dev-box --port 5173');
141
+ return;
142
+ }
143
+ const args = ['-N', '-L', `${bridgePort}:127.0.0.1:${bridgePort}`];
144
+ if (devPort) {
145
+ args.push('-L', `${devPort}:127.0.0.1:${devPort}`);
146
+ }
147
+ args.push(remote);
148
+ out(`转发中(Ctrl-C 断开):ssh ${args.join(' ')}`);
149
+ out(` bridge → 127.0.0.1:${bridgePort}${devPort ? `,dev → 127.0.0.1:${devPort}` : ''}`);
150
+ const child = spawn('ssh', args, {stdio: 'inherit'});
151
+ child.on('exit', code => process.exit(code ?? 0));
152
+ }
153
+
154
+ function bridge() {
155
+ const i = process.argv.indexOf('--port');
156
+ const port = i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : '8917';
157
+ process.env.UI_INSPECTOR_BRIDGE_PORT = String(port);
158
+ out(`启动 HTTP bridge(远程开发传输):127.0.0.1:${port}`);
159
+ out('本地浏览器经 SSH 端口转发访问:ssh -L ' + port + ':127.0.0.1:' + port + ' <remote>');
160
+ import(resolve(pkgRoot, 'bridge/server.mjs'));
99
161
  }
100
162
 
101
163
  function uninstall({purge}) {
@@ -147,20 +209,105 @@ function status() {
147
209
  out(` 扩展目录:${extensionDir}`);
148
210
  }
149
211
 
212
+ function getOpt(name, def) {
213
+ const i = process.argv.indexOf(name);
214
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : def;
215
+ }
216
+
217
+ function parseLink() {
218
+ const rest = process.argv.slice(3);
219
+ let remote;
220
+ let devPort;
221
+ let bridgePort = '8917';
222
+ for (let i = 0; i < rest.length; i++) {
223
+ const a = rest[i];
224
+ if (a === '--port') {
225
+ devPort = rest[++i];
226
+ } else if (a === '--bridge-port') {
227
+ bridgePort = rest[++i];
228
+ } else if (!a.startsWith('-')) {
229
+ remote = a;
230
+ }
231
+ }
232
+ return {remote, devPort, bridgePort};
233
+ }
234
+
235
+ // 探测本地某端口的 http 可达性(2s 超时)。用 node:http 而非 fetch,兼容 node 18+。
236
+ function probe(port, path) {
237
+ return new Promise(resolve => {
238
+ const req = http.get({host: '127.0.0.1', port: Number(port), path, timeout: 2000}, res => {
239
+ let body = '';
240
+ res.on('data', chunk => (body += chunk));
241
+ res.on('end', () => resolve({state: 'ok', code: res.statusCode, body}));
242
+ });
243
+ req.on('timeout', () => {
244
+ req.destroy();
245
+ resolve({state: 'timeout'});
246
+ });
247
+ req.on('error', err => resolve({state: err.code === 'ECONNREFUSED' ? 'refused' : 'error', err: String(err)}));
248
+ });
249
+ }
250
+
251
+ // 一键自检:定位远程开发链路(本地隧道 → 远程 bridge → 远程 dev server)断在哪层,并打印恢复命令。
252
+ async function doctor() {
253
+ const {remote, devPort, bridgePort} = parseLink();
254
+ const rm = remote || '<remote>';
255
+ const dp = devPort || '<dev端口>';
256
+ out('ui-inspector doctor(远程开发链路自检):');
257
+
258
+ const b = await probe(bridgePort, '/state');
259
+ if (b.state === 'ok') {
260
+ out(` [1] bridge ✓ 127.0.0.1:${bridgePort} 可达——本地→隧道→远程 bridge 全通`);
261
+ } else if (b.state === 'refused') {
262
+ out(` [1] bridge ✗ 本地 127.0.0.1:${bridgePort} 无人监听 → SSH 隧道断了(或没起)`);
263
+ out(` 恢复:ui-inspector link ${rm} --port ${dp}`);
264
+ } else {
265
+ out(` [1] bridge ✗ 隧道在、但远程 bridge 无响应(${b.state})`);
266
+ out(` 恢复:登远程机跑 ui-inspector bridge(或 install --remote),看 ~/.ui-inspector/bridge.log`);
267
+ }
268
+
269
+ if (devPort) {
270
+ const d = await probe(devPort, '/');
271
+ if (d.state === 'refused') {
272
+ out(` [2] dev ✗ 本地 127.0.0.1:${devPort} 不可达 → 隧道没转发该端口,或远程 dev server 没起`);
273
+ out(` 恢复:① 远程起 dev server;② 本地 ui-inspector link ${rm} --port ${devPort}`);
274
+ } else if (d.state === 'timeout') {
275
+ out(` [2] dev ✗ 127.0.0.1:${devPort} 超时 → 远程 dev server 可能挂了`);
276
+ out(` 恢复:远程重启 dev server`);
277
+ } else {
278
+ out(` [2] dev ✓ 127.0.0.1:${devPort} 可达(HTTP ${d.code})`);
279
+ }
280
+ } else {
281
+ out(' [2] dev - 未传 --port,跳过 dev server 检查');
282
+ }
283
+
284
+ out('\n恢复顺序:先确保远程 bridge 活 → 再接本地隧道(link)→ 再看远程 dev server。');
285
+ }
286
+
150
287
  function help() {
151
- out('用法:ui-inspector <install|uninstall|status>');
152
- out(' install 注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
153
- out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
154
- out(' status 查看安装状态');
288
+ out('用法:ui-inspector <install|uninstall|status|bridge|link|doctor>');
289
+ out(' install 注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
290
+ out(' install --remote 远程开发机:装 hook/MCP 并后台起 bridge(跳过 native host,--port 默认 8917)');
291
+ out(' link <remote> 本地:SSH 把 bridge 端口 + dev 端口转发到本地(--port <dev端口> [--bridge-port 8917])');
292
+ out(' doctor <remote> 本地:自检远程开发链路断在哪层并打印恢复命令(--port <dev端口> [--bridge-port 8917])');
293
+ out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
294
+ out(' status 查看安装状态');
295
+ out(' bridge 启动 HTTP bridge(远程开发用,--port 默认 8917)');
155
296
  }
156
297
 
157
298
  const cmd = process.argv[2] || 'help';
158
299
  if (cmd === 'install') {
159
- install();
300
+ install({remote: process.argv.includes('--remote'), port: getOpt('--port', '8917')});
301
+ } else if (cmd === 'link') {
302
+ link(parseLink());
303
+ } else if (cmd === 'doctor') {
304
+ doctor();
160
305
  } else if (cmd === 'uninstall') {
161
306
  uninstall({purge: process.argv.includes('--purge')});
162
307
  } else if (cmd === 'status') {
163
308
  status();
309
+ } else if (cmd === 'bridge') {
310
+ bridge();
164
311
  } else {
165
312
  help();
166
313
  }
@@ -1,36 +1,139 @@
1
1
  /*
2
2
  * ui-inspector 扩展的 background service worker。
3
3
  *
4
- * 改用 Native Messaging:不再 fetch 本地 HTTP(免去混合内容问题与手动起 server),
5
- * 由 chrome.runtime.sendNativeMessage 触发浏览器**按需拉起**本地 node host(host.mjs),
6
- * host 读写 .ui-inspector/pending.json 后退出。每次请求一问一答。
4
+ * 两件事:
5
+ * 1) 传输适配:把内容脚本的 ingest/state 转发给本地后端。
6
+ * - native(默认,本地开发):chrome.runtime.sendNativeMessage 按需拉起 host.mjs。
7
+ * - http(远程开发):fetch 配置的 bridgeUrl(/ingest、/state),远程机上跑 bridge/server.mjs。
8
+ * 2) 动态注册内容脚本:目标域名不再硬编码在 manifest,改由弹窗(popup)写入 storage,
9
+ * 这里按配置用 chrome.scripting 注册(MAIN + ISOLATED 两份)。
7
10
  */
8
11
  const HOST = 'com.oreate.ui_inspector';
9
12
 
10
- chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
11
- if (!msg || typeof msg.type !== 'string') {
12
- return;
13
- }
13
+ // localhost / 127.0.0.1 是通用开发地址,作默认种子;项目专属域名(如 local.oreateai.com)在弹窗里加。
14
+ const DEFAULTS = {
15
+ domains: ['http://localhost/*', 'http://127.0.0.1/*'],
16
+ transport: 'native',
17
+ bridgeUrl: 'http://127.0.0.1:8917',
18
+ };
14
19
 
15
- if (msg.type === 'ingest') {
16
- chrome.runtime.sendNativeMessage(HOST, {type: 'ingest', targets: msg.targets || []}, resp => {
20
+ function getConfig() {
21
+ return chrome.storage.local.get(DEFAULTS);
22
+ }
23
+
24
+ // ---------- 传输 ----------
25
+ function viaNative(message) {
26
+ return new Promise(resolve => {
27
+ chrome.runtime.sendNativeMessage(HOST, message, resp => {
17
28
  if (chrome.runtime.lastError) {
18
- sendResponse({ok: false, error: chrome.runtime.lastError.message});
29
+ resolve({ok: false, error: chrome.runtime.lastError.message});
19
30
  return;
20
31
  }
21
- sendResponse(resp);
32
+ resolve(resp);
22
33
  });
34
+ });
35
+ }
36
+
37
+ async function viaHttp(bridgeUrl, message) {
38
+ const base = String(bridgeUrl || '').replace(/\/+$/, '');
39
+ try {
40
+ if (message.type === 'ingest') {
41
+ const r = await fetch(`${base}/ingest`, {
42
+ method: 'POST',
43
+ headers: {'content-type': 'application/json'},
44
+ body: JSON.stringify({targets: message.targets || []}),
45
+ });
46
+ return await r.json();
47
+ }
48
+ const r = await fetch(`${base}/state`);
49
+ return await r.json();
50
+ } catch (error) {
51
+ return {ok: false, error: String(error)};
52
+ }
53
+ }
54
+
55
+ async function dispatch(message) {
56
+ const cfg = await getConfig();
57
+ return cfg.transport === 'http' ? viaHttp(cfg.bridgeUrl, message) : viaNative(message);
58
+ }
59
+
60
+ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
61
+ if (!msg || typeof msg.type !== 'string') {
62
+ return;
63
+ }
64
+ if (msg.type === 'ingest' || msg.type === 'state') {
65
+ dispatch(msg).then(sendResponse);
23
66
  return true; // 异步 sendResponse
24
67
  }
68
+ if (msg.type === 'sync-scripts') {
69
+ syncScripts().then(() => sendResponse({ok: true}));
70
+ return true;
71
+ }
72
+ });
25
73
 
26
- if (msg.type === 'state') {
27
- chrome.runtime.sendNativeMessage(HOST, {type: 'state'}, resp => {
28
- if (chrome.runtime.lastError) {
29
- sendResponse({ok: false, error: chrome.runtime.lastError.message});
30
- return;
74
+ // ---------- 动态内容脚本注册 ----------
75
+ const SCRIPTS = [
76
+ {id: 'ui-inspector-main', js: ['inspector-main.js'], world: 'MAIN'},
77
+ {id: 'ui-inspector-bridge', js: ['bridge-content.js']},
78
+ ];
79
+
80
+ async function syncScripts() {
81
+ const {domains} = await getConfig();
82
+ // 只注册已授予主机权限的域名;未授权的等弹窗 request 到权限后再同步。
83
+ const allowed = [];
84
+ for (const match of domains || []) {
85
+ try {
86
+ if (await chrome.permissions.contains({origins: [match]})) {
87
+ allowed.push(match);
31
88
  }
32
- sendResponse(resp); // {ok, count}
33
- });
34
- return true;
89
+ } catch {
90
+ // 非法 match 模式,跳过
91
+ }
92
+ }
93
+ try {
94
+ const existing = await chrome.scripting.getRegisteredContentScripts({ids: SCRIPTS.map(s => s.id)});
95
+ if (existing.length) {
96
+ await chrome.scripting.unregisterContentScripts({ids: existing.map(s => s.id)});
97
+ }
98
+ } catch {
99
+ // 没注册过,忽略
100
+ }
101
+ if (!allowed.length) {
102
+ return;
103
+ }
104
+ await chrome.scripting.registerContentScripts(
105
+ SCRIPTS.map(s => ({
106
+ id: s.id,
107
+ js: s.js,
108
+ matches: allowed,
109
+ world: s.world,
110
+ runAt: 'document_idle',
111
+ }))
112
+ );
113
+ }
114
+
115
+ async function ensureDefaults() {
116
+ const stored = await chrome.storage.local.get(Object.keys(DEFAULTS));
117
+ const patch = {};
118
+ for (const key of Object.keys(DEFAULTS)) {
119
+ if (stored[key] === undefined) {
120
+ patch[key] = DEFAULTS[key];
121
+ }
122
+ }
123
+ if (Object.keys(patch).length) {
124
+ await chrome.storage.local.set(patch);
125
+ }
126
+ }
127
+
128
+ chrome.runtime.onInstalled.addListener(async () => {
129
+ await ensureDefaults();
130
+ await syncScripts();
131
+ });
132
+ chrome.runtime.onStartup.addListener(() => {
133
+ syncScripts();
134
+ });
135
+ chrome.storage.onChanged.addListener((changes, area) => {
136
+ if (area === 'local' && changes.domains) {
137
+ syncScripts();
35
138
  }
36
139
  });
@@ -10,6 +10,7 @@
10
10
  */
11
11
  (function () {
12
12
  const alive = () => !!(chrome.runtime && chrome.runtime.id);
13
+ let lastStatusId; // 首轮只记录、不弹;之后 id 变化才弹 toast
13
14
 
14
15
  window.addEventListener('message', event => {
15
16
  if (event.source !== window || !event.data || event.data.__uiInspector !== 'selection') {
@@ -35,12 +36,22 @@
35
36
  }
36
37
  try {
37
38
  chrome.runtime.sendMessage({type: 'state'}, resp => {
38
- if (chrome.runtime.lastError) {
39
+ if (chrome.runtime.lastError || !resp || !resp.ok) {
39
40
  return;
40
41
  }
41
- if (resp && resp.ok && resp.count === 0) {
42
+ if (resp.count === 0) {
42
43
  window.postMessage({__uiInspectorBridge: 'cleared'}, '*');
43
44
  }
45
+ const status = resp.status;
46
+ if (status && status.id !== undefined) {
47
+ if (lastStatusId !== undefined && status.id !== lastStatusId) {
48
+ window.postMessage(
49
+ {__uiInspectorBridge: 'status', text: status.text, level: status.level},
50
+ '*'
51
+ );
52
+ }
53
+ lastStatusId = status.id;
54
+ }
44
55
  });
45
56
  } catch {
46
57
  clearInterval(timer);
@@ -28,6 +28,8 @@
28
28
  document.body.appendChild(hint);
29
29
 
30
30
  function showHint(text) {
31
+ window.clearTimeout(showStatus._t);
32
+ hint.style.background = '#111';
31
33
  hint.textContent = text;
32
34
  hint.style.opacity = '1';
33
35
  window.clearTimeout(showHint._t);
@@ -35,6 +37,21 @@
35
37
  hint.style.opacity = '0';
36
38
  }, 1800);
37
39
  }
40
+
41
+ // MCP notify_browser 推送的状态,复用底部这颗 pill:
42
+ // 「修改中」(info) 常驻不消失,「已完成」(success) /「失败」(error) 自动淡出。
43
+ function showStatus(text, level) {
44
+ window.clearTimeout(showHint._t);
45
+ window.clearTimeout(showStatus._t);
46
+ hint.textContent = text;
47
+ hint.style.background = level === 'success' ? '#16a34a' : level === 'error' ? '#dc2626' : '#111';
48
+ hint.style.opacity = '1';
49
+ if (level === 'success' || level === 'error') {
50
+ showStatus._t = window.setTimeout(function () {
51
+ hint.style.opacity = '0';
52
+ }, 2600);
53
+ }
54
+ }
38
55
  function cssPath(el) {
39
56
  var parts = [];
40
57
  var node = el;
@@ -162,7 +179,13 @@
162
179
  function (e) {
163
180
  if (e.key === 'Alt' && !active) {
164
181
  active = true;
165
- showHint('选择模式:点击元素可多选,再点取消;松开 Alt 退出');
182
+ showHint('选择模式:点击元素可多选,再点取消;松开 Alt 退出。点不动时按 Enter 选中当前悬停元素');
183
+ }
184
+ // 有些元素会吞掉 click(preventDefault/覆盖层),改用 Enter 选中当前 hover 的元素
185
+ if (e.key === 'Enter' && active && current && current !== overlay && current !== hint) {
186
+ e.preventDefault();
187
+ e.stopPropagation();
188
+ toggleSelect(current);
166
189
  }
167
190
  },
168
191
  true
@@ -210,14 +233,17 @@
210
233
  window.addEventListener('scroll', repositionBoxes, true);
211
234
  window.addEventListener('resize', repositionBoxes, true);
212
235
 
213
- // bridge-content 轮询到服务端 pending 已清空时回发 cleared,撤掉全部高亮
236
+ // bridge-content 轮询回发的消息:cleared(撤高亮)/ status(toast 提示)
214
237
  window.addEventListener('message', function (event) {
215
- if (event.source !== window) {
238
+ if (event.source !== window || !event.data) {
216
239
  return;
217
240
  }
218
- if (event.data && event.data.__uiInspectorBridge === 'cleared' && selected.length) {
241
+ if (event.data.__uiInspectorBridge === 'cleared' && selected.length) {
219
242
  clearSelection();
220
- showHint('已按 AI 指令取消全部选中');
243
+ showHint('已取消全部选中');
244
+ }
245
+ if (event.data.__uiInspectorBridge === 'status' && event.data.text) {
246
+ showStatus(event.data.text, event.data.level);
221
247
  }
222
248
  });
223
249
  })();
@@ -1,24 +1,18 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "ui-inspector bridge",
4
- "version": "0.0.1",
5
- "description": "按住 Alt 点元素多选,经 Native Messaging 交给本地 host,供 Comate 定位源码修改。仅开发用。",
4
+ "version": "0.1.0",
5
+ "description": "按住 Alt 点元素多选,交给本地 host / bridge,供 Comate 定位源码修改。目标域名与传输通路在扩展弹窗里配置。仅开发用。",
6
+ "minimum_chrome_version": "111",
6
7
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArHbA4zi22qf48xgfUtkMM6vXEtWiTtmhlppNAMlfG/uFjujAK9EEGssy6674woq5W9ugl+FlVE4rf477m9drGnGjnvaaFc9U8xCVqUoe/+vTmRyDWAtSPyu2aulAXd9P78WCia5ROSpecian7b0pG/iPyqvgZsyJfYk1qC3VyEBOnOqVeWawawKE23OaxUZJaci4wB10xNrWBHBIAztL8xmRDSkkByImwNshtlzbo2pE17LHZGr35FMbQXdRBKWxsdKlT1t0j7ezK73bCFjsYLWoAXVSpK+eAjltGTqKtOTvNxV94TXwr/j0v7ikgz80XD4ffzXV0qDbQ8HCxRSZMwIDAQAB",
7
- "permissions": ["nativeMessaging"],
8
+ "permissions": ["nativeMessaging", "scripting", "storage"],
9
+ "host_permissions": ["http://localhost/*", "http://127.0.0.1/*"],
10
+ "optional_host_permissions": ["*://*/*"],
8
11
  "background": {
9
12
  "service_worker": "background.js"
10
13
  },
11
- "content_scripts": [
12
- {
13
- "matches": ["https://local.oreateai.com/*", "http://localhost/*", "http://127.0.0.1/*"],
14
- "js": ["inspector-main.js"],
15
- "world": "MAIN",
16
- "run_at": "document_idle"
17
- },
18
- {
19
- "matches": ["https://local.oreateai.com/*", "http://localhost/*", "http://127.0.0.1/*"],
20
- "js": ["bridge-content.js"],
21
- "run_at": "document_idle"
22
- }
23
- ]
14
+ "action": {
15
+ "default_popup": "popup.html",
16
+ "default_title": "ui-inspector 配置"
17
+ }
24
18
  }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 消息:
9
9
  * {type:'ingest', targets:[...]} → replace 写入 .ui-inspector/pending.json,回 {ok,pending}
10
- * {type:'state'} → 回 {ok,count}
10
+ * {type:'state'} → 回 {ok,count,status}(status 为最近一条 notify_browser 推送)
11
11
  *
12
12
  * 数据文件与 MCP server / hook 共用:统一在用户主目录 ~/.ui-inspector/pending.json(与仓库位置无关)。
13
13
  */
@@ -17,6 +17,7 @@ import {homedir} from 'node:os';
17
17
 
18
18
  // 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
19
19
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
20
+ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
20
21
 
21
22
  function readStore() {
22
23
  try {
@@ -27,6 +28,14 @@ function readStore() {
27
28
  }
28
29
  }
29
30
 
31
+ function readStatus() {
32
+ try {
33
+ return JSON.parse(readFileSync(statusFile, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
30
39
  function writeStore(list) {
31
40
  mkdirSync(dirname(storeFile), {recursive: true});
32
41
  writeFileSync(storeFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
@@ -47,7 +56,7 @@ function handle(msg) {
47
56
  writeStore(targets.map(target => ({target, receivedAt: now})));
48
57
  resp = {ok: true, pending: targets.length};
49
58
  } else if (msg && msg.type === 'state') {
50
- resp = {ok: true, count: readStore().length};
59
+ resp = {ok: true, count: readStore().length, status: readStatus()};
51
60
  } else {
52
61
  resp = {ok: false, error: 'unknown message'};
53
62
  }
@@ -0,0 +1,121 @@
1
+ <!doctype html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <style>
7
+ body {
8
+ width: 320px;
9
+ margin: 0;
10
+ padding: 14px;
11
+ font: 13px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
12
+ color: #1f2328;
13
+ }
14
+ h1 {
15
+ font-size: 13px;
16
+ margin: 0 0 10px;
17
+ }
18
+ section {
19
+ margin-bottom: 14px;
20
+ }
21
+ .label {
22
+ font-weight: 600;
23
+ margin-bottom: 6px;
24
+ }
25
+ .row {
26
+ display: flex;
27
+ gap: 6px;
28
+ align-items: center;
29
+ }
30
+ input[type='text'] {
31
+ flex: 1;
32
+ min-width: 0;
33
+ padding: 5px 8px;
34
+ border: 1px solid #d0d7de;
35
+ border-radius: 6px;
36
+ font: inherit;
37
+ }
38
+ button {
39
+ padding: 5px 10px;
40
+ border: 1px solid #d0d7de;
41
+ border-radius: 6px;
42
+ background: #f6f8fa;
43
+ cursor: pointer;
44
+ font: inherit;
45
+ }
46
+ button.primary {
47
+ background: #1f6feb;
48
+ color: #fff;
49
+ border-color: #1f6feb;
50
+ }
51
+ ul {
52
+ list-style: none;
53
+ margin: 8px 0 0;
54
+ padding: 0;
55
+ }
56
+ li {
57
+ display: flex;
58
+ justify-content: space-between;
59
+ align-items: center;
60
+ padding: 4px 0;
61
+ border-top: 1px solid #f0f1f3;
62
+ }
63
+ li code {
64
+ font-size: 12px;
65
+ word-break: break-all;
66
+ }
67
+ .x {
68
+ border: none;
69
+ background: none;
70
+ color: #cf222e;
71
+ cursor: pointer;
72
+ padding: 0 4px;
73
+ }
74
+ label.radio {
75
+ display: flex;
76
+ gap: 6px;
77
+ align-items: center;
78
+ margin: 2px 0;
79
+ font-weight: 400;
80
+ }
81
+ .hint {
82
+ color: #656d76;
83
+ font-size: 12px;
84
+ margin-top: 4px;
85
+ }
86
+ #msg {
87
+ min-height: 16px;
88
+ color: #1a7f37;
89
+ font-size: 12px;
90
+ }
91
+ </style>
92
+ </head>
93
+ <body>
94
+ <h1>ui-inspector 配置</h1>
95
+
96
+ <section>
97
+ <div class="label">生效域名</div>
98
+ <div class="row">
99
+ <input id="domain-input" type="text" placeholder="如 https://local.oreateai.com" />
100
+ <button id="add-domain" class="primary">添加</button>
101
+ </div>
102
+ <ul id="domain-list"></ul>
103
+ <div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
104
+ </section>
105
+
106
+ <section>
107
+ <div class="label">传输通路</div>
108
+ <label class="radio"><input type="radio" name="transport" value="native" /> 本地(Native Messaging,默认)</label>
109
+ <label class="radio"><input type="radio" name="transport" value="http" /> 远程(HTTP bridge)</label>
110
+ <div class="row" id="bridge-row" style="margin-top: 6px">
111
+ <input id="bridge-url" type="text" placeholder="http://127.0.0.1:8917" />
112
+ </div>
113
+ <div class="hint">远程开发:在远程机跑 <code>ui-inspector bridge</code>,SSH 把端口转发到本地 127.0.0.1。</div>
114
+ </section>
115
+
116
+ <button id="save" class="primary">保存传输设置</button>
117
+ <div id="msg"></div>
118
+
119
+ <script src="popup.js"></script>
120
+ </body>
121
+ </html>
@@ -0,0 +1,126 @@
1
+ /* ui-inspector 弹窗(视图层配置):生效域名 + 传输通路,写入 chrome.storage.local。 */
2
+ const DEFAULTS = {
3
+ domains: ['http://localhost/*', 'http://127.0.0.1/*'],
4
+ transport: 'native',
5
+ bridgeUrl: 'http://127.0.0.1:8917',
6
+ };
7
+ const PROTECTED = new Set(DEFAULTS.domains); // 默认项不可删、不回收权限
8
+
9
+ const $ = id => document.getElementById(id);
10
+
11
+ function msg(text, ok = true) {
12
+ const el = $('msg');
13
+ el.textContent = text;
14
+ el.style.color = ok ? '#1a7f37' : '#cf222e';
15
+ if (text) {
16
+ setTimeout(() => (el.textContent = ''), 2600);
17
+ }
18
+ }
19
+
20
+ // 把用户输入规整成 match 模式:scheme://host/*(无 scheme 默认 https)
21
+ function toMatch(raw) {
22
+ let s = String(raw || '').trim();
23
+ if (!s) {
24
+ return null;
25
+ }
26
+ if (!/^[a-z]+:\/\//i.test(s)) {
27
+ s = `https://${s}`;
28
+ }
29
+ let u;
30
+ try {
31
+ u = new URL(s);
32
+ } catch {
33
+ return null;
34
+ }
35
+ return `${u.protocol}//${u.host}/*`;
36
+ }
37
+
38
+ async function getConfig() {
39
+ return chrome.storage.local.get(DEFAULTS);
40
+ }
41
+
42
+ async function renderDomains() {
43
+ const {domains} = await getConfig();
44
+ const ul = $('domain-list');
45
+ ul.innerHTML = '';
46
+ for (const m of domains) {
47
+ const li = document.createElement('li');
48
+ const code = document.createElement('code');
49
+ code.textContent = m;
50
+ li.appendChild(code);
51
+ if (!PROTECTED.has(m)) {
52
+ const x = document.createElement('button');
53
+ x.className = 'x';
54
+ x.textContent = '删除';
55
+ x.onclick = () => removeDomain(m);
56
+ li.appendChild(x);
57
+ }
58
+ ul.appendChild(li);
59
+ }
60
+ }
61
+
62
+ async function addDomain() {
63
+ const match = toMatch($('domain-input').value);
64
+ if (!match) {
65
+ msg('域名格式不对', false);
66
+ return;
67
+ }
68
+ const {domains} = await getConfig();
69
+ if (domains.includes(match)) {
70
+ msg('已存在');
71
+ return;
72
+ }
73
+ let granted = true;
74
+ try {
75
+ granted = await chrome.permissions.request({origins: [match]});
76
+ } catch (error) {
77
+ msg(String(error), false);
78
+ return;
79
+ }
80
+ if (!granted) {
81
+ msg('未授予权限', false);
82
+ return;
83
+ }
84
+ await chrome.storage.local.set({domains: [...domains, match]});
85
+ $('domain-input').value = '';
86
+ await renderDomains();
87
+ msg(`已添加 ${match}`);
88
+ }
89
+
90
+ async function removeDomain(match) {
91
+ const {domains} = await getConfig();
92
+ await chrome.storage.local.set({domains: domains.filter(m => m !== match)});
93
+ try {
94
+ await chrome.permissions.remove({origins: [match]});
95
+ } catch {
96
+ // 权限回收失败不影响功能
97
+ }
98
+ await renderDomains();
99
+ msg(`已删除 ${match}`);
100
+ }
101
+
102
+ async function renderTransport() {
103
+ const {transport, bridgeUrl} = await getConfig();
104
+ for (const r of document.querySelectorAll('input[name="transport"]')) {
105
+ r.checked = r.value === transport;
106
+ }
107
+ $('bridge-url').value = bridgeUrl;
108
+ }
109
+
110
+ async function saveTransport() {
111
+ const transport = document.querySelector('input[name="transport"]:checked')?.value || 'native';
112
+ const bridgeUrl = $('bridge-url').value.trim() || DEFAULTS.bridgeUrl;
113
+ await chrome.storage.local.set({transport, bridgeUrl});
114
+ msg('已保存传输设置');
115
+ }
116
+
117
+ $('add-domain').onclick = addDomain;
118
+ $('save').onclick = saveTransport;
119
+ $('domain-input').addEventListener('keydown', e => {
120
+ if (e.key === 'Enter') {
121
+ addDomain();
122
+ }
123
+ });
124
+
125
+ renderDomains();
126
+ renderTransport();
@@ -5,13 +5,13 @@
5
5
  * 流程:浏览器里按住 Alt 点元素(可多选,持久高亮)→ 落盘 .ui-inspector/pending.json;
6
6
  * 回到 Comate 写修改意见提交 → 本 hook 读取**全部**选中元素,作为 additionalContext 注入给 Agent。
7
7
  *
8
- * 注意:这里**不清空** pending —— 选中态要一直保持到 AI 改完、主动调 MCP clear_pending_edits
9
- * 才取消(页面轮询到 count=0 后撤掉高亮)。否则第一轮提交就会把多选清掉、页面提前取消高亮。
8
+ * 注入后立即**清空** pending —— 「agent 拿到元素即取消选中」:页面轮询到 count=0 会撤掉高亮,
9
+ * 不必等 AI 改完。改动过程中/完成的状态由 AI 调 MCP 的 notify_browser 推给页面 toast。
10
10
  *
11
11
  * 入参由 stdin 传入(UserPromptSubmit 的 JSON),这里用不到;输出 JSON 到 stdout。
12
12
  */
13
- import {readFileSync} from 'node:fs';
14
- import {resolve} from 'node:path';
13
+ import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
+ import {dirname, resolve} from 'node:path';
15
15
  import {homedir} from 'node:os';
16
16
 
17
17
  // 共享落盘:读用户主目录下的 pending(与 host / mcp 同一路径),一份代码可服务多个仓库。
@@ -52,7 +52,15 @@ const body = list.map((entry, index) => describe(entry?.target ?? {}, index, tot
52
52
  const additionalContext = [
53
53
  `【ui-inspector】用户在页面上选中了 ${total} 个元素,接下来这条消息是针对它们的修改意见:`,
54
54
  body,
55
- '请据此定位源码并按用户意见修改;改完调用 MCP 工具 clear_pending_edits 取消页面选中。',
55
+ '请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。',
56
56
  ].join('\n');
57
57
 
58
+ // 「拿到即取消选中」:注入后清空 pending,页面轮询到 count=0 即撤掉高亮
59
+ try {
60
+ mkdirSync(dirname(storeFile), {recursive: true});
61
+ writeFileSync(storeFile, '[]\n', 'utf8');
62
+ } catch {
63
+ // 清空失败不影响注入
64
+ }
65
+
58
66
  emit({decision: 'allow', additionalContext});
package/mcp/server.mjs CHANGED
@@ -15,6 +15,7 @@ import {homedir} from 'node:os';
15
15
  import {createInterface} from 'node:readline';
16
16
 
17
17
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
+ const statusFile = resolve(homedir(), '.ui-inspector/status.json');
18
19
 
19
20
  function readStore() {
20
21
  try {
@@ -32,6 +33,14 @@ function clearStore() {
32
33
  return count;
33
34
  }
34
35
 
36
+ // 写一条状态供扩展轮询 → 页面 toast。id 单调递增(用时间戳),扩展据此判断是否是新状态。
37
+ function writeStatus(message, level) {
38
+ mkdirSync(dirname(statusFile), {recursive: true});
39
+ const status = {text: String(message ?? ''), level: level || 'info', id: Date.now()};
40
+ writeFileSync(statusFile, `${JSON.stringify(status)}\n`, 'utf8');
41
+ return status;
42
+ }
43
+
35
44
  const TOOLS = [
36
45
  {
37
46
  name: 'list_pending_edits',
@@ -45,12 +54,24 @@ const TOOLS = [
45
54
  },
46
55
  {
47
56
  name: 'clear_pending_edits',
48
- description: '处理完后清空待处理列表,返回被清空的条数。',
57
+ description: '清空待处理选中列表(取消页面高亮),返回被清空的条数。',
49
58
  inputSchema: {type: 'object', properties: {}},
50
59
  },
60
+ {
61
+ name: 'notify_browser',
62
+ description: '向浏览器页面推送一条状态提示(toast),用于告知进度,如「修改中…」「已完成」。',
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: {
66
+ message: {type: 'string', description: '要在页面 toast 显示的文案'},
67
+ level: {type: 'string', enum: ['info', 'success', 'error'], description: '提示级别,默认 info'},
68
+ },
69
+ required: ['message'],
70
+ },
71
+ },
51
72
  ];
52
73
 
53
- function callTool(name) {
74
+ function callTool(name, args) {
54
75
  if (name === 'list_pending_edits') {
55
76
  return readStore();
56
77
  }
@@ -61,6 +82,9 @@ function callTool(name) {
61
82
  if (name === 'clear_pending_edits') {
62
83
  return {cleared: clearStore()};
63
84
  }
85
+ if (name === 'notify_browser') {
86
+ return writeStatus(args?.message, args?.level);
87
+ }
64
88
  throw new Error('unknown tool: ' + name);
65
89
  }
66
90
 
@@ -90,7 +114,7 @@ function handle(msg) {
90
114
  }
91
115
  if (msg.method === 'tools/call') {
92
116
  try {
93
- const result = callTool(msg.params?.name);
117
+ const result = callTool(msg.params?.name, msg.params?.arguments);
94
118
  send({
95
119
  jsonrpc: '2.0',
96
120
  id: msg.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.1",
3
+ "version": "0.1.2-alpha.4",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "cli.mjs",
12
12
  "extension",
13
13
  "mcp",
14
+ "bridge",
14
15
  "hooks",
15
16
  "README.md"
16
17
  ],