comate-ui-inspector 0.1.4-alpha.2 → 0.1.5-alpha.1

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
@@ -33,13 +33,13 @@ ui-inspector install # 注册 native host + 个人级 hook,并打印
33
33
  - **生效域名**:默认已含 `localhost` / `127.0.0.1`;项目域名(如 `https://local.oreateai.com`)自己加,添加时浏览器会弹权限申请,允许后即在该域名注入选取脚本。
34
34
  - **传输通路**:
35
35
  - `本地(Native Messaging)`——默认,浏览器按需拉起 `host.mjs`,零常驻。
36
- - `远程(HTTP bridge)`——填开发机 bridge 的**完整地址**(如 `devlocal.oreateai.com:8917`),用于远程开发(见下)。
36
+ - `远程(HTTP bridge)`——填开发机 bridge 的**完整地址**(如 `<remote-ip>:<port>`),用于远程开发(见下)。
37
37
 
38
38
  ## 远程开发(HTTP bridge 直连)
39
39
 
40
40
  浏览器在本地、代码与 Comate 在远程开发机时,Native Messaging 跨不了机,改用 **HTTP bridge 直连**:在开发机把 bridge 暴露到网卡,浏览器用开发机可达地址直接连,无需 SSH 隧道。
41
41
 
42
- 前提:你的笔记本能直连开发机的内网地址(通常 `/etc/hosts` 把 `devlocal.oreateai.com` 指到开发机 IP,浏览器本来就能打开 dev 页)。
42
+ 前提:你的笔记本能直连开发机的内网地址(通常 `/etc/hosts` 把 `<remote-ip>:<port>` 指到开发机 IP,浏览器本来就能打开 dev 页)。
43
43
 
44
44
  ```bash
45
45
  # 开发机上(和 Comate hook/MCP 同机):装 hook/MCP 并后台起 bridge(默认绑 0.0.0.0)
@@ -52,9 +52,11 @@ ui-inspector install --remote
52
52
  然后在浏览器(笔记本):加载扩展(`chrome://extensions` 选 `extension` 目录)→ 点扩展图标,弹窗里把**传输通路**选「远程(HTTP bridge)」、地址填**完整地址** `devlocal.oreateai.com:8917`、生效域名加上你的 dev 域名。
53
53
 
54
54
  验证直连:
55
+
55
56
  ```bash
56
- curl http://devlocal.oreateai.com:8917/state # 回 {"ok":true,...} 即通
57
+ curl http://<remote-ip>:<port>/state # 回 {"ok":true,...} 即通
57
58
  ```
59
+
58
60
  不通多半是开发机防火墙没放行该端口,或 bridge 没起(看 `~/.ui-inspector/bridge.log`)。
59
61
 
60
62
  > 注意:`--host 0.0.0.0` 会把 bridge 暴露在内网,谁能访问到这个端口谁就能写 pending;仅在可信内网用。dev 页若是 https、扩展连 http bridge 被浏览器拦,则给 bridge 上 https。
package/bridge/server.mjs CHANGED
@@ -16,6 +16,7 @@ import {homedir} from 'node:os';
16
16
 
17
17
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
18
18
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
19
+ const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
19
20
  const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
20
21
  // 默认只绑 127.0.0.1(走 SSH 隧道);直连模式(笔记本能直达开发机内网 IP)时用 0.0.0.0 暴露到网卡。
21
22
  const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
@@ -82,6 +83,21 @@ const server = createServer((req, res) => {
82
83
  json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
83
84
  return;
84
85
  }
86
+ if (req.method === 'POST' && url.pathname === '/recording') {
87
+ let body = '';
88
+ req.on('data', chunk => (body += chunk));
89
+ req.on('end', () => {
90
+ try {
91
+ const {recording} = JSON.parse(body || '{}');
92
+ mkdirSync(dirname(recordingFile), {recursive: true});
93
+ writeFileSync(recordingFile, `${JSON.stringify(recording ?? null, null, 4)}\n`, 'utf8');
94
+ json(res, 200, {ok: true});
95
+ } catch (error) {
96
+ json(res, 400, {ok: false, error: String(error)});
97
+ }
98
+ });
99
+ return;
100
+ }
85
101
  json(res, 404, {ok: false, error: 'not found'});
86
102
  });
87
103
 
package/cli.mjs CHANGED
@@ -12,7 +12,17 @@
12
12
  * 全部按「整机一份、个人级」接入,不绑定任何仓库。仅 macOS / Linux。
13
13
  * 目标域名与传输通路(native/http)在扩展弹窗里配置,不写死在代码库。
14
14
  */
15
- import {chmodSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync, rmSync} from 'node:fs';
15
+ import {
16
+ chmodSync,
17
+ cpSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ openSync,
21
+ readdirSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ rmSync,
25
+ } from 'node:fs';
16
26
  import {dirname, join, resolve} from 'node:path';
17
27
  import {fileURLToPath} from 'node:url';
18
28
  import {homedir, platform} from 'node:os';
