pi2web 0.2.1 → 0.2.8
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 +53 -6
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +671 -152
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +222 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +47 -0
- package/dist/http/web-assets.js +64 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +203 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +249 -45
- package/dist/server.js +488 -614
- package/dist/session-namer.js +66 -17
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +24 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3647 -756
- package/web/backend-state.js +59 -27
- package/web/collab.js +684 -257
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +7 -2
- package/web/styles.css +21 -2
- package/web/voice-pcm.js +36 -0
package/web/hid-keyboard.js
CHANGED
|
@@ -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 =
|
|
12
|
+
const VENDOR_ID = 0x303a;
|
|
13
13
|
const PRODUCT_ID = 0x8360;
|
|
14
|
-
const USAGE_PAGE =
|
|
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
|
-
//
|
|
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
|
|
44
|
-
if (
|
|
45
|
-
return
|
|
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 || '')
|
|
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() {
|
|
55
|
-
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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() {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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);
|
|
124
|
-
|
|
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)
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
this.
|
|
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
|
|
185
|
-
if (
|
|
186
|
-
|
|
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) {
|
|
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(
|
|
254
|
-
item
|
|
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)
|
|
259
|
-
|
|
260
|
-
|
|
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();
|
|
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 {
|
|
290
|
-
|
|
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;
|
|
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()) {
|
|
299
|
-
|
|
300
|
-
|
|
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) {
|
|
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 (
|
|
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) {
|
|
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) {
|
|
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)
|
|
325
|
-
|
|
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() {
|
|
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),
|
|
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),
|
|
338
|
-
|
|
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(() => {
|
|
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 {
|
|
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),
|
|
381
|
-
|
|
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')
|
|
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;
|
|
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),
|
|
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 {
|
|
440
|
+
} finally {
|
|
441
|
+
this.syncPromise = null;
|
|
442
|
+
}
|
|
412
443
|
});
|
|
413
444
|
return this.syncPromise;
|
|
414
445
|
}
|
|
415
446
|
|
|
416
|
-
fail(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
|
@@ -44,8 +44,11 @@
|
|
|
44
44
|
<button id="sessionName" class="agent-action" title="修改 Session 名称">命名</button>
|
|
45
45
|
<button id="undo" class="agent-action" title="在 Session Tree 中回退输入或 Agent 输出">Undo</button>
|
|
46
46
|
<button id="sessionTree" class="agent-action">Session</button>
|
|
47
|
+
<button id="sessionPerformance" class="agent-action" title="分析当前会话的模型与工具耗时">耗时</button>
|
|
47
48
|
<button id="controls" class="agent-action">模型</button>
|
|
48
49
|
<button id="mcp" class="agent-action">MCP</button>
|
|
50
|
+
<button id="systemPrompt" class="agent-action" title="查看此 Agent 的 System Prompt 与模板">Prompt</button>
|
|
51
|
+
<button id="switchPrompt" class="agent-action" title="仅新会话首次对话前可切换 System Prompt">切换 Prompt</button>
|
|
49
52
|
<button id="compact" class="agent-action">Compact</button>
|
|
50
53
|
<button id="stop" class="agent-action">停止</button>
|
|
51
54
|
<button id="abort" class="agent-action danger-action">Abort</button>
|
|
@@ -59,7 +62,8 @@
|
|
|
59
62
|
<div id="widgets"></div>
|
|
60
63
|
<div id="messages" aria-live="polite"><div class="empty">选择或创建 Agent 开始对话</div></div>
|
|
61
64
|
<form id="prompt">
|
|
62
|
-
<div id="
|
|
65
|
+
<div id="activityBar" hidden></div>
|
|
66
|
+
<div id="queueBar" hidden><span id="queueText"></span><div id="queueItems"></div><button type="button" id="queueClear" title="清空队列并把消息放回输入框">清空队列</button></div>
|
|
63
67
|
<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
68
|
<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>
|
|
65
69
|
<div class="composer-row">
|
|
@@ -72,7 +76,7 @@
|
|
|
72
76
|
</article>
|
|
73
77
|
</main>
|
|
74
78
|
<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>
|
|
79
|
+
<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>Sotto 语音输入</h3><p class="muted">语音输入始终使用提供当前页面的本机 Backend 配置;本机未启用识别时不可用。音频不会发送给远端 Backend,识别出的文字仍可用于远端会话。</p><label class="toggle-row"><input id="sottoEnabled" type="checkbox">启用 Sotto 语音识别(默认关闭)</label><label>Endpoint(pi2web 服务所在机器的本机地址)<input id="sottoEndpoint" type="url" value="http://127.0.0.1:8766" placeholder="http://127.0.0.1:8766"></label><small id="sottoSettingsStatus" class="muted">使用本机 Sotto 已加载的 ASR 模型;未开启时不显示输入框旁的麦克风。</small><div class="config-actions"><button id="saveSotto" type="button">保存语音输入设置</button></div></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
80
|
<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
81
|
<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
82
|
<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 +84,7 @@
|
|
|
80
84
|
<script src="/vendor/marked.js"></script>
|
|
81
85
|
<script src="/vendor/dompurify.js"></script>
|
|
82
86
|
<script src="/hid-keyboard.js"></script>
|
|
87
|
+
<script src="/activity-view.js"></script>
|
|
83
88
|
<script src="/backend-state.js"></script>
|
|
84
89
|
<script src="/app.js"></script>
|
|
85
90
|
</body>
|
package/web/styles.css
CHANGED
|
@@ -72,8 +72,12 @@
|
|
|
72
72
|
@media(max-width:700px){.keyboard-slots{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
73
73
|
|
|
74
74
|
/* Queued steering/follow-up messages waiting for the running turn. */
|
|
75
|
-
#queueBar{display:flex;align-items:
|
|
76
|
-
#queueBar
|
|
75
|
+
#queueBar{display:flex;align-items:flex-start;flex-wrap:wrap;gap:8px;margin-bottom:8px;padding:6px 10px;font-size:12px;color:#161c24;background:var(--blue);border-radius:6px}
|
|
76
|
+
#queueBar>span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
77
|
+
#queueItems{flex-basis:100%;max-height:140px;overflow:auto;order:3}
|
|
78
|
+
.queue-item{display:flex;align-items:center;gap:8px;padding:3px 0}
|
|
79
|
+
.queue-item span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
80
|
+
|
|
77
81
|
#queueBar button{padding:3px 8px;font-size:12px;color:#161c24;background:#ffffffcc;border-color:#ffffff66}
|
|
78
82
|
/* Tool calls a tool made itself (codemode scripts, MCP batches) stay inside their caller's card. */
|
|
79
83
|
.nested-tools{margin-top:10px;padding-left:10px;border-left:2px solid var(--line)}
|
|
@@ -86,3 +90,18 @@
|
|
|
86
90
|
.mcp-info{flex:1;min-width:0}
|
|
87
91
|
.mcp-info small{display:block;margin-top:3px;white-space:pre-wrap;word-break:break-word}
|
|
88
92
|
.mcp-error{color:var(--red);white-space:pre-wrap;word-break:break-word}
|
|
93
|
+
.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}
|
|
94
|
+
.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}
|
|
95
|
+
.prompt-editor{min-height:140px;max-height:45vh;resize:vertical;font:12px/1.45 ui-monospace,monospace}
|
|
96
|
+
.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}
|
|
97
|
+
.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}
|
|
98
|
+
.template-editor{margin-top:10px;padding:10px;border:1px solid var(--line);border-radius:6px;background:#262a33}
|
|
99
|
+
#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}
|
|
100
|
+
#activityBar[hidden],.agent-activity[hidden]{display:none}
|
|
101
|
+
.activity-chip{display:inline-flex;align-items:center;gap:6px;min-width:0;max-width:100%}
|
|
102
|
+
.activity-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
103
|
+
.elapsed{flex:0 0 auto;font-variant-numeric:tabular-nums;color:var(--yellow)}
|
|
104
|
+
.activity-run{margin-left:auto;color:var(--muted);white-space:nowrap}
|
|
105
|
+
.agent-activity{display:flex;align-items:center;gap:6px;margin-top:5px;font-size:11px;color:#c9d1de}
|
|
106
|
+
.agent-activity .activity-chip{flex:1;min-width:0}
|
|
107
|
+
.performance-list{max-height:58vh;overflow:auto}.performance-run{border:1px solid var(--line);border-radius:6px;margin:8px 0;padding:8px}.performance-run summary{cursor:pointer}.performance-row{margin:9px 0;font-size:12px}.performance-label{display:block;white-space:normal;overflow-wrap:anywhere;margin-bottom:3px}.performance-track{position:relative;height:9px;background:#1a1e26;border-radius:4px}.performance-track i{position:absolute;height:100%;border-radius:4px;min-width:2px}.performance-model{background:var(--blue)}.performance-tool{background:var(--yellow)}.performance-compaction,.performance-retry{background:#b48ead}
|
package/web/voice-pcm.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// AudioWorklet: forward microphone frames as mono float32 PCM without recording or re-encoding.
|
|
2
|
+
class VoicePcmProcessor extends AudioWorkletProcessor {
|
|
3
|
+
constructor() {
|
|
4
|
+
super();
|
|
5
|
+
this.samples = new Float32Array(4096);
|
|
6
|
+
this.offset = 0;
|
|
7
|
+
this.finished = false;
|
|
8
|
+
this.port.onmessage = event => {
|
|
9
|
+
if (event.data?.type !== 'flush' || this.finished) return;
|
|
10
|
+
this.finished = true;
|
|
11
|
+
if (this.offset) {
|
|
12
|
+
const tail = this.samples.slice(0, this.offset).buffer;
|
|
13
|
+
this.port.postMessage(tail, [tail]);
|
|
14
|
+
this.offset = 0;
|
|
15
|
+
}
|
|
16
|
+
this.port.postMessage({type: 'flushed'});
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
process(inputs) {
|
|
20
|
+
if (this.finished) return false;
|
|
21
|
+
const channels = inputs[0];
|
|
22
|
+
if (!channels?.length) return true;
|
|
23
|
+
for (let i = 0; i < channels[0].length; i++) {
|
|
24
|
+
let sample = 0;
|
|
25
|
+
for (const channel of channels) sample += channel[i] / channels.length;
|
|
26
|
+
this.samples[this.offset++] = sample;
|
|
27
|
+
if (this.offset === this.samples.length) {
|
|
28
|
+
this.port.postMessage(this.samples.buffer, [this.samples.buffer]);
|
|
29
|
+
this.samples = new Float32Array(4096);
|
|
30
|
+
this.offset = 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
registerProcessor('voice-pcm', VoicePcmProcessor);
|