pi2web 0.2.1 → 0.2.6

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.
Files changed (51) hide show
  1. package/README.md +44 -5
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +518 -141
  6. package/dist/auth.js +3 -1
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +45 -10
  9. package/dist/collab/hub.js +1392 -384
  10. package/dist/collab/pi-extension.js +322 -65
  11. package/dist/collab/review-flow.js +197 -48
  12. package/dist/collab/routes.js +94 -23
  13. package/dist/collab/schemas.js +69 -13
  14. package/dist/collab/scoring-flow.js +145 -39
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +467 -112
  17. package/dist/collab/types.js +94 -10
  18. package/dist/collab/validate.js +62 -25
  19. package/dist/drafts.js +26 -6
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +63 -17
  28. package/dist/main.js +61 -16
  29. package/dist/mcp.js +24 -5
  30. package/dist/metadata-store.js +190 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +221 -45
  33. package/dist/server.js +444 -620
  34. package/dist/session-namer.js +66 -17
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +153 -35
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +11 -1
  43. package/packages/protocol/openapi.yaml +6 -2
  44. package/packages/protocol/websocket.schema.json +58 -15
  45. package/web/activity-view.js +121 -0
  46. package/web/app.js +3098 -743
  47. package/web/backend-state.js +59 -27
  48. package/web/collab.js +668 -255
  49. package/web/hid-keyboard.js +173 -140
  50. package/web/index.html +4 -1
  51. package/web/styles.css +14 -0
@@ -9,9 +9,9 @@
9
9
  const CHANNEL_JSON = 2;
10
10
  const REPORT_BYTES = 63;
11
11
  const PAYLOAD_BYTES = 61;
12
- const VENDOR_ID = 0x303A;
12
+ const VENDOR_ID = 0x303a;
13
13
  const PRODUCT_ID = 0x8360;
14
- const USAGE_PAGE = 0xFF00;
14
+ const USAGE_PAGE = 0xff00;
15
15
  const USAGE = 1;
16
16
  const STORAGE_KEY = 'rpSessionKeyboardBindings';
17
17
  // Adjacent slots deliberately alternate across the color wheel so all six
@@ -28,33 +28,40 @@
28
28
  // so each cycle clearly falls toward dark before returning to full brightness.
29
29
  // Waiting remains a shallow breath to read as a separate third state.
30
30
  const ACTIVITY_LIGHTS = {
31
- llm: {effect:EFFECT_BREATH, speed:0.12},
32
- tool: {effect:EFFECT_BREATH, speed:0.45},
33
- retry: {effect:EFFECT_BREATH, speed:0.8},
34
- waiting: {effect:EFFECT_SHALLOW_BREATH, speed:0.1},
31
+ llm: {effect: EFFECT_BREATH, speed: 0.12},
32
+ tool: {effect: EFFECT_BREATH, speed: 0.45},
33
+ retry: {effect: EFFECT_BREATH, speed: 0.8},
34
+ waiting: {effect: EFFECT_SHALLOW_BREATH, speed: 0.1}
35
35
  };
36
- const BUSY_LIGHT = {effect:EFFECT_BREATH, speed:0.15};
36
+ const BUSY_LIGHT = {effect: EFFECT_BREATH, speed: 0.15};
37
37
 
38
- // Keep activity in one place; the light phase is derived, never stored twice.
39
- const newActivity = () => ({turn:'idle', tools:new Set(), bash:new Set(), dialogs:new Set()});
40
- const resetTurn = (activity, turn = 'idle') => { activity.turn = turn; activity.tools.clear(); };
38
+ // The server maintains each Agent's activity (see src/activity.ts); lights only map it.
41
39
  const activityPhase = activity => {
40
+ const kind = activity?.phase?.kind;
42
41
  if (!activity) return 'idle';
43
- if (activity.dialogs.size) return 'waiting';
44
- if (activity.tools.size || activity.bash.size) return 'tool';
45
- return activity.turn;
42
+ if (activity.dialogs?.length) return 'waiting';
43
+ if (kind === 'running_tools' || activity.bash?.length) return 'tool';
44
+ if (kind === 'retrying') return 'retry';
45
+ return kind && kind !== 'idle' ? 'llm' : 'idle';
46
46
  };
47
47
 
48
48
  const normalizeColor = value => {
49
- const color = String(value || '').trim().toUpperCase();
49
+ const color = String(value || '')
50
+ .trim()
51
+ .toUpperCase();
50
52
  return /^#[0-9A-F]{6}$/.test(color) ? color : null;
51
53
  };
