vcorp-chrome 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +547 -547
- package/docs/Install_snapshot.png +0 -0
- package/docs/Intro_snapshot.png +0 -0
- package/docs//344/272/221/347/253/257/345/217/221/345/270/203.md +48 -32
- package/extension/cloud-policy.js +387 -286
- package/extension/cloud.js +950 -813
- package/extension/content.js +27 -1
- package/extension/folders.js +247 -0
- package/extension/manifest.json +38 -38
- package/extension/mount.html +61 -0
- package/extension/mount.js +132 -0
- package/extension/net-hook.js +39 -10
- package/extension/panel.html +206 -197
- package/extension/panel.js +423 -382
- package/extension/welcome.html +99 -99
- package/package.json +44 -44
package/extension/panel.js
CHANGED
|
@@ -1,382 +1,423 @@
|
|
|
1
|
-
// 侧栏 —— agent 在这个窗口里做什么、连没连上、开关在哪,全在这一处。
|
|
2
|
-
//
|
|
3
|
-
// 2026-10-13 起它接替了两样东西:原 popup(连接状态、开关、会话列表)和页内
|
|
4
|
-
// 驾驶舱(准备做 / 正在做 / 时间线)。驾驶舱搬出页面是因为它常驻右下角,
|
|
5
|
-
// 正好压住聊天类页面的发送键(花叔收到的反馈,豆包实测复现)。
|
|
6
|
-
//
|
|
7
|
-
// 数据源:background 每条命令后把该 tab 的归属与时间线写进 storage.session
|
|
8
|
-
// (panel:<tabId>),这里只订阅、只渲染。所有外部文字(agent 声明的意图、
|
|
9
|
-
// 网页标题、client 名)一律 textContent——它们都可能源自被注入的页面。
|
|
10
|
-
|
|
11
|
-
import { compat, compatNote, cmpVersion } from './compat.js';
|
|
12
|
-
|
|
13
|
-
const $ = (id) => document.getElementById(id);
|
|
14
|
-
const el = (tag, cls, text) => {
|
|
15
|
-
const e = document.createElement(tag);
|
|
16
|
-
if (cls) e.className = cls;
|
|
17
|
-
if (text != null) e.textContent = text;
|
|
18
|
-
return e;
|
|
19
|
-
};
|
|
20
|
-
const send = (msg) => chrome.runtime.sendMessage(msg).catch(() => null);
|
|
21
|
-
const MINE = chrome.runtime.getManifest().version;
|
|
22
|
-
const ACT_MS = 5000; // 「正在做」只亮 5 秒——停手了还亮着就是在骗人
|
|
23
|
-
|
|
24
|
-
$('ver').textContent = `v${MINE}`;
|
|
25
|
-
|
|
26
|
-
const relTime = (t) => {
|
|
27
|
-
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
28
|
-
if (s < 5) return '刚刚';
|
|
29
|
-
if (s < 60) return `${s}秒前`;
|
|
30
|
-
if (s < 3600) return `${Math.floor(s / 60)}分前`;
|
|
31
|
-
return `${Math.floor(s / 3600)}时前`;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
let winId = null, tabId = null, actTimer = null;
|
|
35
|
-
|
|
36
|
-
// ---------- 这一页 ----------
|
|
37
|
-
|
|
38
|
-
async function renderPage() {
|
|
39
|
-
const box = $('page');
|
|
40
|
-
if (winId == null) winId = (await chrome.windows.getCurrent()).id;
|
|
41
|
-
const [tab] = await chrome.tabs.query({ active: true, windowId: winId });
|
|
42
|
-
tabId = tab?.id ?? null;
|
|
43
|
-
const key = `panel:${tabId}`;
|
|
44
|
-
const got = tabId == null ? {} : await chrome.storage.session.get([key, 'liveSids']);
|
|
45
|
-
const lives = new Set(got.liveSids || []);
|
|
46
|
-
const snap = got[key];
|
|
47
|
-
// 会话结束后快照可能还没来得及摘(页面标记关着时没人去刷新它)——以在线名单为准
|
|
48
|
-
const owners = (snap?.owners || []).filter((o) => lives.has(o.sid));
|
|
49
|
-
|
|
50
|
-
box.textContent = '';
|
|
51
|
-
if (!owners.length) {
|
|
52
|
-
box.appendChild(el('div', 'empty', '这一页没有 agent 在操作。下面列着它们各自在哪一页,点一下就能切过去。'));
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
for (const o of owners) {
|
|
57
|
-
const wrap = el('div');
|
|
58
|
-
wrap.style.setProperty('--c', o.color);
|
|
59
|
-
const head = el('div', 'owner');
|
|
60
|
-
const sw = el('span', 'sw'); sw.style.background = o.color;
|
|
61
|
-
head.append(sw, el('span', 'who', o.label), el('span', 'code', o.code));
|
|
62
|
-
wrap.appendChild(head);
|
|
63
|
-
|
|
64
|
-
if (snap.tabLabel) {
|
|
65
|
-
const b = el('div', 'blk');
|
|
66
|
-
b.append(el('div', 'lab', '本页'), el('div', null, snap.tabLabel));
|
|
67
|
-
wrap.appendChild(b);
|
|
68
|
-
}
|
|
69
|
-
const intent = snap.intents?.[o.sid];
|
|
70
|
-
if (intent?.text) {
|
|
71
|
-
const b = el('div', 'blk');
|
|
72
|
-
const q = el('div', 'intent', intent.text);
|
|
73
|
-
q.appendChild(el('span', 'ago', relTime(intent.t)));
|
|
74
|
-
b.append(el('div', 'lab', '准备做'), q);
|
|
75
|
-
wrap.appendChild(b);
|
|
76
|
-
}
|
|
77
|
-
if (snap.plan?.length) {
|
|
78
|
-
const p = snap.plan;
|
|
79
|
-
const b = el('div', 'blk');
|
|
80
|
-
b.append(el('div', 'lab', '接下来'),
|
|
81
|
-
el('div', null, p.slice(0, 4).join(' → ') + (p.length > 4 ? ` →(还有 ${p.length - 4} 步)` : '')));
|
|
82
|
-
wrap.appendChild(b);
|
|
83
|
-
}
|
|
84
|
-
if (snap.act && snap.sid === o.sid && Date.now() - snap.actAt < ACT_MS) {
|
|
85
|
-
const b = el('div', 'blk');
|
|
86
|
-
const n = el('div', 'now');
|
|
87
|
-
const pl = el('span', 'pulse'); pl.style.background = o.color;
|
|
88
|
-
n.append(pl, el('span', null, snap.act));
|
|
89
|
-
b.append(el('div', 'lab', '正在做'), n);
|
|
90
|
-
wrap.appendChild(b);
|
|
91
|
-
clearTimeout(actTimer);
|
|
92
|
-
actTimer = setTimeout(renderPage, ACT_MS - (Date.now() - snap.actAt) + 50);
|
|
93
|
-
}
|
|
94
|
-
const rows = snap.logs?.[o.sid] || [];
|
|
95
|
-
if (rows.length) {
|
|
96
|
-
const b = el('div', 'blk');
|
|
97
|
-
const tl = el('div', 'tl');
|
|
98
|
-
for (const r of rows) {
|
|
99
|
-
const row = el('div', 'row');
|
|
100
|
-
row.append(el('span', 'ago', relTime(r.t)), el('span', null, r.text));
|
|
101
|
-
tl.appendChild(row);
|
|
102
|
-
}
|
|
103
|
-
b.append(el('div', 'lab', '时间线'), tl);
|
|
104
|
-
wrap.appendChild(b);
|
|
105
|
-
}
|
|
106
|
-
box.appendChild(wrap);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ---------- 所有会话 ----------
|
|
111
|
-
|
|
112
|
-
async function renderSessions() {
|
|
113
|
-
const r = await send({ __hcPopup: 'sessions' });
|
|
114
|
-
$('mark').checked = r?.enabled !== false;
|
|
115
|
-
const box = $('sess');
|
|
116
|
-
box.textContent = '';
|
|
117
|
-
const rows = r?.sessions || [];
|
|
118
|
-
if (!rows.length) {
|
|
119
|
-
box.appendChild(el('div', 'empty', '还没有 agent 在线。在 Claude Code、Codex 这类工具里让它用 vcorp-chrome 做件事,这里就会出现。'));
|
|
120
|
-
return rows;
|
|
121
|
-
}
|
|
122
|
-
for (const s of rows) {
|
|
123
|
-
const b = el('button');
|
|
124
|
-
const sw = el('span', 'dot'); sw.style.background = s.color;
|
|
125
|
-
b.append(sw, el('span', 'who', s.label), el('span', 'page', s.title ? `· ${s.title}` : '· 还没打开页面'));
|
|
126
|
-
if (s.tabId) b.onclick = () => send({ __hcPopup: 'focusTab', tabId: s.tabId });
|
|
127
|
-
else b.disabled = true;
|
|
128
|
-
box.appendChild(b);
|
|
129
|
-
}
|
|
130
|
-
return rows;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ---------- 连接:三盏灯 ----------
|
|
134
|
-
//
|
|
135
|
-
// 用户以前只看得到一盏灯,分不清是扩展断了、本地端没起、还是根本没有 agent 在跑。
|
|
136
|
-
// 每盏红灯旁边直接给能复制的修法。
|
|
137
|
-
|
|
138
|
-
function light(state, name, sub, cmd) {
|
|
139
|
-
const row = el('div', 'light');
|
|
140
|
-
const d = el('span', `dot ${state}`);
|
|
141
|
-
const txt = el('div');
|
|
142
|
-
txt.appendChild(el('div', 'name', name));
|
|
143
|
-
if (sub) txt.appendChild(el('div', 'sub', sub));
|
|
144
|
-
if (cmd) {
|
|
145
|
-
const c = el('div', 'cmd');
|
|
146
|
-
const code = el('code', null, cmd);
|
|
147
|
-
const btn = el('button', 'btn ghost', '复制');
|
|
148
|
-
btn.onclick = async () => {
|
|
149
|
-
try { await navigator.clipboard.writeText(cmd); btn.textContent = '已复制'; } catch { btn.textContent = '请手动复制'; }
|
|
150
|
-
setTimeout(() => { btn.textContent = '复制'; }, 1600);
|
|
151
|
-
};
|
|
152
|
-
c.append(code, btn);
|
|
153
|
-
txt.appendChild(c);
|
|
154
|
-
}
|
|
155
|
-
row.append(d, txt);
|
|
156
|
-
return row;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
async function renderConn(sessionCount) {
|
|
160
|
-
const r = (await send({ __hcPopup: 'status' })) || {};
|
|
161
|
-
const box = $('lights');
|
|
162
|
-
box.textContent = '';
|
|
163
|
-
box.appendChild(light('ok', `浏览器扩展 v${MINE}`, '已装好,正在运行'));
|
|
164
|
-
|
|
165
|
-
let pill = ['bad', '未连接'];
|
|
166
|
-
if (r.connected) {
|
|
167
|
-
const level = compat({ ext: MINE, cli: r.bridge });
|
|
168
|
-
const note = compatNote(level, { ext: MINE, cli: r.bridge });
|
|
169
|
-
const needCli = level === 'cli-old' || (level === 'protocol' && cmpVersion(MINE, r.bridge) > 0);
|
|
170
|
-
box.appendChild(light(note ? 'warn' : 'ok', `本地端 v${r.bridge || '?'}`, note || '已连接', needCli ? 'npx -y vcorp-chrome@latest install' : null));
|
|
171
|
-
pill = note ? ['warn', '已连接(有更新)'] : ['ok', '已连接'];
|
|
172
|
-
} else {
|
|
173
|
-
const never = !r.lastRx;
|
|
174
|
-
box.appendChild(light('bad', '本地端未连接',
|
|
175
|
-
never ? '还没装本地端,或者装了但还没有 agent 用过它(本地端由 agent 第一次调用时拉起)。在终端里跑这一行,它会自动配好你电脑上的 agent:'
|
|
176
|
-
: `上次收到心跳是 ${relTime(r.lastRx)}。先点下面的「重连」;不行再在终端跑:`,
|
|
177
|
-
never ? 'npx -y vcorp-chrome@latest install' : 'npx vcorp-chrome doctor'));
|
|
178
|
-
if (r.offscreenError) box.appendChild(light('warn', '后台文档建不起来', r.offscreenError));
|
|
179
|
-
}
|
|
180
|
-
$('reconnRow').hidden = !!r.connected;
|
|
181
|
-
|
|
182
|
-
box.appendChild(sessionCount
|
|
183
|
-
? light('ok', `${sessionCount} 个 agent 在线`, '每个 agent 用自己的颜色,页面描边和标签组同色')
|
|
184
|
-
: light('', '暂无 agent 在线', '在你的 agent 里说一句「用 vcorp-chrome 打开……」就会出现在这里'));
|
|
185
|
-
|
|
186
|
-
$('pilldot').className = `dot ${pill[0]}`;
|
|
187
|
-
$('pilltxt').textContent = pill[1];
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function refreshAll() {
|
|
191
|
-
const rows = await renderSessions();
|
|
192
|
-
await renderConn(rows.length);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
$('reconn').onclick = async () => {
|
|
196
|
-
$('reconn').disabled = true;
|
|
197
|
-
$('reconn').textContent = '连接中…';
|
|
198
|
-
await send({ __hcPopup: 'connect' });
|
|
199
|
-
$('reconn').disabled = false;
|
|
200
|
-
$('reconn').textContent = '重连';
|
|
201
|
-
refreshAll();
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
// ---------- 设置 ----------
|
|
205
|
-
|
|
206
|
-
$('guide').onclick = (e) => {
|
|
207
|
-
e.preventDefault();
|
|
208
|
-
chrome.tabs.create({ url: chrome.runtime.getURL('welcome.html') });
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
chrome.storage.local.get('l2Disabled', ({ l2Disabled }) => { $('l2').checked = !l2Disabled; });
|
|
212
|
-
|
|
213
|
-
$('l2').onchange = async (e) => {
|
|
214
|
-
if (!e.target.checked) {
|
|
215
|
-
// 关之前先把还挂着的调试会话断干净,否则开关关了、黄条还留在标签页上
|
|
216
|
-
await send({ __hcPopup: 'detachAll' });
|
|
217
|
-
await chrome.storage.local.set({ l2Disabled: true });
|
|
218
|
-
} else {
|
|
219
|
-
await chrome.storage.local.set({ l2Disabled: false });
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
$('mark').onchange = async (e) => {
|
|
224
|
-
await chrome.storage.local.set({ markDisabled: !e.target.checked });
|
|
225
|
-
// 先落盘再让 background 去贴/摘——它读的就是这个开关
|
|
226
|
-
await send({ __hcPopup: 'markSync' });
|
|
227
|
-
refreshAll();
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
// ---------- 云端发布 ----------
|
|
231
|
-
//
|
|
232
|
-
// 状态全在 background(cloud.js),这里只发操作、只渲染。服务器给的文字
|
|
233
|
-
// (账号名、任务标题)一律 textContent——服务器被攻破时它们就是攻击者写的。
|
|
234
|
-
|
|
235
|
-
const cloudCall = async (op, extra = {}) => {
|
|
236
|
-
const r = await send({ __hcCloud: op, ...extra });
|
|
237
|
-
$('cloudErr').hidden = !!r?.ok;
|
|
238
|
-
if (!r?.ok) $('cloudErr').textContent = r?.error || '扩展没有响应,稍后再试';
|
|
239
|
-
if (r?.state) renderCloud(r.state);
|
|
240
|
-
return r;
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
const CLOUD_STATE = {
|
|
244
|
-
online: ['ok', '已连接'],
|
|
245
|
-
connecting: ['warn', '连接中'],
|
|
246
|
-
paused: ['warn', '已暂停'],
|
|
247
|
-
error: ['bad', '连接失败'],
|
|
248
|
-
off: ['', '未连接'],
|
|
249
|
-
};
|
|
250
|
-
const LOG_TEXT = {
|
|
251
|
-
paired: '配对', unpaired: '解除配对', approved: '批准任务', denied: '拒绝任务', ended: '任务结束',
|
|
252
|
-
sites_on: '允许了新网站', sites_off: '拒绝了新网站', sites_timeout: '新网站没回应',
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
$('
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
box.
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
1
|
+
// 侧栏 —— agent 在这个窗口里做什么、连没连上、开关在哪,全在这一处。
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-13 起它接替了两样东西:原 popup(连接状态、开关、会话列表)和页内
|
|
4
|
+
// 驾驶舱(准备做 / 正在做 / 时间线)。驾驶舱搬出页面是因为它常驻右下角,
|
|
5
|
+
// 正好压住聊天类页面的发送键(花叔收到的反馈,豆包实测复现)。
|
|
6
|
+
//
|
|
7
|
+
// 数据源:background 每条命令后把该 tab 的归属与时间线写进 storage.session
|
|
8
|
+
// (panel:<tabId>),这里只订阅、只渲染。所有外部文字(agent 声明的意图、
|
|
9
|
+
// 网页标题、client 名)一律 textContent——它们都可能源自被注入的页面。
|
|
10
|
+
|
|
11
|
+
import { compat, compatNote, cmpVersion } from './compat.js';
|
|
12
|
+
|
|
13
|
+
const $ = (id) => document.getElementById(id);
|
|
14
|
+
const el = (tag, cls, text) => {
|
|
15
|
+
const e = document.createElement(tag);
|
|
16
|
+
if (cls) e.className = cls;
|
|
17
|
+
if (text != null) e.textContent = text;
|
|
18
|
+
return e;
|
|
19
|
+
};
|
|
20
|
+
const send = (msg) => chrome.runtime.sendMessage(msg).catch(() => null);
|
|
21
|
+
const MINE = chrome.runtime.getManifest().version;
|
|
22
|
+
const ACT_MS = 5000; // 「正在做」只亮 5 秒——停手了还亮着就是在骗人
|
|
23
|
+
|
|
24
|
+
$('ver').textContent = `v${MINE}`;
|
|
25
|
+
|
|
26
|
+
const relTime = (t) => {
|
|
27
|
+
const s = Math.max(0, Math.round((Date.now() - t) / 1000));
|
|
28
|
+
if (s < 5) return '刚刚';
|
|
29
|
+
if (s < 60) return `${s}秒前`;
|
|
30
|
+
if (s < 3600) return `${Math.floor(s / 60)}分前`;
|
|
31
|
+
return `${Math.floor(s / 3600)}时前`;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let winId = null, tabId = null, actTimer = null;
|
|
35
|
+
|
|
36
|
+
// ---------- 这一页 ----------
|
|
37
|
+
|
|
38
|
+
async function renderPage() {
|
|
39
|
+
const box = $('page');
|
|
40
|
+
if (winId == null) winId = (await chrome.windows.getCurrent()).id;
|
|
41
|
+
const [tab] = await chrome.tabs.query({ active: true, windowId: winId });
|
|
42
|
+
tabId = tab?.id ?? null;
|
|
43
|
+
const key = `panel:${tabId}`;
|
|
44
|
+
const got = tabId == null ? {} : await chrome.storage.session.get([key, 'liveSids']);
|
|
45
|
+
const lives = new Set(got.liveSids || []);
|
|
46
|
+
const snap = got[key];
|
|
47
|
+
// 会话结束后快照可能还没来得及摘(页面标记关着时没人去刷新它)——以在线名单为准
|
|
48
|
+
const owners = (snap?.owners || []).filter((o) => lives.has(o.sid));
|
|
49
|
+
|
|
50
|
+
box.textContent = '';
|
|
51
|
+
if (!owners.length) {
|
|
52
|
+
box.appendChild(el('div', 'empty', '这一页没有 agent 在操作。下面列着它们各自在哪一页,点一下就能切过去。'));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const o of owners) {
|
|
57
|
+
const wrap = el('div');
|
|
58
|
+
wrap.style.setProperty('--c', o.color);
|
|
59
|
+
const head = el('div', 'owner');
|
|
60
|
+
const sw = el('span', 'sw'); sw.style.background = o.color;
|
|
61
|
+
head.append(sw, el('span', 'who', o.label), el('span', 'code', o.code));
|
|
62
|
+
wrap.appendChild(head);
|
|
63
|
+
|
|
64
|
+
if (snap.tabLabel) {
|
|
65
|
+
const b = el('div', 'blk');
|
|
66
|
+
b.append(el('div', 'lab', '本页'), el('div', null, snap.tabLabel));
|
|
67
|
+
wrap.appendChild(b);
|
|
68
|
+
}
|
|
69
|
+
const intent = snap.intents?.[o.sid];
|
|
70
|
+
if (intent?.text) {
|
|
71
|
+
const b = el('div', 'blk');
|
|
72
|
+
const q = el('div', 'intent', intent.text);
|
|
73
|
+
q.appendChild(el('span', 'ago', relTime(intent.t)));
|
|
74
|
+
b.append(el('div', 'lab', '准备做'), q);
|
|
75
|
+
wrap.appendChild(b);
|
|
76
|
+
}
|
|
77
|
+
if (snap.plan?.length) {
|
|
78
|
+
const p = snap.plan;
|
|
79
|
+
const b = el('div', 'blk');
|
|
80
|
+
b.append(el('div', 'lab', '接下来'),
|
|
81
|
+
el('div', null, p.slice(0, 4).join(' → ') + (p.length > 4 ? ` →(还有 ${p.length - 4} 步)` : '')));
|
|
82
|
+
wrap.appendChild(b);
|
|
83
|
+
}
|
|
84
|
+
if (snap.act && snap.sid === o.sid && Date.now() - snap.actAt < ACT_MS) {
|
|
85
|
+
const b = el('div', 'blk');
|
|
86
|
+
const n = el('div', 'now');
|
|
87
|
+
const pl = el('span', 'pulse'); pl.style.background = o.color;
|
|
88
|
+
n.append(pl, el('span', null, snap.act));
|
|
89
|
+
b.append(el('div', 'lab', '正在做'), n);
|
|
90
|
+
wrap.appendChild(b);
|
|
91
|
+
clearTimeout(actTimer);
|
|
92
|
+
actTimer = setTimeout(renderPage, ACT_MS - (Date.now() - snap.actAt) + 50);
|
|
93
|
+
}
|
|
94
|
+
const rows = snap.logs?.[o.sid] || [];
|
|
95
|
+
if (rows.length) {
|
|
96
|
+
const b = el('div', 'blk');
|
|
97
|
+
const tl = el('div', 'tl');
|
|
98
|
+
for (const r of rows) {
|
|
99
|
+
const row = el('div', 'row');
|
|
100
|
+
row.append(el('span', 'ago', relTime(r.t)), el('span', null, r.text));
|
|
101
|
+
tl.appendChild(row);
|
|
102
|
+
}
|
|
103
|
+
b.append(el('div', 'lab', '时间线'), tl);
|
|
104
|
+
wrap.appendChild(b);
|
|
105
|
+
}
|
|
106
|
+
box.appendChild(wrap);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- 所有会话 ----------
|
|
111
|
+
|
|
112
|
+
async function renderSessions() {
|
|
113
|
+
const r = await send({ __hcPopup: 'sessions' });
|
|
114
|
+
$('mark').checked = r?.enabled !== false;
|
|
115
|
+
const box = $('sess');
|
|
116
|
+
box.textContent = '';
|
|
117
|
+
const rows = r?.sessions || [];
|
|
118
|
+
if (!rows.length) {
|
|
119
|
+
box.appendChild(el('div', 'empty', '还没有 agent 在线。在 Claude Code、Codex 这类工具里让它用 vcorp-chrome 做件事,这里就会出现。'));
|
|
120
|
+
return rows;
|
|
121
|
+
}
|
|
122
|
+
for (const s of rows) {
|
|
123
|
+
const b = el('button');
|
|
124
|
+
const sw = el('span', 'dot'); sw.style.background = s.color;
|
|
125
|
+
b.append(sw, el('span', 'who', s.label), el('span', 'page', s.title ? `· ${s.title}` : '· 还没打开页面'));
|
|
126
|
+
if (s.tabId) b.onclick = () => send({ __hcPopup: 'focusTab', tabId: s.tabId });
|
|
127
|
+
else b.disabled = true;
|
|
128
|
+
box.appendChild(b);
|
|
129
|
+
}
|
|
130
|
+
return rows;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- 连接:三盏灯 ----------
|
|
134
|
+
//
|
|
135
|
+
// 用户以前只看得到一盏灯,分不清是扩展断了、本地端没起、还是根本没有 agent 在跑。
|
|
136
|
+
// 每盏红灯旁边直接给能复制的修法。
|
|
137
|
+
|
|
138
|
+
function light(state, name, sub, cmd) {
|
|
139
|
+
const row = el('div', 'light');
|
|
140
|
+
const d = el('span', `dot ${state}`);
|
|
141
|
+
const txt = el('div');
|
|
142
|
+
txt.appendChild(el('div', 'name', name));
|
|
143
|
+
if (sub) txt.appendChild(el('div', 'sub', sub));
|
|
144
|
+
if (cmd) {
|
|
145
|
+
const c = el('div', 'cmd');
|
|
146
|
+
const code = el('code', null, cmd);
|
|
147
|
+
const btn = el('button', 'btn ghost', '复制');
|
|
148
|
+
btn.onclick = async () => {
|
|
149
|
+
try { await navigator.clipboard.writeText(cmd); btn.textContent = '已复制'; } catch { btn.textContent = '请手动复制'; }
|
|
150
|
+
setTimeout(() => { btn.textContent = '复制'; }, 1600);
|
|
151
|
+
};
|
|
152
|
+
c.append(code, btn);
|
|
153
|
+
txt.appendChild(c);
|
|
154
|
+
}
|
|
155
|
+
row.append(d, txt);
|
|
156
|
+
return row;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function renderConn(sessionCount) {
|
|
160
|
+
const r = (await send({ __hcPopup: 'status' })) || {};
|
|
161
|
+
const box = $('lights');
|
|
162
|
+
box.textContent = '';
|
|
163
|
+
box.appendChild(light('ok', `浏览器扩展 v${MINE}`, '已装好,正在运行'));
|
|
164
|
+
|
|
165
|
+
let pill = ['bad', '未连接'];
|
|
166
|
+
if (r.connected) {
|
|
167
|
+
const level = compat({ ext: MINE, cli: r.bridge });
|
|
168
|
+
const note = compatNote(level, { ext: MINE, cli: r.bridge });
|
|
169
|
+
const needCli = level === 'cli-old' || (level === 'protocol' && cmpVersion(MINE, r.bridge) > 0);
|
|
170
|
+
box.appendChild(light(note ? 'warn' : 'ok', `本地端 v${r.bridge || '?'}`, note || '已连接', needCli ? 'npx -y vcorp-chrome@latest install' : null));
|
|
171
|
+
pill = note ? ['warn', '已连接(有更新)'] : ['ok', '已连接'];
|
|
172
|
+
} else {
|
|
173
|
+
const never = !r.lastRx;
|
|
174
|
+
box.appendChild(light('bad', '本地端未连接',
|
|
175
|
+
never ? '还没装本地端,或者装了但还没有 agent 用过它(本地端由 agent 第一次调用时拉起)。在终端里跑这一行,它会自动配好你电脑上的 agent:'
|
|
176
|
+
: `上次收到心跳是 ${relTime(r.lastRx)}。先点下面的「重连」;不行再在终端跑:`,
|
|
177
|
+
never ? 'npx -y vcorp-chrome@latest install' : 'npx vcorp-chrome doctor'));
|
|
178
|
+
if (r.offscreenError) box.appendChild(light('warn', '后台文档建不起来', r.offscreenError));
|
|
179
|
+
}
|
|
180
|
+
$('reconnRow').hidden = !!r.connected;
|
|
181
|
+
|
|
182
|
+
box.appendChild(sessionCount
|
|
183
|
+
? light('ok', `${sessionCount} 个 agent 在线`, '每个 agent 用自己的颜色,页面描边和标签组同色')
|
|
184
|
+
: light('', '暂无 agent 在线', '在你的 agent 里说一句「用 vcorp-chrome 打开……」就会出现在这里'));
|
|
185
|
+
|
|
186
|
+
$('pilldot').className = `dot ${pill[0]}`;
|
|
187
|
+
$('pilltxt').textContent = pill[1];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function refreshAll() {
|
|
191
|
+
const rows = await renderSessions();
|
|
192
|
+
await renderConn(rows.length);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
$('reconn').onclick = async () => {
|
|
196
|
+
$('reconn').disabled = true;
|
|
197
|
+
$('reconn').textContent = '连接中…';
|
|
198
|
+
await send({ __hcPopup: 'connect' });
|
|
199
|
+
$('reconn').disabled = false;
|
|
200
|
+
$('reconn').textContent = '重连';
|
|
201
|
+
refreshAll();
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// ---------- 设置 ----------
|
|
205
|
+
|
|
206
|
+
$('guide').onclick = (e) => {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
chrome.tabs.create({ url: chrome.runtime.getURL('welcome.html') });
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
chrome.storage.local.get('l2Disabled', ({ l2Disabled }) => { $('l2').checked = !l2Disabled; });
|
|
212
|
+
|
|
213
|
+
$('l2').onchange = async (e) => {
|
|
214
|
+
if (!e.target.checked) {
|
|
215
|
+
// 关之前先把还挂着的调试会话断干净,否则开关关了、黄条还留在标签页上
|
|
216
|
+
await send({ __hcPopup: 'detachAll' });
|
|
217
|
+
await chrome.storage.local.set({ l2Disabled: true });
|
|
218
|
+
} else {
|
|
219
|
+
await chrome.storage.local.set({ l2Disabled: false });
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
$('mark').onchange = async (e) => {
|
|
224
|
+
await chrome.storage.local.set({ markDisabled: !e.target.checked });
|
|
225
|
+
// 先落盘再让 background 去贴/摘——它读的就是这个开关
|
|
226
|
+
await send({ __hcPopup: 'markSync' });
|
|
227
|
+
refreshAll();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// ---------- 云端发布 ----------
|
|
231
|
+
//
|
|
232
|
+
// 状态全在 background(cloud.js),这里只发操作、只渲染。服务器给的文字
|
|
233
|
+
// (账号名、任务标题)一律 textContent——服务器被攻破时它们就是攻击者写的。
|
|
234
|
+
|
|
235
|
+
const cloudCall = async (op, extra = {}) => {
|
|
236
|
+
const r = await send({ __hcCloud: op, ...extra });
|
|
237
|
+
$('cloudErr').hidden = !!r?.ok;
|
|
238
|
+
if (!r?.ok) $('cloudErr').textContent = r?.error || '扩展没有响应,稍后再试';
|
|
239
|
+
if (r?.state) renderCloud(r.state);
|
|
240
|
+
return r;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const CLOUD_STATE = {
|
|
244
|
+
online: ['ok', '已连接'],
|
|
245
|
+
connecting: ['warn', '连接中'],
|
|
246
|
+
paused: ['warn', '已暂停'],
|
|
247
|
+
error: ['bad', '连接失败'],
|
|
248
|
+
off: ['', '未连接'],
|
|
249
|
+
};
|
|
250
|
+
const LOG_TEXT = {
|
|
251
|
+
paired: '配对', unpaired: '解除配对', approved: '批准任务', denied: '拒绝任务', ended: '任务结束',
|
|
252
|
+
sites_on: '允许了新网站', sites_off: '拒绝了新网站', sites_timeout: '新网站没回应',
|
|
253
|
+
folders_on: '允许了本地文件夹', folders_off: '拒绝了本地文件夹', folders_timeout: '本地文件夹没回应',
|
|
254
|
+
site_on: '网站加入允许名单', site_off: '从允许名单移除网站', auto_on: '打开自动允许', auto_off: '关闭自动允许', paused: '暂停', resumed: '恢复',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const PERM_WORD = { granted: '可读', prompt: '需要恢复访问', denied: '已拒绝', unknown: '打不开' };
|
|
258
|
+
|
|
259
|
+
function renderFolders(s) {
|
|
260
|
+
const box = $('folderList');
|
|
261
|
+
if (!box) return;
|
|
262
|
+
box.textContent = '';
|
|
263
|
+
const rows = s.folders || [];
|
|
264
|
+
if (!rows.length) {
|
|
265
|
+
box.appendChild(el('div', 'empty', '还没有。添加后,任务要读的时候会再问你一次。'));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (const f of rows) {
|
|
269
|
+
const row = el('div', 'sw-row');
|
|
270
|
+
const text = el('span', 'txt');
|
|
271
|
+
text.append(el('div', null, f.name), el('div', 'sub', PERM_WORD[f.permission] || PERM_WORD.unknown));
|
|
272
|
+
row.appendChild(text);
|
|
273
|
+
if (f.permission !== 'granted') {
|
|
274
|
+
const restore = el('button', 'btn', '恢复访问');
|
|
275
|
+
restore.onclick = () => cloudCall('openMount', { folderId: f.id });
|
|
276
|
+
row.appendChild(restore);
|
|
277
|
+
}
|
|
278
|
+
const del = el('button', 'btn ghost', '移除');
|
|
279
|
+
del.onclick = () => {
|
|
280
|
+
if (confirm(`移除「${f.name}」?已经批准的任务也不能再读它。文件夹本身不会被删除。`)) cloudCall('removeFolder', { id: f.id });
|
|
281
|
+
};
|
|
282
|
+
row.appendChild(del);
|
|
283
|
+
box.appendChild(row);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderCloud(s) {
|
|
288
|
+
renderFolders(s);
|
|
289
|
+
const on = !!s.cfg;
|
|
290
|
+
$('cloudPair').hidden = on || !!s.claim;
|
|
291
|
+
$('cloudClaim').hidden = on || !s.claim;
|
|
292
|
+
$('cloudOn').hidden = !on;
|
|
293
|
+
if (!$('pairOrigin').value) $('pairOrigin').value = s.defaultOrigin;
|
|
294
|
+
if (s.claim) {
|
|
295
|
+
$('claimOrigin').textContent = new URL(s.claim.origin).host;
|
|
296
|
+
$('claimAccount').textContent = `「${s.claim.account || '(未命名)'}」`;
|
|
297
|
+
}
|
|
298
|
+
if (!on) return;
|
|
299
|
+
|
|
300
|
+
const [cls, word] = CLOUD_STATE[s.status?.state] || CLOUD_STATE.off;
|
|
301
|
+
$('cloudDot').className = `dot ${cls}`;
|
|
302
|
+
$('cloudName').textContent = `${new URL(s.cfg.origin).host} · ${word}`;
|
|
303
|
+
$('cloudSub').textContent = s.status?.state === 'error' || s.status?.state === 'paused'
|
|
304
|
+
? s.status.message : `账号「${s.cfg.account || ''}」`;
|
|
305
|
+
$('cloudAuto').checked = !!s.cfg.auto;
|
|
306
|
+
$('cloudPause').checked = !!s.cfg.paused;
|
|
307
|
+
|
|
308
|
+
// 允许名单:用户点过「允许」的网站,含子域。没有预设的平台。
|
|
309
|
+
const sites = $('cloudSites');
|
|
310
|
+
sites.textContent = '';
|
|
311
|
+
if (!s.cfg.sites.length) sites.appendChild(el('div', 'empty', '还没有。任务申请网站、你点了允许,就会出现在这里。'));
|
|
312
|
+
for (const host of s.cfg.sites) {
|
|
313
|
+
const row = el('div', 'site');
|
|
314
|
+
row.appendChild(el('span', 'host', host));
|
|
315
|
+
const del = el('button', 'btn ghost', '移除');
|
|
316
|
+
del.onclick = () => cloudCall('removeSite', { host });
|
|
317
|
+
row.appendChild(del);
|
|
318
|
+
sites.appendChild(row);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tasks = $('cloudTasks');
|
|
322
|
+
tasks.textContent = '';
|
|
323
|
+
for (const t of s.tasks.filter((x) => x.state !== 'denied')) {
|
|
324
|
+
const box = el('div', `task ${t.state}`);
|
|
325
|
+
box.appendChild(el('div', 't', t.title || `任务 ${t.jobId}`));
|
|
326
|
+
if (t.state === 'pending') {
|
|
327
|
+
const lines = [];
|
|
328
|
+
if (t.ask?.length) lines.push(`请求访问这些网站:${t.ask.join('、')}(允许后加入允许名单)`);
|
|
329
|
+
if (t.use?.length) lines.push(`要用允许名单里的:${t.use.join('、')}`);
|
|
330
|
+
if (t.purpose) lines.push(`用途:${t.purpose}`);
|
|
331
|
+
for (const line of lines) box.appendChild(el('div', 'sub', line));
|
|
332
|
+
const row = el('div', 'row');
|
|
333
|
+
const yes = el('button', 'btn', '允许');
|
|
334
|
+
const no = el('button', 'btn ghost', '拒绝');
|
|
335
|
+
yes.onclick = () => cloudCall('decide', { jobId: t.jobId, allow: true });
|
|
336
|
+
no.onclick = () => cloudCall('decide', { jobId: t.jobId, allow: false });
|
|
337
|
+
row.append(yes, no);
|
|
338
|
+
box.appendChild(row);
|
|
339
|
+
} else {
|
|
340
|
+
const where = [...new Set([...(t.use || []), ...(t.sites || [])])].join('、') || '(还没有网站)';
|
|
341
|
+
box.appendChild(el('div', 'sub', `进行中 · ${where} · ${t.count} 条命令 · ${relTime(t.lastAt)}`));
|
|
342
|
+
if (t.ask?.length) {
|
|
343
|
+
box.appendChild(el('div', 'sub', `还想访问:${t.ask.join('、')}${t.askWhy ? `。用途:${t.askWhy}` : ''}`));
|
|
344
|
+
const ask = el('div', 'row');
|
|
345
|
+
const yes = el('button', 'btn', '允许这些网站');
|
|
346
|
+
const no = el('button', 'btn ghost', '不允许');
|
|
347
|
+
yes.onclick = () => cloudCall('decideSites', { jobId: t.jobId, allow: true });
|
|
348
|
+
no.onclick = () => cloudCall('decideSites', { jobId: t.jobId, allow: false });
|
|
349
|
+
ask.append(yes, no);
|
|
350
|
+
box.appendChild(ask);
|
|
351
|
+
}
|
|
352
|
+
if (t.folders?.length) box.appendChild(el('div', 'sub', `可读本地文件夹:${t.folders.join('、')}`));
|
|
353
|
+
if (t.folderAsk?.length) {
|
|
354
|
+
box.appendChild(el('div', 'sub', `还想读取本地文件夹:${t.folderAsk.join('、')}${t.folderAskWhy ? `。用途:${t.folderAskWhy}` : ''}`));
|
|
355
|
+
const ask = el('div', 'row');
|
|
356
|
+
const yes = el('button', 'btn', '允许这些文件夹');
|
|
357
|
+
const no = el('button', 'btn ghost', '不允许');
|
|
358
|
+
yes.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: true });
|
|
359
|
+
no.onclick = () => cloudCall('decideFolders', { jobId: t.jobId, allow: false });
|
|
360
|
+
ask.append(yes, no);
|
|
361
|
+
box.appendChild(ask);
|
|
362
|
+
}
|
|
363
|
+
const row = el('div', 'row');
|
|
364
|
+
const stop = el('button', 'btn ghost', '收回(关掉它的标签页)');
|
|
365
|
+
stop.onclick = () => cloudCall('endTask', { jobId: t.jobId });
|
|
366
|
+
row.appendChild(stop);
|
|
367
|
+
box.appendChild(row);
|
|
368
|
+
}
|
|
369
|
+
tasks.appendChild(box);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const log = $('cloudLog');
|
|
373
|
+
log.textContent = '';
|
|
374
|
+
if (!s.log.length) log.appendChild(el('div', null, '还没有记录'));
|
|
375
|
+
for (const r of s.log) {
|
|
376
|
+
const row = el('div');
|
|
377
|
+
const text = r.ev === 'approved' && r.msg === '自动放行'
|
|
378
|
+
? '自动放行了任务'
|
|
379
|
+
: r.ev ? `${LOG_TEXT[r.ev] || r.ev}${r.msg ? ` ${r.msg}` : ''}` : `${r.cmd}${r.ok ? '' : ` ✗ ${r.code}${r.msg ? `:${r.msg}` : ''}`}`;
|
|
380
|
+
row.append(el('span', 'ago', relTime(r.t)), el('span', r.ok === false ? 'no' : null, `${text}${r.job ? ` · ${String(r.job).slice(-6)}` : ''}`));
|
|
381
|
+
log.appendChild(row);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
$('pairGo').onclick = async () => {
|
|
386
|
+
$('pairGo').disabled = true;
|
|
387
|
+
await cloudCall('claim', { origin: $('pairOrigin').value, code: $('pairCode').value });
|
|
388
|
+
$('pairGo').disabled = false;
|
|
389
|
+
};
|
|
390
|
+
$('pairCode').onkeydown = (e) => { if (e.key === 'Enter') $('pairGo').click(); };
|
|
391
|
+
$('claimYes').onclick = async () => {
|
|
392
|
+
$('claimYes').disabled = true;
|
|
393
|
+
await cloudCall('confirm');
|
|
394
|
+
$('claimYes').disabled = false;
|
|
395
|
+
$('pairCode').value = '';
|
|
396
|
+
};
|
|
397
|
+
$('claimNo').onclick = () => cloudCall('cancelClaim');
|
|
398
|
+
$('cloudAuto').onchange = (e) => cloudCall('setAuto', { auto: e.target.checked });
|
|
399
|
+
$('cloudPause').onchange = (e) => cloudCall('setPaused', { paused: e.target.checked });
|
|
400
|
+
$('cloudReconn').onclick = () => cloudCall('reconnect');
|
|
401
|
+
$('cloudUnpair').onclick = () => {
|
|
402
|
+
if (confirm('断开后这个服务器再也不能在本浏览器里发布,它开的标签页会被关掉。要再用得重新配对。')) cloudCall('unpair');
|
|
403
|
+
};
|
|
404
|
+
$('folderAdd').onclick = () => cloudCall('openMount');
|
|
405
|
+
|
|
406
|
+
// ---------- 订阅 ----------
|
|
407
|
+
|
|
408
|
+
chrome.storage.onChanged.addListener((changes, area) => {
|
|
409
|
+
const keys0 = Object.keys(changes);
|
|
410
|
+
if (keys0.some((k) => ['cloud', 'cloudTasks', 'cloudLog', 'cloudStatus', 'cloudClaim', 'folderTick'].includes(k))) void cloudCall('state');
|
|
411
|
+
if (area !== 'session') return;
|
|
412
|
+
const keys = Object.keys(changes);
|
|
413
|
+
if (keys.includes(`panel:${tabId}`) || keys.includes('liveSids')) renderPage();
|
|
414
|
+
if (keys.includes('liveSids') || keys.includes('bridgeConnected') || keys.includes('markedTabs')) refreshAll();
|
|
415
|
+
});
|
|
416
|
+
chrome.tabs.onActivated.addListener(({ windowId }) => { if (windowId === winId) renderPage(); });
|
|
417
|
+
chrome.tabs.onUpdated.addListener((id, info) => { if (info.title) refreshAll(); if (id === tabId && info.status === 'complete') renderPage(); });
|
|
418
|
+
|
|
419
|
+
renderPage();
|
|
420
|
+
refreshAll();
|
|
421
|
+
void cloudCall('state');
|
|
422
|
+
// 相对时间要走字,连接状态要跟上(桥的心跳不写 storage)
|
|
423
|
+
setInterval(() => { renderPage(); refreshAll(); void cloudCall('state'); }, 15000);
|