@@ -26,6 +36,9 @@ const hostPath = resolve(pkgRoot, 'extension/native-host/host.mjs');
26
36
  const serverPath = resolve(pkgRoot, 'mcp/server.mjs');
27
37
  const hookPath = resolve(pkgRoot, 'hooks/inject-ui-inspector.mjs');
28
38
  const extensionDir = resolve(pkgRoot, 'extension');
39
+ const enhanceSrcDir = resolve(pkgRoot, 'enhancements'); // 包内增强包源目录(每个子目录一个插件)
40
+ const enhanceDstDir = resolve(extensionDir, 'enhancements'); // 拷贝进扩展的已装增强包
41
+ const enhanceRegistry = resolve(extensionDir, 'enhancements.json'); // 已装增强包清单(数组)
29
42
  const home = homedir();
30
43
  const hooksFile = resolve(home, '.comate/hooks.json');
31
44
  const pendingDir = resolve(home, '.ui-inspector');
@@ -129,7 +142,9 @@ function install({remote, port, host}) {
129
142
  const bindHost = host || '0.0.0.0';
130
143
  out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
131
144
  startBridgeDetached(port, bindHost);
132
- out(`\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${port}」(如 devlocal.oreateai.com:${port})即可直连。`);
145
+ out(
146
+ `\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${port}」(如 devlocal.oreateai.com:${port})即可直连。`
147
+ );
133
148
  return;
134
149
  }
135
150
  installNativeHost();
@@ -149,6 +164,108 @@ function bridge() {
149
164
  import(resolve(pkgRoot, 'bridge/server.mjs'));
150
165
  }
151
166
 