52
54
 
53
55
  class JsonLineAssembler {
54
- constructor() { this.reset(); }
55
- reset() { this.decoder = new TextDecoder(); this.buffer = ''; }
56
+ constructor() {
57
+ this.reset();
58
+ }
59
+ reset() {
60
+ this.decoder = new TextDecoder();
61
+ this.buffer = '';
62
+ }
56
63
  feed(data) {
57
- this.buffer += this.decoder.decode(data, {stream:true});
64
+ this.buffer += this.decoder.decode(data, {stream: true});
58
65
  const messages = [];
59
66
  let newline;
60
67
  while ((newline = this.buffer.indexOf('\n')) >= 0) {
@@ -95,11 +102,19 @@
95
102
  try {
96
103
  const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
97
104
  if (!Array.isArray(parsed)) return new Map();
98
- return new Map(parsed.flatMap(item => {
99
- const slot = Number(item?.slot), agentId = String(item?.agentId || ''), color = normalizeColor(item?.color);
100
- return Number.isInteger(slot) && slot >= 0 && slot < 6 && agentId && color ? [[slot, {agentId, color}]] : [];
101
- }));
102
- } catch { return new Map(); }
105
+ return new Map(
106
+ parsed.flatMap(item => {
107
+ const slot = Number(item?.slot),
108
+ agentId = String(item?.agentId || ''),
109
+ color = normalizeColor(item?.color);
110
+ return Number.isInteger(slot) && slot >= 0 && slot < 6 && agentId && color
111
+ ? [[slot, {agentId, color}]]
112
+ : [];
113
+ })
114
+ );
115
+ } catch {
116
+ return new Map();
117
+ }
103
118
  }
104
119
 
105
120
  saveBindings() {
@@ -108,11 +123,22 @@
108
123
  this.syncLights(true).catch(error => this.fail(error));
109
124
  }
110
125
 
111
- get connected() { return Boolean(this.device?.opened); }
112
- getBindings() { return [...this.bindings].sort(([a], [b]) => a - b).map(([slot, value]) => ({slot, ...value})); }
113
- getBinding(agentId) { return this.getBindings().find(binding => binding.agentId === agentId) || null; }
114
- getSlot(slot) { const value = this.bindings.get(Number(slot)); return value ? {slot:Number(slot), ...value} : null; }
115
- getFreeSlots() { return Array.from({length:6}, (_, slot) => slot).filter(slot => !this.bindings.has(slot)); }
126
+ get connected() {
127
+ return Boolean(this.device?.opened);
128
+ }
129
+ getBindings() {
130
+ return [...this.bindings].sort(([a], [b]) => a - b).map(([slot, value]) => ({slot, ...value}));
131
+ }
132
+ getBinding(agentId) {
133
+ return this.getBindings().find(binding => binding.agentId === agentId) || null;
134
+ }
135
+ getSlot(slot) {
136
+ const value = this.bindings.get(Number(slot));
137
+ return value ? {slot: Number(slot), ...value} : null;
138
+ }
139
+ getFreeSlots() {
140
+ return Array.from({length: 6}, (_, slot) => slot).filter(slot => !this.bindings.has(slot));
141
+ }
116
142
 
117
143
  nextColor() {
118
144
  const used = new Set(this.getBindings().map(binding => binding.color));
@@ -120,11 +146,14 @@
120
146
  }
121
147
 
122
148
  bind(agentId, slot, color = this.nextColor()) {
123
- slot = Number(slot); color = normalizeColor(color);
124
- if (!agentId || !Number.isInteger(slot) || slot < 0 || slot > 5 || !color) throw Error('无效的键盘绑定');
149
+ slot = Number(slot);
150
+ color = normalizeColor(color);
151
+ if (!agentId || !Number.isInteger(slot) || slot < 0 || slot > 5 || !color)
152
+ throw Error('无效的键盘绑定');
125
153
  const occupied = this.bindings.get(slot);
126
154
  if (occupied && occupied.agentId !== agentId) throw Error(`按键 ${slot + 1} 已被其他 Session 使用`);
127
- for (const [boundSlot, binding] of this.bindings) if (binding.agentId === agentId) this.bindings.delete(boundSlot);
155
+ for (const [boundSlot, binding] of this.bindings)
156
+ if (binding.agentId === agentId) this.bindings.delete(boundSlot);
128
157
  this.bindings.set(slot, {agentId, color});
129
158
  this.saveBindings();
130
159
  return this.getSlot(slot);
@@ -151,7 +180,7 @@
151
180
  if (!entry || !color) throw Error('无效的 Session 颜色');
152
181
  entry[1].color = color;
153
182
  this.saveBindings();
154
- return {slot:entry[0], ...entry[1]};
183
+ return {slot: entry[0], ...entry[1]};
155
184
  }
156
185
 
157
186
  setAgents(agents) {
@@ -160,104 +189,55 @@
160
189
  this.syncLights().catch(error => this.fail(error));
161
190
  }
162
191
 
192
+ /** An Agent's status, with its server activity when the update carries one. */
163
193
  updateAgent(agent) {
164
194
  if (!agent?.agentId) return;
165
195
  this.agents.set(agent.agentId, agent);
166
- if (agent.activity) {
167
- const activity = this.activities.get(agent.agentId) || newActivity();
168
- activity.bash = new Set(agent.activity.bashIds || []);
169
- activity.dialogs = new Set(agent.activity.dialogIds || []);
170
- this.activities.set(agent.agentId, activity);
171
- }
196
+ if (agent.activity) this.activities.set(agent.agentId, agent.activity);
172
197
  this.reconcileActivity(agent.agentId);
173
198
  this.syncLights().catch(error => this.fail(error));
174
199
  }
175
200
 
176
- setAgentSnapshot(agent) {
177
- // A gap invalidates old details; restore independent work from the server.
178
- // Older servers omit activity and fall back to ordinary Agent status.
179
- this.activities.delete(agent.agentId);
180
- this.updateAgent(agent);
201
+ setActivity(agentId, activity) {
202
+ this.activities.set(agentId, activity);
203
+ this.reconcileActivity(agentId);
204
+ this.syncLights().catch(error => this.fail(error));
181
205
  }
182
206
 
183
207
  reconcileActivity(agentId) {
184
- const activity = this.activities.get(agentId), status = this.agents.get(agentId)?.status;
185
- if (!activity) return;
186
- if (!status || ['unloaded', 'stopped', 'error'].includes(status)) {
208
+ const status = this.agents.get(agentId)?.status;
209
+ if (
210
+ !status ||
211
+ ['unloaded', 'stopped', 'error'].includes(status) ||
212
+ activityPhase(this.activities.get(agentId)) === 'idle'
213
+ )
187
214
  this.activities.delete(agentId);
188
- return;
189
- }
190
- // Status describes the LLM run, not independent !bash commands or dialogs.
191
- if (!BUSY_STATUSES.has(status)) resetTurn(activity);
192
- if (activityPhase(activity) === 'idle') this.activities.delete(agentId);
193
- }
194
-
195
- handleAgentEvent(agentId, event) {
196
- const activity = this.activities.get(agentId) || newActivity();
197
- const previous = activityPhase(activity);
198
- switch (event.type) {
199
- case 'agent_start':
200
- resetTurn(activity, 'llm');
201
- break;
202
- case 'message_start':
203
- if ((event.message?.role || event.role) !== 'assistant') return;
204
- activity.turn = 'llm';
205
- break;
206
- case 'message_update':
207
- activity.turn = 'llm';
208
- break;
209
- case 'tool_execution_start':
210
- case 'tool_execution_update':
211
- activity.turn = 'llm';
212
- activity.tools.add(event.toolCallId || event.toolName || 'default');
213
- break;
214
- case 'tool_execution_end':
215
- activity.tools.delete(event.toolCallId || event.toolName || 'default');
216
- break;
217
- case 'bash_execution_start':
218
- case 'bash_execution_update':
219
- activity.bash.add(event.id || 'default');
220
- break;
221
- case 'bash_execution_end':
222
- activity.bash.delete(event.id || 'default');
223
- break;
224
- case 'auto_retry_start':
225
- resetTurn(activity, 'retry');
226
- break;
227
- case 'auto_retry_end':
228
- activity.turn = event.success === false ? 'idle' : 'llm';
229
- break;
230
- case 'extension_ui_request':
231
- activity.dialogs.add(event.requestId);
232
- break;
233
- case 'extension_ui_response':
234
- activity.dialogs.delete(event.requestId);
235
- break;
236
- case 'agent_end':
237
- resetTurn(activity, event.willRetry ? 'retry' : 'idle');
238
- break;
239
- case 'agent_settled':
240
- resetTurn(activity);
241
- break;
242
- default:
243
- return;
244
- }
245
- const phase = activityPhase(activity);
246
- if (phase === 'idle') this.activities.delete(agentId); else this.activities.set(agentId, activity);
247
- if (phase !== previous) this.syncLights().catch(error => this.fail(error));
248
215
  }
249
216
 
250
- isBusy(agentId) { return BUSY_STATUSES.has(this.agents.get(agentId)?.status); }
217
+ isBusy(agentId) {
218
+ return BUSY_STATUSES.has(this.agents.get(agentId)?.status);
219
+ }
251
220
 
252
221
  collection(device) {
253
- return device.collections.find(item => item.usagePage === USAGE_PAGE && item.usage === USAGE &&
254
- item.outputReports.some(report => report.reportId === REPORT_ID) && item.inputReports.some(report => report.reportId === REPORT_ID));
222
+ return device.collections.find(
223
+ item =>
224
+ item.usagePage === USAGE_PAGE &&
225
+ item.usage === USAGE &&
226
+ item.outputReports.some(report => report.reportId === REPORT_ID) &&
227
+ item.inputReports.some(report => report.reportId === REPORT_ID)
228
+ );
255
229
  }
256
230
 
257
231
  async connect({request = true} = {}) {
258
- if (!window.isSecureContext || !navigator.hid) throw Error('WebHID 需要 Chrome/Edge,并通过 HTTPS 或 localhost 打开');
259
- let devices = (await navigator.hid.getDevices()).filter(device => device.vendorId === VENDOR_ID && device.productId === PRODUCT_ID && this.collection(device));
260
- if (!devices.length && request) devices = await navigator.hid.requestDevice({filters:[{vendorId:VENDOR_ID, productId:PRODUCT_ID, usagePage:USAGE_PAGE, usage:USAGE}]});
232
+ if (!window.isSecureContext || !navigator.hid)
233
+ throw Error('WebHID 需要 Chrome/Edge,并通过 HTTPS 或 localhost 打开');
234
+ let devices = (await navigator.hid.getDevices()).filter(
235
+ device => device.vendorId === VENDOR_ID && device.productId === PRODUCT_ID && this.collection(device)
236
+ );
237
+ if (!devices.length && request)
238
+ devices = await navigator.hid.requestDevice({
239
+ filters: [{vendorId: VENDOR_ID, productId: PRODUCT_ID, usagePage: USAGE_PAGE, usage: USAGE}]
240
+ });
261
241
  if (!devices.length) return false;
262
242
  const device = devices[0];
263
243
  // Finish the previous close and sync before reusing the same HIDDevice.
@@ -270,7 +250,8 @@
270
250
  if (this.device === device) device.removeEventListener('inputreport', this.handleInputReport);
271
251
  this.device = device;
272
252
  device.addEventListener('inputreport', this.handleInputReport);
273
- this.assembler.reset(); this.sentLights.clear();
253
+ this.assembler.reset();
254
+ this.sentLights.clear();
274
255
  await this.syncLights(true);
275
256
  if (this.device !== device || !device.opened) throw Error('键盘已断开');
276
257
  } catch (error) {
@@ -280,62 +261,97 @@
280
261
  else if (device.opened) await device.close().catch(() => undefined);
281
262
  throw error;
282
263
  }
283
- this.onStateChange?.({connected:true, name:device.productName || 'Codex Micro'});
264
+ this.onStateChange?.({connected: true, name: device.productName || 'Codex Micro'});
284
265
  return true;
285
266
  }
286
267
 
287
268
  async restoreAuthorized() {
288
269
  if (!window.isSecureContext || !navigator.hid) return false;
289
- try { return await this.connect({request:false}); }
290
- catch (error) { this.fail(error); return false; }
270
+ try {
271
+ return await this.connect({request: false});
272
+ } catch (error) {
273
+ this.fail(error);
274
+ return false;
275
+ }
291
276
  }
292
277
 
293
278
  async disconnect() {
294
279
  if (!this.device) return this.closePromise;
295
- const device = this.device; this.device = null;
280
+ const device = this.device;
281
+ this.device = null;
296
282
  device.removeEventListener('inputreport', this.handleInputReport);
297
283
  // Detach and notify before awaiting the OS: close() can fail or stall.
298
- for (const waiter of this.pending.values()) { clearTimeout(waiter.timer); waiter.reject(Error('键盘已断开')); }
299
- this.pending.clear(); this.assembler.reset(); this.sentLights.clear();
300
- this.syncRequested = false; this.forceSync = false;
284
+ for (const waiter of this.pending.values()) {
285
+ clearTimeout(waiter.timer);
286
+ waiter.reject(Error('键盘已断开'));
287
+ }
288
+ this.pending.clear();
289
+ this.assembler.reset();
290
+ this.sentLights.clear();
291
+ this.syncRequested = false;
292
+ this.forceSync = false;
301
293
  const closing = device.opened ? device.close() : Promise.resolve();
302
294
  this.closePromise = closing.catch(() => undefined);
303
- this.onStateChange?.({connected:false, name:''});
295
+ this.onStateChange?.({connected: false, name: ''});
304
296
  return closing;
305
297
  }
306
298
 
307
- handleDisconnect(event) { if (event.device === this.device) this.disconnect().catch(error => this.fail(error)); }
299
+ handleDisconnect(event) {
300
+ if (event.device === this.device) this.disconnect().catch(error => this.fail(error));
301
+ }
308
302
 
309
303
  handleInputReport(event) {
310
304
  if (event.device !== this.device || Number(event.reportId) !== REPORT_ID) return;
311
305
  try {
312
306
  const bytes = new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
313
- if (bytes.length < 2 || bytes[0] !== CHANNEL_JSON || bytes[1] > PAYLOAD_BYTES || bytes[1] > bytes.length - 2) throw Error('收到无效的键盘 HID 数据');
307
+ if (
308
+ bytes.length < 2 ||
309
+ bytes[0] !== CHANNEL_JSON ||
310
+ bytes[1] > PAYLOAD_BYTES ||
311
+ bytes[1] > bytes.length - 2
312
+ )
313
+ throw Error('收到无效的键盘 HID 数据');
314
314
  for (const message of this.assembler.feed(bytes.slice(2, 2 + bytes[1]))) this.handleMessage(message);
315
- } catch (error) { this.fail(error); }
315
+ } catch (error) {
316
+ this.fail(error);
317
+ }
316
318
  }
317
319
 
318
320
  handleMessage(message) {
319
321
  if (Object.prototype.hasOwnProperty.call(message, 'id')) {
320
322
  const waiter = this.pending.get(String(message.id));
321
- if (waiter) { this.pending.delete(String(message.id)); clearTimeout(waiter.timer); waiter.resolve(message); }
323
+ if (waiter) {
324
+ this.pending.delete(String(message.id));
325
+ clearTimeout(waiter.timer);
326
+ waiter.resolve(message);
327
+ }
322
328
  return;
323
329
  }
324
- if (message.m !== 'v.oai.hid' || !/^AG0[0-5]$/.test(message.p?.k) || Number(message.p?.act) !== 1) return;
325
- const slot = Number(message.p.k.slice(-1)), now = Date.now();
330
+ if (message.m !== 'v.oai.hid' || !/^AG0[0-5]$/.test(message.p?.k) || Number(message.p?.act) !== 1)
331
+ return;
332
+ const slot = Number(message.p.k.slice(-1)),
333
+ now = Date.now();
326
334
  if (now - (this.lastPress.get(slot) || 0) < 250) return;
327
335
  this.lastPress.set(slot, now);
328
336
  const binding = this.getSlot(slot);
329
337
  if (binding) this.onKey?.(binding, message);
330
338
  }