167
+ // ---------- 增强包(多插件)----------
168
+ // 源在包内 enhancements/<名>/(含 plugin.json)。`add` 把整目录拷进 extension/enhancements/<名>/,
169
+ // 并把描述写进 extension/enhancements.json;弹窗与 background 读该清单动态加载。需重新加载扩展生效。
170
+ function readRegistry() {
171
+ try {
172
+ const j = JSON.parse(readFileSync(enhanceRegistry, 'utf8'));
173
+ return Array.isArray(j) ? j : [];
174
+ } catch {
175
+ return [];
176
+ }
177
+ }
178
+
179
+ function writeRegistry(list) {
180
+ if (!list.length) {
181
+ if (existsSync(enhanceRegistry)) {
182
+ rmSync(enhanceRegistry);
183
+ }
184
+ return;
185
+ }
186
+ writeFileSync(enhanceRegistry, `${JSON.stringify(list, null, 4)}\n`);
187
+ }
188
+
189
+ function availableEnhancements() {
190
+ try {
191
+ return readdirSync(enhanceSrcDir, {withFileTypes: true})
192
+ .filter(d => d.isDirectory() && existsSync(join(enhanceSrcDir, d.name, 'plugin.json')))
193
+ .map(d => d.name);
194
+ } catch {
195
+ return [];
196
+ }
197
+ }
198
+
199
+ function addEnhancement(name) {
200
+ if (!name) {
201
+ out(`用法:ui-inspector add <名>。可用:${availableEnhancements().join(', ') || '(无)'}`);
202
+ return;
203
+ }
204
+ const src = join(enhanceSrcDir, name);
205
+ const manifestPath = join(src, 'plugin.json');
206
+ if (!existsSync(manifestPath)) {
207
+ out(`未找到增强包「${name}」。可用:${availableEnhancements().join(', ') || '(无)'}`);
208
+ return;
209
+ }
210
+ let descriptor;
211
+ try {
212
+ descriptor = JSON.parse(readFileSync(manifestPath, 'utf8'));
213
+ } catch (error) {
214
+ out(`增强包 plugin.json 解析失败:${String(error)}`);
215
+ return;
216
+ }
217
+ const id = descriptor.id || name;
218
+ cpSync(src, join(enhanceDstDir, id), {recursive: true});
219
+ const list = readRegistry().filter(p => p.id !== id);
220
+ list.push(descriptor);
221
+ writeRegistry(list);
222
+ out(`已安装增强包「${descriptor.name || id}」(${id}) -> ${join(enhanceDstDir, id)}`);
223
+ out('在 chrome://extensions 点「刷新」重新加载扩展后生效:弹窗出现对应「模式」tab。');
224
+ }
225
+
226
+ function removeEnhancement(name) {
227
+ if (!name) {
228
+ out('用法:ui-inspector remove <名>');
229
+ return;
230
+ }
231
+ const dir = join(enhanceDstDir, name);
232
+ let removed = false;
233
+ if (existsSync(dir)) {
234
+ rmSync(dir, {recursive: true, force: true});
235
+ removed = true;
236
+ }
237
+ const before = readRegistry();
238
+ const after = before.filter(p => p.id !== name);
239
+ if (after.length !== before.length) {
240
+ removed = true;
241
+ }
242
+ writeRegistry(after);
243
+ out(removed ? `已移除增强包「${name}」` : `未安装增强包「${name}」`);
244
+ if (removed) {
245
+ out('在 chrome://extensions 点「刷新」重新加载扩展后生效。');
246
+ }
247
+ }
248
+
249
+ function listEnhancements() {
250
+ const available = availableEnhancements();
251
+ const installed = readRegistry();
252
+ const installedIds = new Set(installed.map(p => p.id));
253
+ out('可用增强包(包内 enhancements/):');
254
+ for (const name of available) {
255
+ out(` ${installedIds.has(name) ? '●' : '○'} ${name}${installedIds.has(name) ? '(已安装)' : ''}`);
256
+ }
257
+ if (!available.length) {
258
+ out(' (无)');
259
+ }
260
+ out('\n已安装(extension/enhancements.json):');
261
+ for (const p of installed) {
262
+ out(` ${p.id} — ${p.name || ''}`);
263
+ }
264
+ if (!installed.length) {
265
+ out(' (无)');
266
+ }
267
+ }
268
+
152
269
  function uninstall({purge}) {
153
270
  for (const dir of nmDirs()) {
154
271
  const file = join(dir, `${HOST_NAME}.json`);
@@ -204,12 +321,15 @@ function getOpt(name, def) {
204
321
  }
205
322
 
206
323
  function help() {
207
- out('用法:ui-inspector <install|uninstall|status|bridge>');
324
+ out('用法:ui-inspector <install|uninstall|status|bridge|add|remove|list>');
208
325
  out(' install 本地:注册 native host + 个人级 hook,并打印 MCP 配置与扩展目录');
209
326
  out(' install --remote 远程开发机:装 hook/MCP 并后台起 bridge(默认绑 0.0.0.0 供浏览器直连,--port 默认 8917)');
210
327
  out(' uninstall 移除上述注册(加 --purge 连 ~/.ui-inspector 数据一起删)');
211
328
  out(' status 查看安装状态');
212
329
  out(' bridge 启动 HTTP bridge(--port 默认 8917,--host 默认 0.0.0.0 供直连)');
330
+ out(' add <名> 安装增强包(拷进扩展并登记,如 add recorder);需重新加载扩展');
331
+ out(' remove <名> 卸载增强包');
332
+ out(' list 列出可用与已安装的增强包');
213
333
  }
214
334
 
215
335
  const cmd = process.argv[2] || 'help';
@@ -221,6 +341,12 @@ if (cmd === 'install') {
221
341
  status();
222
342
  } else if (cmd === 'bridge') {
223
343
  bridge();
344
+ } else if (cmd === 'add') {
345
+ addEnhancement(process.argv[3]);
346
+ } else if (cmd === 'remove') {
347
+ removeEnhancement(process.argv[3]);
348
+ } else if (cmd === 'list') {
349
+ listEnhancements();
224
350
  } else {
225
351
  help();
226
352
  }
@@ -0,0 +1,28 @@
1
+ <label class="radio">
2
+ <input
3
+ type="checkbox"
4
+ id="rec-console"
5
+ checked
6
+ />
7
+ 录制控制台输出(console)
8
+ </label>
9
+ <label class="radio">
10
+ <input
11
+ type="checkbox"
12
+ id="rec-network"
13
+ checked
14
+ />
15
+ 录制网络请求(仅同域)
16
+ </label>
17
+ <button
18
+ id="rec-toggle"
19
+ class="primary"
20
+ style="margin-top: 6px"
21
+ >
22
+ 开始录制
23
+ </button>
24
+ <div
25
+ id="rec-status"
26
+ class="hint"
27
+ ></div>
28
+ <div class="hint">录制点击的元素(含组件源码)+ 勾选项;停止后在下次发消息时自动带给 Agent。</div>
@@ -0,0 +1,36 @@
1
+ /*
2
+ * 录制增强包的弹窗面板逻辑(随 pane.html 一起由 popup.js 注入后加载)。
3
+ * 自包含:读写 chrome.storage.local.recording {active,console,network,startedAt},
4
+ * bridge-content 监听该键的变化,驱动 MAIN 世界的 recorder-main.js 起停。
5
+ */
6
+ (function () {
7
+ const byId = id => document.getElementById(id);
8
+
9
+ async function refresh() {
10
+ const {recording} = await chrome.storage.local.get({recording: null});
11
+ const active = !!(recording && recording.active);
12
+ byId('rec-toggle').textContent = active ? '停止录制' : '开始录制';
13
+ byId('rec-status').textContent = active ? '录制中…(操作页面后点停止)' : '';
14
+ byId('rec-console').disabled = active;
15
+ byId('rec-network').disabled = active;
16
+ }
17
+
18
+ byId('rec-toggle').onclick = async () => {
19
+ const {recording} = await chrome.storage.local.get({recording: null});
20
+ if (recording && recording.active) {
21
+ await chrome.storage.local.set({recording: {active: false}});
22
+ } else {
23
+ await chrome.storage.local.set({
24
+ recording: {
25
+ active: true,
26
+ console: byId('rec-console').checked,
27
+ network: byId('rec-network').checked,
28
+ startedAt: Date.now(),
29
+ },
30
+ });
31
+ }
32
+ refresh();
33
+ };
34
+
35
+ refresh();
36
+ })();
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "recorder",
3
+ "name": "录制",
4
+ "main": ["recorder-main.js"],
5
+ "pane": "pane.html",
6
+ "paneJs": "pane.js"
7
+ }
@@ -0,0 +1,217 @@
1
+ /*
2
+ * MAIN world 录制脚本(增强包):录制期间抓取
3
+ * - 点击的元素(含 Vue 组件源码链,和选择模式同源)
4
+ * - console.log/info/warn/error
5
+ * - 同域名下的 fetch / XMLHttpRequest(方法/URL/状态/耗时)
6
+ * 由 bridge-content 依据 chrome.storage.recording 发来的 rec-control 起停;
7
+ * 停止时把整段录制 postMessage 出去,经 bridge→background→host/bridge 落盘 recording.json。
8
+ * 未开始录制时完全惰性,不打补丁、无开销。
9
+ */
10
+ (function () {
11
+ if (window.__oreateRecorder) {
12
+ return;
13
+ }
14
+ window.__oreateRecorder = true;
15
+
16
+ var rec = null; // 进行中的录制 {clicks,console,network,startedAt,pageUrl,opts}
17
+ var saved = {}; // 被打补丁的原函数
18
+
19
+ function cssPath(el) {
20
+ var parts = [];
21
+ var node = el;
22
+ while (node && node.nodeType === 1 && parts.length < 6) {
23
+ var seg = node.nodeName.toLowerCase();
24
+ if (node.id) {
25
+ parts.unshift(seg + '#' + node.id);
26
+ break;
27
+ }
28
+ var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
29
+ if (cls.length) {
30
+ seg += '.' + cls.join('.');
31
+ }
32
+ parts.unshift(seg);
33
+ node = node.parentElement;
34
+ }
35
+ return parts.join(' > ');
36
+ }
37
+
38
+ function componentChain(el) {
39
+ var chain = [];
40
+ var instance = el.__vueParentComponent;
41
+ while (instance && chain.length < 6) {
42
+ var type = instance.type || {};
43
+ var file = type.__file || null;
44
+ var name = type.__name || type.name || null;
45
+ if (file || name) {
46
+ chain.push({name: name, file: file});
47
+ }
48
+ instance = instance.parent;
49
+ }
50
+ return chain;
51
+ }
52
+
53
+ function fmt(a) {
54
+ if (typeof a === 'string') {
55
+ return a;
56
+ }
57
+ try {
58
+ return JSON.stringify(a);
59
+ } catch (e) {
60
+ return String(a);
61
+ }
62
+ }
63
+
64
+ function sameHost(url) {
65
+ try {
66
+ return new URL(url, location.href).host === location.host;
67
+ } catch (e) {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ function onClick(e) {
73
+ if (!rec) {
74
+ return;
75
+ }
76
+ var el = e.target;
77
+ if (!el || el.nodeType !== 1) {
78
+ return;
79
+ }
80
+ rec.clicks.push({
81
+ selector: cssPath(el),
82
+ tag: el.nodeName.toLowerCase(),
83
+ text: (el.textContent || '').trim().slice(0, 80),
84
+ componentChain: componentChain(el),
85
+ ts: Date.now(),
86
+ });
87
+ }
88
+
89
+ function startConsole() {
90
+ ['log', 'info', 'warn', 'error'].forEach(function (level) {
91
+ saved['c_' + level] = console[level];
92
+ console[level] = function () {
93
+ try {
94
+ if (rec) {
95
+ rec.console.push({
96
+ level: level,
97
+ text: Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000),
98
+ ts: Date.now(),
99
+ });
100
+ }
101
+ } catch (e) {
102
+ // 记录失败不影响原 console
103
+ }
104
+ return saved['c_' + level].apply(console, arguments);
105
+ };
106
+ });
107
+ }
108
+
109
+ function stopConsole() {
110
+ ['log', 'info', 'warn', 'error'].forEach(function (level) {
111
+ if (saved['c_' + level]) {
112
+ console[level] = saved['c_' + level];
113
+ delete saved['c_' + level];
114
+ }
115
+ });
116
+ }
117
+
118
+ function startNetwork() {
119
+ if (window.fetch) {
120
+ saved.fetch = window.fetch;
121
+ window.fetch = function (input, init) {
122
+ var url = typeof input === 'string' ? input : (input && input.url) || '';
123
+ var method = (init && init.method) || (input && input.method) || 'GET';
124
+ var t0 = Date.now();
125
+ var p = saved.fetch.apply(this, arguments);
126
+ if (rec && sameHost(url)) {
127
+ p.then(
128
+ function (r) {
129
+ rec.network.push({method: method, url: url, status: r.status, ms: Date.now() - t0, ts: t0});
130
+ },
131
+ function () {
132
+ rec.network.push({method: method, url: url, status: 0, ms: Date.now() - t0, ts: t0, error: true});
133
+ }
134
+ );
135
+ }
136
+ return p;
137
+ };
138
+ }
139
+ saved.xhrOpen = XMLHttpRequest.prototype.open;
140
+ saved.xhrSend = XMLHttpRequest.prototype.send;
141
+ XMLHttpRequest.prototype.open = function (method, url) {
142
+ this.__rec = {method: method, url: url, t0: Date.now()};
143
+ return saved.xhrOpen.apply(this, arguments);
144
+ };
145
+ XMLHttpRequest.prototype.send = function () {
146
+ var xhr = this;
147
+ var info = xhr.__rec;
148
+ if (info) {
149
+ xhr.addEventListener('loadend', function () {
150
+ if (rec && sameHost(info.url)) {
151
+ rec.network.push({
152
+ method: info.method,
153
+ url: info.url,
154
+ status: xhr.status,
155
+ ms: Date.now() - info.t0,
156
+ ts: info.t0,
157
+ });
158
+ }
159
+ });
160
+ }
161
+ return saved.xhrSend.apply(this, arguments);
162
+ };
163
+ }
164
+
165
+ function stopNetwork() {
166
+ if (saved.fetch) {
167
+ window.fetch = saved.fetch;
168
+ delete saved.fetch;
169
+ }
170
+ if (saved.xhrOpen) {
171
+ XMLHttpRequest.prototype.open = saved.xhrOpen;
172
+ delete saved.xhrOpen;
173
+ }
174
+ if (saved.xhrSend) {
175
+ XMLHttpRequest.prototype.send = saved.xhrSend;
176
+ delete saved.xhrSend;
177
+ }
178
+ }
179
+
180
+ function start(opts) {
181
+ if (rec) {
182
+ return;
183
+ }
184
+ rec = {clicks: [], console: [], network: [], startedAt: Date.now(), pageUrl: location.href, opts: opts};
185
+ document.addEventListener('click', onClick, true);
186
+ if (opts.console) {
187
+ startConsole();
188
+ }
189
+ if (opts.network) {
190
+ startNetwork();
191
+ }
192
+ }
193
+
194
+ function stop() {
195
+ if (!rec) {
196
+ return;
197
+ }
198
+ document.removeEventListener('click', onClick, true);
199
+ stopConsole();
200
+ stopNetwork();
201
+ rec.endedAt = Date.now();
202
+ var payload = rec;
203
+ rec = null;
204
+ window.postMessage({__uiInspector: 'recording', recording: payload}, '*');
205
+ }
206
+
207
+ window.addEventListener('message', function (ev) {
208
+ if (ev.source !== window || !ev.data || ev.data.__uiInspectorBridge !== 'rec-control') {
209
+ return;
210
+ }
211
+ if (ev.data.active) {
212
+ start({console: !!ev.data.console, network: !!ev.data.network});
213
+ } else {
214
+ stop();
215
+ }
216
+ });
217
+ })();
@@ -59,6 +59,14 @@ async function viaHttp(target, message) {
59
59
  });
60
60
  return await r.json();
61
61
  }