331
339
 
332
- nextId() { this.requestId = this.requestId >= 998 ? 0 : this.requestId + 1; return this.requestId; }
340
+ nextId() {
341
+ this.requestId = this.requestId >= 998 ? 0 : this.requestId + 1;
342
+ return this.requestId;
343
+ }
333
344
 
334
345
  reports(message) {
335
- const bytes = new TextEncoder().encode(message), reports = [];
346
+ const bytes = new TextEncoder().encode(message),
347
+ reports = [];
336
348
  for (let offset = 0; offset < bytes.length; offset += PAYLOAD_BYTES) {
337
- const fragment = bytes.slice(offset, offset + PAYLOAD_BYTES), report = new Uint8Array(REPORT_BYTES);
338
- report[0] = CHANNEL_JSON; report[1] = fragment.length; report.set(fragment, 2); reports.push(report);
349
+ const fragment = bytes.slice(offset, offset + PAYLOAD_BYTES),
350
+ report = new Uint8Array(REPORT_BYTES);
351
+ report[0] = CHANNEL_JSON;
352
+ report[1] = fragment.length;
353
+ report.set(fragment, 2);
354
+ reports.push(report);
339
355
  }
340
356
  return reports;
341
357
  }
@@ -345,7 +361,10 @@
345
361
  const job = this.queue.then(async () => {
346
362
  if (this.device !== device || !device?.opened) throw Error('键盘未连接');
347
363
  const response = new Promise((resolve, reject) => {
348
- const timer = setTimeout(() => { this.pending.delete(String(request.id)); reject(Error('键盘响应超时')); }, 5000);
364
+ const timer = setTimeout(() => {
365
+ this.pending.delete(String(request.id));
366
+ reject(Error('键盘响应超时'));
367
+ }, 5000);
349
368
  this.pending.set(String(request.id), {resolve, reject, timer});
350
369
  });
351
370
  // A timeout/disconnect can reject the reply while sendReport is still
@@ -373,17 +392,25 @@
373
392
 
374
393
  lightRequest(slot, color, effect, speed = 0) {
375
394
  const enabled = effect !== EFFECT_OFF;
376
- return {id:this.nextId(), m:'v.oai.thstatus', p:[{id:slot, c:parseInt(color.slice(1), 16), b:enabled ? 1 : 0, e:effect, s:speed, sk:0, sa:0}]};
395
+ return {
396
+ id: this.nextId(),
397
+ m: 'v.oai.thstatus',
398
+ p: [
399
+ {id: slot, c: parseInt(color.slice(1), 16), b: enabled ? 1 : 0, e: effect, s: speed, sk: 0, sa: 0}
400
+ ]
401
+ };
377
402
  }
378
403
 
379
404
  lightState(slot) {
380
- const binding = this.bindings.get(slot), active = Boolean(binding && this.agents.has(binding.agentId));
381
- if (!active) return {color:binding?.color || '#000000', effect:EFFECT_OFF, speed:0};
405
+ const binding = this.bindings.get(slot),
406
+ active = Boolean(binding && this.agents.has(binding.agentId));
407
+ if (!active) return {color: binding?.color || '#000000', effect: EFFECT_OFF, speed: 0};
382
408
  const phase = activityPhase(this.activities.get(binding.agentId));
383
409
  let animation = ACTIVITY_LIGHTS[phase];
384
- if (!animation && this.agents.get(binding.agentId)?.status === 'waiting_for_user') animation = ACTIVITY_LIGHTS.waiting;
410
+ if (!animation && this.agents.get(binding.agentId)?.status === 'waiting_for_user')
411
+ animation = ACTIVITY_LIGHTS.waiting;
385
412
  if (!animation && this.isBusy(binding.agentId)) animation = BUSY_LIGHT;
386
- return {color:binding.color, effect:animation?.effect || EFFECT_SOLID, speed:animation?.speed || 0};
413
+ return {color: binding.color, effect: animation?.effect || EFFECT_SOLID, speed: animation?.speed || 0};
387
414
  }
388
415
 
389
416
  async syncLights(force = false) {
@@ -399,21 +426,27 @@
399
426
  if (this.device !== device || !device.opened) throw Error('键盘已断开');
400
427
  do {
401
428
  this.syncRequested = false;
402
- const forcePass = this.forceSync; this.forceSync = false;
429
+ const forcePass = this.forceSync;
430
+ this.forceSync = false;
403
431
  for (let slot = 0; slot < 6; slot++) {
404
- const {color, effect, speed} = this.lightState(slot), signature = `${color}:${effect}:${speed}`;
432
+ const {color, effect, speed} = this.lightState(slot),
433
+ signature = `${color}:${effect}:${speed}`;
405
434
  if (!forcePass && this.sentLights.get(slot) === signature) continue;
406
435
  await this.transmit(this.lightRequest(slot, color, effect, speed));
407
436
  if (this.device !== device || !device.opened) throw Error('键盘已断开');
408
437
  this.sentLights.set(slot, signature);
409
438
  }
410
439
  } while (this.syncRequested && this.connected);
411
- } finally { this.syncPromise = null; }
440
+ } finally {
441
+ this.syncPromise = null;
442
+ }
412
443
  });
413
444
  return this.syncPromise;
414
445
  }
415
446
 
416
- fail(error) { this.onError?.(error instanceof Error ? error : Error(String(error))); }
447
+ fail(error) {
448
+ this.onError?.(error instanceof Error ? error : Error(String(error)));
449
+ }
417
450
  }
418
451
 
419
452
  window.SessionKeyboardController = SessionKeyboardController;
package/web/index.html CHANGED
@@ -46,6 +46,7 @@
46
46
  <button id="sessionTree" class="agent-action">Session</button>
47
47
  <button id="controls" class="agent-action">模型</button>
48
48
  <button id="mcp" class="agent-action">MCP</button>
49
+ <button id="systemPrompt" class="agent-action" title="查看此 Agent 的 System Prompt 与模板">Prompt</button>
49
50
  <button id="compact" class="agent-action">Compact</button>
50
51
  <button id="stop" class="agent-action">停止</button>
51
52
  <button id="abort" class="agent-action danger-action">Abort</button>
@@ -59,6 +60,7 @@
59
60
  <div id="widgets"></div>
60
61
  <div id="messages" aria-live="polite"><div class="empty">选择或创建 Agent 开始对话</div></div>
61
62
  <form id="prompt">
63
+ <div id="activityBar" hidden></div>
62
64
  <div id="queueBar" hidden><span id="queueText"></span><button type="button" id="queueClear" title="清空队列并把消息放回输入框">清空队列</button></div>