62
+ if (message.type === 'ingest-recording') {
63
+ const r = await fetch(`${base}/recording`, {
64
+ method: 'POST',
65
+ headers: {'content-type': 'application/json'},
66
+ body: JSON.stringify({recording: message.recording || null}),
67
+ });
68
+ return await r.json();
69
+ }
62
70
  const r = await fetch(`${base}/state`);
63
71
  return await r.json();
64
72
  } catch (error) {
@@ -75,7 +83,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
75
83
  if (!msg || typeof msg.type !== 'string') {
76
84
  return;
77
85
  }
78
- if (msg.type === 'ingest' || msg.type === 'state') {
86
+ if (msg.type === 'ingest' || msg.type === 'state' || msg.type === 'ingest-recording') {
79
87
  dispatch(msg).then(sendResponse);
80
88
  return true; // 异步 sendResponse
81
89
  }
@@ -86,13 +94,38 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
86
94
  });
87
95
 
88
96
  // ---------- 动态内容脚本注册 ----------
89
- const SCRIPTS = [
90
- {id: 'ui-inspector-main', js: ['inspector-main.js'], world: 'MAIN'},
91
- {id: 'ui-inspector-bridge', js: ['bridge-content.js']},
92
- ];
97
+ // MAIN 世界脚本 = 基础选取脚本 + 各增强包的 main 脚本(enhancements.json,由 `ui-inspector add` 写入);
98
+ // ISOLATED 世界固定一份 bridge-content。增强包脚本路径相对扩展根:enhancements/<id>/<file>。
99
+ const BASE_MAIN_JS = ['inspector-main.js'];
100
+ const BRIDGE_SCRIPT = {id: 'ui-inspector-bridge', js: ['bridge-content.js']};
101
+
102
+ async function readEnhancements() {
103
+ try {
104
+ const r = await fetch(chrome.runtime.getURL('enhancements.json'));
105
+ if (!r.ok) {
106
+ return [];
107
+ }
108
+ const j = await r.json().catch(() => []);
109
+ return Array.isArray(j) ? j.filter(p => p && p.id) : [];
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ async function buildScripts() {
116
+ const plugins = await readEnhancements();
117
+ const mainJs = [...BASE_MAIN_JS];
118
+ for (const p of plugins) {
119
+ for (const file of p.main || []) {
120
+ mainJs.push(`enhancements/${p.id}/${file}`);
121
+ }
122
+ }
123
+ return [{id: 'ui-inspector-main', js: mainJs, world: 'MAIN'}, BRIDGE_SCRIPT];
124
+ }
93
125
 
94
126
  async function syncScripts() {
95
127
  const {domains} = await getConfig();
128
+ const scripts = await buildScripts();
96
129
  // 只注册已授予主机权限的域名;未授权的等弹窗 request 到权限后再同步。
97
130
  const allowed = [];
98
131
  for (const match of domains || []) {
@@ -105,7 +138,7 @@ async function syncScripts() {
105
138
  }
106
139
  }
107
140
  try {
108
- const existing = await chrome.scripting.getRegisteredContentScripts({ids: SCRIPTS.map(s => s.id)});
141
+ const existing = await chrome.scripting.getRegisteredContentScripts({ids: scripts.map(s => s.id)});
109
142
  if (existing.length) {
110
143
  await chrome.scripting.unregisterContentScripts({ids: existing.map(s => s.id)});
111
144
  }
@@ -116,7 +149,7 @@ async function syncScripts() {
116
149
  return;
117
150
  }
118
151
  await chrome.scripting.registerContentScripts(
119
- SCRIPTS.map(s => ({
152
+ scripts.map(s => ({
120
153
  id: s.id,
121
154
  js: s.js,
122
155
  matches: allowed,
@@ -30,6 +30,52 @@
30
30
  }
31
31
  });
32
32
 
33
+ // 录制(增强包):MAIN 的录制结果 → background 落盘 recording.json
34
+ window.addEventListener('message', event => {
35
+ if (event.source !== window || !event.data || event.data.__uiInspector !== 'recording') {
36
+ return;
37
+ }
38
+ if (!alive()) {
39
+ return;
40
+ }
41
+ try {
42
+ chrome.runtime.sendMessage(
43
+ {type: 'ingest-recording', recording: event.data.recording},
44
+ () => void chrome.runtime.lastError
45
+ );
46
+ } catch {
47
+ // 上下文失效
48
+ }
49
+ });
50
+
51
+ // 录制起停:弹窗写 chrome.storage.recording,这里转成 rec-control 发给 MAIN 的 recorder。
52
+ function relayRecControl(r) {
53
+ window.postMessage(
54
+ {
55
+ __uiInspectorBridge: 'rec-control',
56
+ active: !!(r && r.active),
57
+ console: !!(r && r.console),
58
+ network: !!(r && r.network),
59
+ },
60
+ '*'
61
+ );
62
+ }
63
+ try {
64
+ chrome.storage.local.get({recording: null}, o => {
65
+ if (chrome.runtime.lastError || !o || !o.recording || !o.recording.active) {
66
+ return;
67
+ }
68
+ relayRecControl(o.recording); // 页面在录制中途加载:补发开始
69
+ });
70
+ chrome.storage.onChanged.addListener((changes, area) => {
71
+ if (area === 'local' && changes.recording) {
72
+ relayRecControl(changes.recording.newValue);
73
+ }
74
+ });
75
+ } catch {
76
+ // 上下文失效
77
+ }
78
+
33
79
  const timer = setInterval(() => {
34
80
  if (!alive()) {
35
81
  clearInterval(timer);
@@ -18,6 +18,7 @@ import {homedir} from 'node:os';
18
18
  // 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
19
19
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
20
20
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
21
+ const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
21
22
 
22
23
  function readStore() {
23
24
  try {
@@ -57,6 +58,10 @@ function handle(msg) {
57
58
  resp = {ok: true, pending: targets.length};
58
59
  } else if (msg && msg.type === 'state') {
59
60
  resp = {ok: true, count: readStore().length, status: readStatus()};
61
+ } else if (msg && msg.type === 'ingest-recording') {
62
+ mkdirSync(dirname(recordingFile), {recursive: true});
63
+ writeFileSync(recordingFile, `${JSON.stringify(msg.recording ?? null, null, 4)}\n`, 'utf8');
64
+ resp = {ok: true};
60
65
  } else {
61
66
  resp = {ok: false, error: 'unknown message'};
62
67
  }
@@ -95,11 +95,48 @@
95
95
  color: #1a7f37;
96
96
  font-size: 12px;
97
97
  }
98
+ .tabs {
99
+ display: flex;
100
+ gap: 6px;
101
+ margin-bottom: 8px;
102
+ }
103
+ .tab {
104
+ flex: 1;
105
+ padding: 6px 0;
106
+ border: 1px solid #d0d7de;
107
+ border-radius: 6px;
108
+ background: #f6f8fa;
109
+ cursor: pointer;
110
+ font: inherit;
111
+ }
112
+ .tab.active {
113
+ background: #1f6feb;
114
+ color: #fff;
115
+ border-color: #1f6feb;
116
+ }
98
117
  </style>
99
118
  </head>
100
119
  <body>
101
120
  <h1>ui-inspector 配置</h1>
102
121
 
122
+ <!-- 增强包模式区:无增强包时保持空;popup.js 读 enhancements.json 后动态填充 tab 与面板。 -->
123
+ <div
124
+ id="mode-bar"
125
+ style="display: none"
126
+ >
127
+ <div
128
+ class="tabs"
129
+ id="mode-tabs"
130
+ ></div>
131
+ <section
132
+ id="pane-select"
133
+ class="mode-pane"
134
+ >
135
+ <div class="hint">按住 Alt 点选页面元素(可多选),回 Comate 写修改意见即可。</div>
136
+ </section>
137
+ <div id="mode-panes"></div>
138
+ </div>
139
+
103
140
  <section>
104
141
  <div class="label">生效域名</div>
105
142
  <div class="row">
@@ -138,3 +138,76 @@ $('domain-input').addEventListener('keydown', e => {
138
138
 
139
139
  renderDomains();
140
140
  renderTransport();
141
+
142
+ // ---------- 增强包:通用加载器 ----------
143
+ // 读 extension/enhancements.json(由 `ui-inspector add <名>` 拷贝插件并写入)。
144
+ // 每个插件描述:{id, name, pane, paneJs}。有插件时显示「模式」tab 区:
145
+ // 「选择 Element」固定第一项 + 每个插件一项;点 tab 切换对应面板。
146
+ // 面板 HTML 片段从 enhancements/<id>/<pane> 拉取内嵌,随后加载 enhancements/<id>/<paneJs>。
147
+ async function initEnhancements() {
148
+ let plugins = [];
149
+ try {
150
+ const r = await fetch(chrome.runtime.getURL('enhancements.json'));
151
+ if (r.ok) {
152
+ const j = await r.json().catch(() => []);
153
+ if (Array.isArray(j)) {
154
+ plugins = j.filter(p => p && p.id);
155
+ }
156
+ }
157
+ } catch {
158
+ plugins = [];
159
+ }
160
+ if (!plugins.length) {
161
+ return; // 基础版:不显示模式 tab
162
+ }
163
+
164
+ const tabsEl = $('mode-tabs');
165
+ const panesEl = $('mode-panes');
166
+ const paneEls = {select: $('pane-select')};
167
+
168
+ function makeTab(key, label, active) {
169
+ const b = document.createElement('button');
170
+ b.className = active ? 'tab active' : 'tab';
171
+ b.textContent = label;
172
+ b.onclick = () => switchMode(key);
173
+ tabsEl.appendChild(b);
174
+ b.dataset.mode = key;
175
+ return b;
176
+ }
177
+
178
+ function switchMode(key) {
179
+ for (const b of tabsEl.querySelectorAll('.tab')) {
180
+ b.classList.toggle('active', b.dataset.mode === key);
181
+ }
182
+ for (const [k, el] of Object.entries(paneEls)) {
183
+ el.style.display = k === key ? 'block' : 'none';
184
+ }
185
+ }
186
+
187
+ makeTab('select', '选择 Element', true);
188
+
189
+ for (const p of plugins) {
190
+ const pane = document.createElement('section');
191
+ pane.className = 'mode-pane';
192
+ pane.style.display = 'none';
193
+ if (p.pane) {
194
+ try {
195
+ const html = await fetch(chrome.runtime.getURL(`enhancements/${p.id}/${p.pane}`)).then(x => x.text());
196
+ pane.innerHTML = html;
197
+ } catch {
198
+ pane.textContent = `加载 ${p.id} 面板失败`;
199
+ }
200
+ }
201
+ panesEl.appendChild(pane);
202
+ paneEls[p.id] = pane;
203
+ makeTab(p.id, p.name || p.id, false);
204
+ if (p.paneJs) {
205
+ const s = document.createElement('script');
206
+ s.src = `enhancements/${p.id}/${p.paneJs}`;
207
+ document.body.appendChild(s);
208
+ }
209
+ }
210
+
211
+ $('mode-bar').style.display = 'block';
212
+ }
213
+ initEnhancements();
@@ -14,23 +14,33 @@ import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
14
  import {dirname, resolve} from 'node:path';
15
15
  import {homedir} from 'node:os';
16
16
 
17
- // 共享落盘:读用户主目录下的 pending(与 host / mcp 同一路径),一份代码可服务多个仓库。
17
+ // 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
18
18
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
19
+ const recordingFile = resolve(homedir(), '.ui-inspector/recording.json');
19
20
 
20
21
  function emit(output) {
21
22
  process.stdout.write(JSON.stringify(output));
22
23
  process.exit(0);
23
24
  }
24
25
 
25
- let list = [];
26
- try {
27
- const parsed = JSON.parse(readFileSync(storeFile, 'utf8'));
28
- list = Array.isArray(parsed) ? parsed : [];
29
- } catch {
30
- emit({decision: 'allow'});
26
+ function readJson(file, fallback) {
27
+ try {
28
+ return JSON.parse(readFileSync(file, 'utf8'));
29
+ } catch {
30
+ return fallback;
31
+ }
31
32
  }
32
33
 
33
- if (!list.length) {
34
+ const parsed = readJson(storeFile, []);
35
+ const list = Array.isArray(parsed) ? parsed : [];
36
+ const recording = readJson(recordingFile, null);
37
+ const hasRec =
38
+ recording &&
39
+ ((recording.clicks && recording.clicks.length) ||
40
+ (recording.console && recording.console.length) ||
41
+ (recording.network && recording.network.length));
42
+
43
+ if (!list.length && !hasRec) {
34
44
  emit({decision: 'allow'});
35
45
  }
36
46
 
@@ -47,20 +57,54 @@ function describe(target, index, total) {
47
57
  .join('\n');
48
58
  }
49
59
 
50
- const total = list.length;
51
- const body = list.map((entry, index) => describe(entry?.target ?? {}, index, total)).join('\n');
52
- const additionalContext = [
53
- `【ui-inspector】用户在页面上选中了 ${total} 个元素,接下来这条消息是针对它们的修改意见:`,
54
- body,
55
- '请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。',
56
- ].join('\n');
60
+ function describeRecording(r) {
61
+ const secs = Math.round(((r.endedAt || 0) - (r.startedAt || 0) || 0) / 1000);
62
+ const parts = [`【ui-inspector 录制】用户录制了一段操作(页面 ${r.pageUrl || ''},约 ${secs}s):`];
63
+ if (r.clicks && r.clicks.length) {
64
+ parts.push(`· 点击 ${r.clicks.length} 次(由先到后):`);
65
+ r.clicks.slice(0, 50).forEach((c, i) => {
66
+ const files = (c.componentChain || []).map(x => x && x.file).filter(Boolean);
67
+ parts.push(
68
+ ` ${i + 1}. <${c.tag}> ${JSON.stringify((c.text || '').slice(0, 40))} @ ${c.selector}` +
69
+ (files.length ? ` | 源码 ${files.join(' <- ')}` : '')
70
+ );
71
+ });
72
+ }
73
+ if (r.console && r.console.length) {
74
+ parts.push(`· 控制台 ${r.console.length} 条:`);
75
+ r.console.slice(0, 50).forEach(l => parts.push(` [${l.level}] ${String(l.text).slice(0, 300)}`));
76
+ }
77
+ if (r.network && r.network.length) {
78
+ parts.push(`· 同域网络 ${r.network.length} 条:`);
79
+ r.network.slice(0, 50).forEach(n => parts.push(` ${n.method} ${n.url} → ${n.status} (${n.ms}ms)`));
80
+ }
81
+ return parts.join('\n');
82
+ }
83
+
84
+ const sections = [];
85
+ if (list.length) {
86
+ const total = list.length;
87
+ sections.push(
88
+ [
89
+ `【ui-inspector】用户在页面上选中了 ${total} 个元素,接下来这条消息是针对它们的修改意见:`,
90
+ list.map((entry, index) => describe(entry?.target ?? {}, index, total)).join('\n'),
91
+ ].join('\n')
92
+ );
93
+ }
94
+ if (hasRec) {
95
+ sections.push(describeRecording(recording));
96
+ }
97
+ sections.push(
98
+ '请据此定位源码并按用户意见修改。页面选中态已自动取消;改动过程中可调用 MCP 工具 notify_browser 向页面推送状态(如「修改中…」「已完成」),会在浏览器弹 toast。'
99
+ );
57
100
 
58
- // 「拿到即取消选中」:注入后清空 pending,页面轮询到 count=0 即撤掉高亮
101
+ // 「拿到即消费」:注入后清空 pending 与 recording
59
102
  try {
60
103
  mkdirSync(dirname(storeFile), {recursive: true});
61
104
  writeFileSync(storeFile, '[]\n', 'utf8');
105
+ writeFileSync(recordingFile, 'null\n', 'utf8');
62
106
  } catch {
63
107
  // 清空失败不影响注入
64
108
  }
65
109
 
66
- emit({decision: 'allow', additionalContext});
110
+ emit({decision: 'allow', additionalContext: sections.join('\n\n')});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.4-alpha.2",
3
+ "version": "0.1.5-alpha.1",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "cli.mjs",
12
12
  "extension",
13
+ "enhancements",
13
14
  "mcp",
14
15
  "bridge",
15
16
  "hooks",