63
65
  <div id="mentionPicker" hidden><div class="picker-head"><button type="button" id="mentionUp">↑</button><b id="mentionPath">.</b><button type="button" id="mentionClose">×</button></div><div id="mentionItems"></div></div>
64
66
  <div id="commandPicker" hidden><div class="picker-head"><b>命令 · prompt template / skill</b><button type="button" id="commandClose">×</button></div><div id="commandItems"></div></div>
@@ -72,7 +74,7 @@
72
74
  </article>
73
75
  </main>
74
76
  <div id="contextMenu" class="context-menu" hidden><button id="menuStartAgent">启动 Agent</button><button id="menuOpenTerminal">在 Terminal 中打开</button><button id="menuSetCwd">设为 Agent 工作路径</button><button id="menuMention">在输入框中引用</button></div>
75
- <dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>Backend 连接</h3><div id="backendList"></div><div class="config-actions"><button id="connect" type="button">添加 Backend</button><button id="disconnectAll" type="button" hidden>断开全部</button></div><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>六键 Session 键盘</h3><div class="config-actions"><button id="keyboardConnect" type="button">连接键盘</button><button id="keyboardDisconnect" type="button" hidden>断开键盘</button></div><small id="keyboardStatus" class="muted">未连接 · 需要 Chrome/Edge 和 HTTPS 或 localhost</small><div id="keyboardSlots" class="keyboard-slots" aria-label="键盘按键绑定"></div></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><label class="toggle-row"><input id="mailCollabEscalations" type="checkbox" checked>协作会话需要人工裁定或停滞时立即发信</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
77
+ <dialog id="configDialog"><form method="dialog" id="configForm"><h2>Remote Pi 配置</h2><section class="config-section"><h3>Backend 连接</h3><div id="backendList"></div><div class="config-actions"><button id="connect" type="button">添加 Backend</button><button id="disconnectAll" type="button" hidden>断开全部</button></div><small id="configConnectionStatus" class="muted"></small></section><section class="config-section"><h3>六键 Session 键盘</h3><div class="config-actions"><button id="keyboardConnect" type="button">连接键盘</button><button id="keyboardDisconnect" type="button" hidden>断开键盘</button></div><small id="keyboardStatus" class="muted">未连接 · 需要 Chrome/Edge 和 HTTPS 或 localhost</small><div id="keyboardSlots" class="keyboard-slots" aria-label="键盘按键绑定"></div></section><section class="config-section"><h3>浏览器通知与语音</h3><div class="config-actions"><button id="notifications" type="button" title="启用所有 Agent 的完成通知">启用通知</button><button id="voicePlayback" type="button" title="启用 Agent 语音摘要播放" hidden>启用语音</button></div><small id="browserNotificationStatus" class="muted"></small></section><section class="config-section"><h3>System Prompt 与 Agent 模板</h3><p class="muted">查看、修改 Pi 的全局 System Prompt(<code>~/.pi/agent/SYSTEM.md</code>、<code>APPEND_SYSTEM.md</code>),并管理新建 Agent 时可选择的模板(<code>~/.pi/pi2web/agent-templates/*.md</code>)。作用于当前 Workspace 所在的 Backend。</p><div class="config-actions"><button id="openSystemPrompts" type="button">管理 System Prompt…</button></div></section><section class="config-section"><h3>邮件通知</h3><p id="mailNotificationAvailability" class="muted"></p><label class="toggle-row"><input id="mailNotificationEnabled" type="checkbox">启动邮件通知</label><label>邮件通知聚合时间(秒)<input id="mailAggregationDelay" type="number" min="0" max="86400" step="1" value="0"><small class="muted">首个任务完成后等待这段时间,期间完成的其他任务合并到同一封邮件;0 表示立即发送。</small></label><label class="toggle-row"><input id="mailIncludeResponse" type="checkbox" checked>包含 Agent 回复内容</label><label class="toggle-row"><input id="mailIncludeSessionDetails" type="checkbox" checked>包含 Session 名称和路径</label><label class="toggle-row"><input id="mailCollabEscalations" type="checkbox" checked>协作会话需要人工裁定或停滞时立即发信</label><small class="muted">如果回复内容和 Session 信息都不包含,邮件只会说明有任务完成。</small></section><div class="dialog-actions"><button id="configClose" type="button">关闭</button><button id="configSave" type="button">保存邮件设置</button></div></form></dialog>
76
78
  <dialog id="pairDialog"><form method="dialog" id="pairForm"><h2>连接 Backend</h2><p class="muted">可以添加多个 backend;连接成功后可选择是否将配对码保存到浏览器本地存储。请仅在可信设备上保存。</p><label>短名称<input id="pairName" placeholder="例如 home / lab,显示在 Session 标签上"></label><label>服务地址<input id="pairBase" required></label><label>配对码<input id="pairToken" type="password" autocomplete="off" placeholder="服务端使用 --no-auth 时留空" autofocus></label><div class="dialog-actions"><button id="pairCancel" type="button">取消</button><button id="pairSubmit" type="submit" value="default">连接</button></div></form></dialog>
77
79
  <div id="agentContextMenu" class="context-menu" hidden><button id="menuBindKeyboard">绑定键盘按键…</button><button id="menuKeyboardColor">更换按键颜色…</button><button id="menuUnbindKeyboard">取消按键绑定</button><button id="menuArchiveAgent">Archive</button><button id="menuCloseTerminal" hidden>关闭 Terminal</button></div>
78
80
  <dialog id="modal"><form method="dialog"><h2 id="modalTitle"></h2><div id="modalBody"></div><div class="dialog-actions"><button id="modalCancel" type="button">取消</button><button id="modalOk" type="submit" value="default">确定</button></div></form></dialog>
@@ -80,6 +82,7 @@
80
82
  <script src="/vendor/marked.js"></script>
81
83
  <script src="/vendor/dompurify.js"></script>
82
84
  <script src="/hid-keyboard.js"></script>
85
+ <script src="/activity-view.js"></script>
83
86
  <script src="/backend-state.js"></script>
84
87
  <script src="/app.js"></script>
85
88
  </body>
package/web/styles.css CHANGED
@@ -86,3 +86,17 @@
86
86
  .mcp-info{flex:1;min-width:0}
87
87
  .mcp-info small{display:block;margin-top:3px;white-space:pre-wrap;word-break:break-word}
88
88
  .mcp-error{color:var(--red);white-space:pre-wrap;word-break:break-word}
89
+ .template-tag{flex:0 0 auto;max-width:110px;padding:2px 7px;border:1px solid #b48ead;border-radius:999px;background:#3a3443;color:#e5d4ef;font:700 10px/1.4 system-ui,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
90
+ .prompt-text{white-space:pre-wrap;word-break:break-word;max-height:45vh;overflow:auto;margin:6px 0;padding:8px;background:#20232b;border:1px solid var(--line);border-radius:5px;font:12px/1.45 ui-monospace,monospace}
91
+ .prompt-editor{min-height:140px;max-height:45vh;resize:vertical;font:12px/1.45 ui-monospace,monospace}
92
+ .prompt-preview{margin:10px 0}.prompt-preview summary{cursor:pointer;color:var(--blue)}.prompt-preview>small{display:block;margin-top:6px;word-break:break-all}
93
+ .template-row{display:flex;align-items:center;gap:6px;padding:7px 0;border-bottom:1px solid var(--line)}.template-info{flex:1;min-width:0}.template-info b,.template-info small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.template-row button{padding:4px 8px}
94
+ .template-editor{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:6px;background:#262a33}
95
+ #activityBar{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;margin-bottom:8px;padding:6px 10px;font-size:12px;background:#2f3542;border:1px solid var(--line);border-left:3px solid var(--yellow);border-radius:6px}
96
+ #activityBar[hidden],.agent-activity[hidden]{display:none}
97
+ .activity-chip{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%}
98
+ .activity-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
99
+ .elapsed{flex:0 0 auto;font-variant-numeric:tabular-nums;color:var(--yellow)}
100
+ .activity-run{margin-left:auto;color:var(--muted);white-space:nowrap}
101
+ .agent-activity{display:flex;align-items:center;gap:6px;margin-top:5px;font-size:11px;color:#c9d1de}
102
+ .agent-activity .activity-chip{flex:1;min-width:0}