pi2web 0.2.6 → 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 +11 -3
- package/dist/agents.js +183 -41
- package/dist/http/agent-routes.js +28 -2
- package/dist/http/utils.js +5 -1
- package/dist/http/web-assets.js +1 -0
- package/dist/metadata-store.js +14 -1
- package/dist/sdk-backend.js +28 -0
- package/dist/server.js +52 -2
- package/dist/session-performance.js +137 -0
- package/dist/sotto-stt.js +217 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +18 -0
- package/web/app.js +602 -66
- package/web/collab.js +18 -4
- package/web/index.html +4 -2
- package/web/styles.css +7 -2
- package/web/voice-pcm.js +36 -0
package/web/app.js
CHANGED
|
@@ -79,12 +79,18 @@
|
|
|
79
79
|
generation: 0
|
|
80
80
|
},
|
|
81
81
|
mediaRecorder: null,
|
|
82
|
+
voiceStream: null,
|
|
82
83
|
mediaChunks: [],
|
|
83
84
|
mediaStream: null,
|
|
84
85
|
mediaTimer: null,
|
|
85
86
|
mediaAgentId: null,
|
|
87
|
+
mediaBackendId: null,
|
|
88
|
+
/** Messages that failed after the user switched away; restored when that Agent is selected again. */
|
|
89
|
+
failedSends: new Map(),
|
|
86
90
|
mailBackendId: null,
|
|
87
91
|
mailRequest: 0,
|
|
92
|
+
sottoBackendId: null,
|
|
93
|
+
sottoSettingsRequest: 0,
|
|
88
94
|
mailNotificationsAvailable: false,
|
|
89
95
|
mailSettings: {
|
|
90
96
|
enabled: false,
|
|
@@ -169,15 +175,24 @@
|
|
|
169
175
|
const body = await response.json().catch(() => ({}));
|
|
170
176
|
if (!response.ok) {
|
|
171
177
|
if (response.status === 401) onUnauthorized?.();
|
|
172
|
-
|
|
178
|
+
const error = Error(body.error?.message || `HTTP ${response.status}`);
|
|
179
|
+
error.status = response.status;
|
|
180
|
+
throw error;
|
|
173
181
|
}
|
|
174
182
|
return body.data;
|
|
175
183
|
}
|
|
176
184
|
async function backendRequiresAuth(base) {
|
|
177
185
|
try {
|
|
178
186
|
return (await request(base, '', '/health')).authRequired !== false;
|
|
179
|
-
} catch {
|
|
180
|
-
return true;
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (error.status === 401) return true;
|
|
189
|
+
if (error.status === 403)
|
|
190
|
+
throw Error(
|
|
191
|
+
'服务拒绝访问(403):无认证服务默认仅允许本机访问;请检查服务地址、代理和 --no-auth 设置'
|
|
192
|
+
);
|
|
193
|
+
if (error.status === 404) throw Error('服务未提供 /health(404):请检查地址和远端版本');
|
|
194
|
+
if (error instanceof TypeError) throw Error('无法访问服务:请检查地址、网络及浏览器跨域限制(CORS)');
|
|
195
|
+
throw error;
|
|
181
196
|
}
|
|
182
197
|
}
|
|
183
198
|
async function requestBackend(backendId, url, options = {}) {
|
|
@@ -245,8 +260,20 @@
|
|
|
245
260
|
}
|
|
246
261
|
|
|
247
262
|
function cancelVoiceRecording(backendId) {
|
|
263
|
+
if (
|
|
264
|
+
state.voiceStream &&
|
|
265
|
+
(!backendId ||
|
|
266
|
+
splitResourceKey(state.voiceStream.agentId).backendId === backendId ||
|
|
267
|
+
state.voiceStream.transcribeBackendId === backendId)
|
|
268
|
+
)
|
|
269
|
+
finishSottoStream(state.voiceStream, true);
|
|
248
270
|
if (!state.mediaAgentId) return;
|
|
249
|
-
if (
|
|
271
|
+
if (
|
|
272
|
+
backendId &&
|
|
273
|
+
splitResourceKey(state.mediaAgentId).backendId !== backendId &&
|
|
274
|
+
state.mediaBackendId !== backendId
|
|
275
|
+
)
|
|
276
|
+
return;
|
|
250
277
|
clearTimeout(state.mediaTimer);
|
|
251
278
|
if (state.mediaRecorder) {
|
|
252
279
|
state.mediaRecorder.onstop = null;
|
|
@@ -257,7 +284,9 @@
|
|
|
257
284
|
state.mediaStream = null;
|
|
258
285
|
state.mediaChunks = [];
|
|
259
286
|
state.mediaAgentId = null;
|
|
287
|
+
state.mediaBackendId = null;
|
|
260
288
|
$('voiceInput').textContent = '🎙';
|
|
289
|
+
$('voiceInput').disabled = false;
|
|
261
290
|
$('voiceInput').classList.remove('recording');
|
|
262
291
|
}
|
|
263
292
|
|
|
@@ -265,6 +294,7 @@
|
|
|
265
294
|
cancelVoiceRecording(backendId);
|
|
266
295
|
if (backendId && state.agent?.backendId !== backendId) return;
|
|
267
296
|
state.agent = null;
|
|
297
|
+
updateVoiceAvailability();
|
|
268
298
|
state.timeline = null;
|
|
269
299
|
sendFocus();
|
|
270
300
|
localStorage.removeItem('rpAgentId');
|
|
@@ -334,12 +364,52 @@
|
|
|
334
364
|
}
|
|
335
365
|
}
|
|
336
366
|
|
|
367
|
+
function applyVoiceStatus(backend, statusInfo) {
|
|
368
|
+
const voice = state.voiceStream;
|
|
369
|
+
if (
|
|
370
|
+
voice &&
|
|
371
|
+
voice.transcribeBackendId === backend.id &&
|
|
372
|
+
(!statusInfo.sottoStt?.enabled ||
|
|
373
|
+
statusInfo.sottoStt.endpoint !== backend.statusInfo?.sottoStt?.endpoint)
|
|
374
|
+
)
|
|
375
|
+
finishSottoStream(voice, true);
|
|
376
|
+
backend.statusInfo = statusInfo;
|
|
377
|
+
backend.voiceEnabled = Boolean(statusInfo.voiceCapabilities?.tts ?? statusInfo.voiceEnabled);
|
|
378
|
+
backend.voiceSttEnabled = Boolean(statusInfo.voiceCapabilities?.stt ?? statusInfo.voiceEnabled);
|
|
379
|
+
}
|
|
380
|
+
// The page is opened on the user's own pi2web server. Never route microphone audio to a remote Backend.
|
|
381
|
+
function localBackend() {
|
|
382
|
+
return (
|
|
383
|
+
connectedBackends().find(backend => {
|
|
384
|
+
if (backend.status !== 'connected') return false;
|
|
385
|
+
try {
|
|
386
|
+
const url = new URL(backend.base);
|
|
387
|
+
return (
|
|
388
|
+
url.origin === location.origin &&
|
|
389
|
+
url.pathname === '/' &&
|
|
390
|
+
!url.username &&
|
|
391
|
+
!url.password &&
|
|
392
|
+
!url.search &&
|
|
393
|
+
!url.hash
|
|
394
|
+
);
|
|
395
|
+
} catch {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
}) || null
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
function transcriptionBackend() {
|
|
402
|
+
if (!state.agent || backendOf(state.agent.backendId)?.status !== 'connected') return null;
|
|
403
|
+
const backend = localBackend();
|
|
404
|
+
return backend?.voiceSttEnabled ? backend : null;
|
|
405
|
+
}
|
|
337
406
|
function updateVoiceAvailability() {
|
|
338
407
|
const backends = connectedBackends();
|
|
339
408
|
state.voiceEnabled = backends.some(backend => backend.voiceEnabled);
|
|
340
|
-
state.voiceSttEnabled =
|
|
409
|
+
state.voiceSttEnabled = Boolean(transcriptionBackend());
|
|
341
410
|
$('voicePlayback').hidden = !state.voiceEnabled;
|
|
342
|
-
$('voiceInput').hidden = !state.voiceSttEnabled;
|
|
411
|
+
$('voiceInput').hidden = !state.voiceSttEnabled && !state.voiceStream;
|
|
412
|
+
$('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
|
|
343
413
|
if (!state.voiceEnabled) stopVoiceAudio();
|
|
344
414
|
updateVoiceButton();
|
|
345
415
|
}
|
|
@@ -382,10 +452,9 @@
|
|
|
382
452
|
statusInfo,
|
|
383
453
|
authRequired,
|
|
384
454
|
serverInfo: `v${statusInfo.version} · Pi ${statusInfo.piVersion}`,
|
|
385
|
-
reconnectAttempt: 0
|
|
386
|
-
voiceEnabled: Boolean(statusInfo.voiceCapabilities?.tts ?? statusInfo.voiceEnabled),
|
|
387
|
-
voiceSttEnabled: Boolean(statusInfo.voiceCapabilities?.stt ?? statusInfo.voiceEnabled)
|
|
455
|
+
reconnectAttempt: 0
|
|
388
456
|
});
|
|
457
|
+
applyVoiceStatus(backend, statusInfo);
|
|
389
458
|
|
|
390
459
|
try {
|
|
391
460
|
await refreshBackendData(backend);
|
|
@@ -1253,6 +1322,12 @@
|
|
|
1253
1322
|
sessionKeyboard.updateAgent(agent);
|
|
1254
1323
|
renderAgentList();
|
|
1255
1324
|
updateAgentHeader();
|
|
1325
|
+
if (state.agent?.agentId === agentId) {
|
|
1326
|
+
const picker = $('sendMode');
|
|
1327
|
+
if (status === 'streaming' || status === 'waiting_for_user') {
|
|
1328
|
+
if (picker.value === 'prompt') picker.value = 'follow-up';
|
|
1329
|
+
} else picker.value = 'prompt';
|
|
1330
|
+
}
|
|
1256
1331
|
return agent;
|
|
1257
1332
|
}
|
|
1258
1333
|
function updateAgentHeader(agent = state.agent) {
|
|
@@ -1490,23 +1565,9 @@
|
|
|
1490
1565
|
}
|
|
1491
1566
|
const autoBindNewSession = agent => sessionKeyboard.autoBind(agent.agentId);
|
|
1492
1567
|
async function startAgent(relativePath) {
|
|
1493
|
-
if (!state.workspace) return toast('请先选择 Workspace');
|
|
1494
1568
|
$('contextMenu').hidden = true;
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
const created = await postBackend(backendId, '/api/v1/agents', {
|
|
1498
|
-
workspaceId: state.workspace.id,
|
|
1499
|
-
relativeCwd: relativePath
|
|
1500
|
-
});
|
|
1501
|
-
const agent = stampNewItem(backendId, created);
|
|
1502
|
-
const binding = autoBindNewSession(agent);
|
|
1503
|
-
$('agentCwd').value = relativePath;
|
|
1504
|
-
await refreshAgents();
|
|
1505
|
-
await selectAgent(agent);
|
|
1506
|
-
if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot + 1}`);
|
|
1507
|
-
} catch (error) {
|
|
1508
|
-
toast(error.message);
|
|
1509
|
-
}
|
|
1569
|
+
$('agentCwd').value = relativePath;
|
|
1570
|
+
await createAgent();
|
|
1510
1571
|
}
|
|
1511
1572
|
async function openTerminal(relativePath) {
|
|
1512
1573
|
if (!state.workspace) return toast('请先选择 Workspace');
|
|
@@ -1542,14 +1603,24 @@
|
|
|
1542
1603
|
}
|
|
1543
1604
|
}
|
|
1544
1605
|
async function selectAgent(agent, openView = true) {
|
|
1606
|
+
if (state.agent?.agentId !== agent.agentId) cancelVoiceRecording();
|
|
1545
1607
|
state.selectedKind = 'agent';
|
|
1546
1608
|
state.terminalWs?.close();
|
|
1547
1609
|
state.terminalWs = null;
|
|
1548
1610
|
state.agent = agent;
|
|
1611
|
+
updateVoiceAvailability();
|
|
1612
|
+
const failed = state.failedSends.get(agent.agentId);
|
|
1613
|
+
if (failed !== undefined) {
|
|
1614
|
+
state.failedSends.delete(agent.agentId);
|
|
1615
|
+
prependInput(failed);
|
|
1616
|
+
toast('之前发送失败的消息已放回输入框');
|
|
1617
|
+
}
|
|
1549
1618
|
localStorage.rpAgentId = agent.agentId;
|
|
1550
1619
|
localStorage.rpSelectedKind = 'agent';
|
|
1551
1620
|
showAgentView();
|
|
1552
1621
|
updateAgentHeader();
|
|
1622
|
+
$('sendMode').value =
|
|
1623
|
+
agent.status === 'streaming' || agent.status === 'waiting_for_user' ? 'follow-up' : 'prompt';
|
|
1553
1624
|
if (openView) navigateMobile('agent');
|
|
1554
1625
|
void syncWorkspaceForSelection(agent);
|
|
1555
1626
|
if (ownsPath(state.workspace, agent))
|
|
@@ -2188,6 +2259,7 @@
|
|
|
2188
2259
|
bar = $('queueBar');
|
|
2189
2260
|
if (!queue) {
|
|
2190
2261
|
bar.hidden = true;
|
|
2262
|
+
$('queueItems').replaceChildren();
|
|
2191
2263
|
return;
|
|
2192
2264
|
}
|
|
2193
2265
|
const parts = [];
|
|
@@ -2196,6 +2268,39 @@
|
|
|
2196
2268
|
const preview = [...queue.steering, ...queue.followUp][0] || '';
|
|
2197
2269
|
$('queueText').textContent =
|
|
2198
2270
|
`排队中:${parts.join(' · ')}${preview ? ` — ${preview.replace(/\s+/g, ' ').slice(0, 60)}` : ''}`;
|
|
2271
|
+
const items = $('queueItems');
|
|
2272
|
+
items.replaceChildren();
|
|
2273
|
+
for (const [from, label, target] of [
|
|
2274
|
+
['steering', 'Steer', 'Follow-up'],
|
|
2275
|
+
['followUp', 'Follow-up', 'Steer']
|
|
2276
|
+
]) {
|
|
2277
|
+
queue[from].forEach((text, index) => {
|
|
2278
|
+
const row = document.createElement('div');
|
|
2279
|
+
row.className = 'queue-item';
|
|
2280
|
+
const preview = document.createElement('span');
|
|
2281
|
+
preview.textContent = `${label}: ${text}`;
|
|
2282
|
+
preview.title = text;
|
|
2283
|
+
const button = document.createElement('button');
|
|
2284
|
+
button.type = 'button';
|
|
2285
|
+
button.textContent = `改为 ${target}`;
|
|
2286
|
+
button.onclick = async () => {
|
|
2287
|
+
const agentId = state.agent.agentId;
|
|
2288
|
+
button.disabled = true;
|
|
2289
|
+
try {
|
|
2290
|
+
await postAgent(agentId, '/queue/move', {from, index, expected: text});
|
|
2291
|
+
} catch (error) {
|
|
2292
|
+
toast(`切换失败:${error.message}`);
|
|
2293
|
+
try {
|
|
2294
|
+
setAgentQueue(agentId, await requestAgent(agentId, '/queue'));
|
|
2295
|
+
} catch {}
|
|
2296
|
+
} finally {
|
|
2297
|
+
button.disabled = false;
|
|
2298
|
+
}
|
|
2299
|
+
};
|
|
2300
|
+
row.append(preview, button);
|
|
2301
|
+
items.append(row);
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2199
2304
|
bar.hidden = false;
|
|
2200
2305
|
}
|
|
2201
2306
|
function restoreExtensionRequests(agentId, activity) {
|
|
@@ -2313,11 +2418,225 @@
|
|
|
2313
2418
|
}
|
|
2314
2419
|
} else stopVoiceAudio();
|
|
2315
2420
|
}
|
|
2421
|
+
function updateSottoDraft(voice) {
|
|
2422
|
+
if (state.voiceStream !== voice || state.agent?.agentId !== voice.agentId) return;
|
|
2423
|
+
const input = voice.input,
|
|
2424
|
+
text = [...voice.finals.values(), voice.partial].filter(Boolean).join(' ');
|
|
2425
|
+
if (text === voice.inserted) return;
|
|
2426
|
+
if (input.value.slice(voice.at, voice.at + voice.inserted.length) !== voice.inserted) {
|
|
2427
|
+
finishSottoStream(voice, true);
|
|
2428
|
+
toast('输入框内容已被修改,语音输入已停止;现有文字不会覆盖');
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
input.setRangeText(text, voice.at, voice.at + voice.inserted.length, 'end');
|
|
2432
|
+
voice.inserted = text;
|
|
2433
|
+
}
|
|
2434
|
+
function finishSottoStream(voice, abort = false, reason = '') {
|
|
2435
|
+
if (state.voiceStream !== voice) return;
|
|
2436
|
+
state.voiceStream = null;
|
|
2437
|
+
clearTimeout(voice.timer);
|
|
2438
|
+
clearTimeout(voice.idleTimer);
|
|
2439
|
+
voice.stream?.getTracks().forEach(track => {
|
|
2440
|
+
track.onended = null;
|
|
2441
|
+
track.stop();
|
|
2442
|
+
});
|
|
2443
|
+
voice.node?.disconnect();
|
|
2444
|
+
voice.source?.disconnect();
|
|
2445
|
+
voice.sink?.disconnect();
|
|
2446
|
+
if (voice.node?.port) voice.node.port.onmessage = null;
|
|
2447
|
+
if (voice.node) voice.node.onaudioprocess = null;
|
|
2448
|
+
if (voice.context) void voice.context.close().catch(() => {});
|
|
2449
|
+
if (voice.socket?.readyState === WebSocket.OPEN || voice.socket?.readyState === WebSocket.CONNECTING)
|
|
2450
|
+
voice.socket.close();
|
|
2451
|
+
$('voiceInput').disabled = false;
|
|
2452
|
+
$('voiceInput').classList.remove('recording');
|
|
2453
|
+
$('voiceInput').textContent = '🎙';
|
|
2454
|
+
$('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
|
|
2455
|
+
if (
|
|
2456
|
+
abort &&
|
|
2457
|
+
voice.partial &&
|
|
2458
|
+
voice.input.value.slice(voice.at, voice.at + voice.inserted.length) === voice.inserted
|
|
2459
|
+
) {
|
|
2460
|
+
const text = [...voice.finals.values()].filter(Boolean).join(' ');
|
|
2461
|
+
voice.input.setRangeText(text, voice.at, voice.at + voice.inserted.length, 'end');
|
|
2462
|
+
}
|
|
2463
|
+
if (reason) toast(reason);
|
|
2464
|
+
}
|
|
2465
|
+
function finishSottoAudio(voice) {
|
|
2466
|
+
if (state.voiceStream !== voice || voice.phase !== 'draining') return;
|
|
2467
|
+
if (voice.socket.readyState !== WebSocket.OPEN)
|
|
2468
|
+
return finishSottoStream(voice, true, '语音连接已断开,无法完成识别');
|
|
2469
|
+
voice.phase = 'finishing';
|
|
2470
|
+
voice.node?.disconnect();
|
|
2471
|
+
voice.socket.send(JSON.stringify({type: 'stop'}));
|
|
2472
|
+
clearTimeout(voice.timer);
|
|
2473
|
+
voice.timer = setTimeout(() => finishSottoStream(voice, true, '等待语音识别结果超时'), 12000);
|
|
2474
|
+
}
|
|
2475
|
+
function stopSottoStream(voice) {
|
|
2476
|
+
if (state.voiceStream !== voice) return;
|
|
2477
|
+
if (voice.phase === 'starting') return finishSottoStream(voice, true);
|
|
2478
|
+
if (voice.phase !== 'recording') return;
|
|
2479
|
+
voice.phase = 'draining';
|
|
2480
|
+
clearTimeout(voice.idleTimer);
|
|
2481
|
+
voice.source?.disconnect();
|
|
2482
|
+
voice.stream.getTracks().forEach(track => {
|
|
2483
|
+
track.onended = null;
|
|
2484
|
+
track.stop();
|
|
2485
|
+
});
|
|
2486
|
+
$('voiceInput').classList.remove('recording');
|
|
2487
|
+
$('voiceInput').textContent = '…';
|
|
2488
|
+
$('voiceInput').disabled = true;
|
|
2489
|
+
// Keep accepting queued PCM until the worklet confirms that its final buffer was sent.
|
|
2490
|
+
if (voice.node?.port) {
|
|
2491
|
+
voice.timer = setTimeout(() => finishSottoStream(voice, true, '麦克风尾部音频处理超时'), 3000);
|
|
2492
|
+
voice.node.port.postMessage({type: 'flush'});
|
|
2493
|
+
} else finishSottoAudio(voice);
|
|
2494
|
+
}
|
|
2495
|
+
function sendSottoPcm(voice, pcm) {
|
|
2496
|
+
if (state.voiceStream !== voice || !['recording', 'draining'].includes(voice.phase)) return;
|
|
2497
|
+
if (voice.socket.readyState !== WebSocket.OPEN || voice.socket.bufferedAmount >= 500000)
|
|
2498
|
+
return finishSottoStream(voice, true, '实时语音连接中断或处理不过来,录音已停止');
|
|
2499
|
+
voice.socket.send(pcm);
|
|
2500
|
+
}
|
|
2501
|
+
// Dictation survives switching views; it ends when the user stops it or nothing new is recognised.
|
|
2502
|
+
const SOTTO_IDLE_MS = 60000;
|
|
2503
|
+
function touchSottoIdle(voice) {
|
|
2504
|
+
clearTimeout(voice.idleTimer);
|
|
2505
|
+
voice.idleTimer = setTimeout(() => {
|
|
2506
|
+
if (state.voiceStream !== voice || voice.phase !== 'recording') return;
|
|
2507
|
+
toast('1 分钟没有新的识别文字,语音输入已自动停止');
|
|
2508
|
+
stopSottoStream(voice);
|
|
2509
|
+
}, SOTTO_IDLE_MS);
|
|
2510
|
+
}
|
|
2511
|
+
async function startSottoStream() {
|
|
2512
|
+
if (state.voiceStream) return;
|
|
2513
|
+
if (!navigator.mediaDevices?.getUserMedia) return toast('当前浏览器不支持麦克风录音');
|
|
2514
|
+
const Audio = window.AudioContext || window.webkitAudioContext;
|
|
2515
|
+
if (!Audio) return toast('当前浏览器不支持实时音频采集');
|
|
2516
|
+
const agentId = state.agent.agentId,
|
|
2517
|
+
speechBackend = transcriptionBackend(),
|
|
2518
|
+
input = $('input');
|
|
2519
|
+
// One owner from permission acquisition through final result. Cancelling invalidates pending awaits.
|
|
2520
|
+
const voice = {
|
|
2521
|
+
agentId,
|
|
2522
|
+
transcribeBackendId: speechBackend.id,
|
|
2523
|
+
input,
|
|
2524
|
+
at: input.selectionStart,
|
|
2525
|
+
inserted: '',
|
|
2526
|
+
finals: new Map(),
|
|
2527
|
+
partial: '',
|
|
2528
|
+
phase: 'starting',
|
|
2529
|
+
stream: null,
|
|
2530
|
+
context: null,
|
|
2531
|
+
socket: null,
|
|
2532
|
+
timer: null,
|
|
2533
|
+
idleTimer: null,
|
|
2534
|
+
node: null,
|
|
2535
|
+
source: null,
|
|
2536
|
+
sink: null
|
|
2537
|
+
};
|
|
2538
|
+
state.voiceStream = voice;
|
|
2539
|
+
$('voiceInput').textContent = '…';
|
|
2540
|
+
$('voiceInput').title = '正在启动语音输入,点击取消';
|
|
2541
|
+
try {
|
|
2542
|
+
const stream = await navigator.mediaDevices.getUserMedia({audio: true});
|
|
2543
|
+
if (state.voiceStream !== voice) {
|
|
2544
|
+
stream.getTracks().forEach(track => track.stop());
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
voice.stream = stream;
|
|
2548
|
+
stream.getTracks().forEach(track => {
|
|
2549
|
+
track.onended = () => finishSottoStream(voice, true, '麦克风已断开或录音权限被撤销');
|
|
2550
|
+
});
|
|
2551
|
+
const context = (voice.context = new Audio());
|
|
2552
|
+
voice.timer = setTimeout(() => finishSottoStream(voice, true, '启动实时语音服务超时'), 30000);
|
|
2553
|
+
if (context.audioWorklet && window.AudioWorkletNode)
|
|
2554
|
+
await context.audioWorklet.addModule('/voice-pcm.js');
|
|
2555
|
+
if (state.voiceStream !== voice) return;
|
|
2556
|
+
if (state.agent?.agentId !== agentId) throw Error('会话已切换');
|
|
2557
|
+
const backend = backendOf(voice.transcribeBackendId);
|
|
2558
|
+
if (backend?.status !== 'connected' || !backend.statusInfo?.sottoStt?.enabled)
|
|
2559
|
+
throw Error('语音识别 Backend 已断开');
|
|
2560
|
+
const url = backend.base.replace(/^http/, 'ws') + '/api/v1/transcribe/ws';
|
|
2561
|
+
const socket = (voice.socket = new WebSocket(
|
|
2562
|
+
url,
|
|
2563
|
+
backend.token ? ['access-token.' + backend.token] : []
|
|
2564
|
+
));
|
|
2565
|
+
socket.onmessage = event => {
|
|
2566
|
+
if (state.voiceStream !== voice) return;
|
|
2567
|
+
let msg;
|
|
2568
|
+
try {
|
|
2569
|
+
msg = JSON.parse(event.data);
|
|
2570
|
+
} catch {
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (msg.type === 'ready') {
|
|
2574
|
+
if (voice.phase !== 'starting') return;
|
|
2575
|
+
try {
|
|
2576
|
+
socket.send(JSON.stringify({type: 'start', sample_rate: context.sampleRate, format: 'float32'}));
|
|
2577
|
+
voice.source = context.createMediaStreamSource(stream);
|
|
2578
|
+
voice.sink = context.createGain();
|
|
2579
|
+
voice.sink.gain.value = 0;
|
|
2580
|
+
if (context.audioWorklet && window.AudioWorkletNode) {
|
|
2581
|
+
voice.node = new AudioWorkletNode(context, 'voice-pcm');
|
|
2582
|
+
voice.node.port.onmessage = e =>
|
|
2583
|
+
e.data?.type === 'flushed' ? finishSottoAudio(voice) : sendSottoPcm(voice, e.data);
|
|
2584
|
+
} else {
|
|
2585
|
+
voice.node = context.createScriptProcessor(4096, 1, 1);
|
|
2586
|
+
voice.node.onaudioprocess = e =>
|
|
2587
|
+
sendSottoPcm(voice, new Float32Array(e.inputBuffer.getChannelData(0)).buffer);
|
|
2588
|
+
}
|
|
2589
|
+
voice.phase = 'recording';
|
|
2590
|
+
voice.source.connect(voice.node);
|
|
2591
|
+
voice.node.connect(voice.sink);
|
|
2592
|
+
voice.sink.connect(context.destination);
|
|
2593
|
+
void context
|
|
2594
|
+
.resume()
|
|
2595
|
+
.catch(error => finishSottoStream(voice, true, `无法启动麦克风:${error.message}`));
|
|
2596
|
+
clearTimeout(voice.timer);
|
|
2597
|
+
touchSottoIdle(voice);
|
|
2598
|
+
$('voiceInput').textContent = '■';
|
|
2599
|
+
$('voiceInput').title = '正在录音,点击停止(停顿不会结束录音)';
|
|
2600
|
+
$('voiceInput').classList.add('recording');
|
|
2601
|
+
} catch (error) {
|
|
2602
|
+
finishSottoStream(voice, true, `无法采集音频:${error.message}`);
|
|
2603
|
+
}
|
|
2604
|
+
} else if (msg.type === 'partial' && voice.phase === 'recording') {
|
|
2605
|
+
const text = String(msg.text || '');
|
|
2606
|
+
if (text && text !== voice.partial) touchSottoIdle(voice);
|
|
2607
|
+
voice.partial = text;
|
|
2608
|
+
updateSottoDraft(voice);
|
|
2609
|
+
} else if (msg.type === 'final') {
|
|
2610
|
+
// A final ends one sentence, not the recording. Only the user's stop ends capture.
|
|
2611
|
+
const text = String(msg.text || '');
|
|
2612
|
+
if (text && voice.phase === 'recording') touchSottoIdle(voice);
|
|
2613
|
+
voice.finals.set(msg.segment, text);
|
|
2614
|
+
voice.partial = '';
|
|
2615
|
+
updateSottoDraft(voice);
|
|
2616
|
+
} else if (msg.type === 'error') {
|
|
2617
|
+
finishSottoStream(voice, true, `Sotto 语音识别:${msg.message || '未知错误'}`);
|
|
2618
|
+
}
|
|
2619
|
+
};
|
|
2620
|
+
socket.onerror = () => finishSottoStream(voice, true, '无法连接 Sotto 实时语音服务');
|
|
2621
|
+
socket.onclose = event => {
|
|
2622
|
+
const expected = voice.phase === 'finishing' && event.code === 1000;
|
|
2623
|
+
finishSottoStream(voice, !expected, expected ? '' : `实时语音连接意外断开(${event.code})`);
|
|
2624
|
+
};
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
finishSottoStream(voice, true, `无法开始语音输入:${error.message}`);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2316
2629
|
async function toggleVoiceInput() {
|
|
2630
|
+
if (state.voiceStream) return stopSottoStream(state.voiceStream);
|
|
2631
|
+
if ($('voiceInput').disabled) return;
|
|
2317
2632
|
if (!state.voiceSttEnabled) return toast('服务端未启用语音识别');
|
|
2318
2633
|
if (!state.agent) return toast('请先选择 Agent');
|
|
2319
|
-
if (
|
|
2320
|
-
|
|
2634
|
+
if (transcriptionBackend()?.statusInfo?.sottoStt?.enabled) return startSottoStream();
|
|
2635
|
+
if (state.mediaRecorder) {
|
|
2636
|
+
if (state.mediaRecorder.state === 'recording') {
|
|
2637
|
+
$('voiceInput').disabled = true;
|
|
2638
|
+
state.mediaRecorder.stop();
|
|
2639
|
+
}
|
|
2321
2640
|
return;
|
|
2322
2641
|
}
|
|
2323
2642
|
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)
|
|
@@ -2334,16 +2653,26 @@
|
|
|
2334
2653
|
if (event.data.size) state.mediaChunks.push(event.data);
|
|
2335
2654
|
};
|
|
2336
2655
|
state.mediaAgentId = state.agent.agentId;
|
|
2656
|
+
state.mediaBackendId = transcriptionBackend()?.id;
|
|
2337
2657
|
state.mediaRecorder.onstop = () => void transcribeVoice();
|
|
2338
2658
|
state.mediaRecorder.start(250);
|
|
2339
2659
|
$('voiceInput').textContent = '■';
|
|
2340
2660
|
$('voiceInput').title = '停止并识别';
|
|
2341
2661
|
$('voiceInput').classList.add('recording');
|
|
2342
|
-
state.mediaTimer = setTimeout(
|
|
2343
|
-
(
|
|
2344
|
-
|
|
2345
|
-
|
|
2662
|
+
state.mediaTimer = setTimeout(() => {
|
|
2663
|
+
if (state.mediaRecorder?.state === 'recording') {
|
|
2664
|
+
$('voiceInput').disabled = true;
|
|
2665
|
+
state.mediaRecorder.stop();
|
|
2666
|
+
}
|
|
2667
|
+
}, 60000);
|
|
2346
2668
|
} catch (error) {
|
|
2669
|
+
state.mediaStream?.getTracks().forEach(track => track.stop());
|
|
2670
|
+
state.mediaStream = null;
|
|
2671
|
+
state.mediaRecorder = null;
|
|
2672
|
+
state.mediaChunks = [];
|
|
2673
|
+
state.mediaAgentId = null;
|
|
2674
|
+
state.mediaBackendId = null;
|
|
2675
|
+
$('voiceInput').disabled = false;
|
|
2347
2676
|
toast(`无法录音:${error.message}`);
|
|
2348
2677
|
}
|
|
2349
2678
|
}
|
|
@@ -2352,37 +2681,38 @@
|
|
|
2352
2681
|
const recorder = state.mediaRecorder,
|
|
2353
2682
|
mime = recorder?.mimeType || 'audio/webm',
|
|
2354
2683
|
blob = new Blob(state.mediaChunks, {type: mime}),
|
|
2355
|
-
agentId = state.mediaAgentId
|
|
2684
|
+
agentId = state.mediaAgentId,
|
|
2685
|
+
speechBackendId = state.mediaBackendId;
|
|
2356
2686
|
state.mediaStream?.getTracks().forEach(track => track.stop());
|
|
2357
2687
|
state.mediaRecorder = null;
|
|
2358
2688
|
state.mediaStream = null;
|
|
2359
2689
|
state.mediaChunks = [];
|
|
2360
2690
|
state.mediaAgentId = null;
|
|
2691
|
+
state.mediaBackendId = null;
|
|
2361
2692
|
$('voiceInput').textContent = '…';
|
|
2693
|
+
$('voiceInput').disabled = true;
|
|
2362
2694
|
$('voiceInput').classList.remove('recording');
|
|
2363
2695
|
if (!agentId) {
|
|
2364
2696
|
$('voiceInput').textContent = '🎙';
|
|
2697
|
+
$('voiceInput').disabled = false;
|
|
2365
2698
|
return;
|
|
2366
2699
|
}
|
|
2367
|
-
const
|
|
2368
|
-
|
|
2369
|
-
if (!transcribeBackend) {
|
|
2700
|
+
const transcribeBackend = backendOf(speechBackendId);
|
|
2701
|
+
if (transcribeBackend?.status !== 'connected') {
|
|
2370
2702
|
$('voiceInput').textContent = '🎙';
|
|
2371
|
-
|
|
2703
|
+
$('voiceInput').disabled = false;
|
|
2704
|
+
return toast('语音识别 Backend 已断开');
|
|
2372
2705
|
}
|
|
2373
2706
|
try {
|
|
2374
|
-
const response = await fetch(
|
|
2375
|
-
|
|
2376
|
-
{
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
body: blob
|
|
2384
|
-
}
|
|
2385
|
-
);
|
|
2707
|
+
const response = await fetch(transcribeBackend.base + '/api/v1/transcribe', {
|
|
2708
|
+
method: 'POST',
|
|
2709
|
+
headers: {
|
|
2710
|
+
...(transcribeBackend.token ? {Authorization: `Bearer ${transcribeBackend.token}`} : {}),
|
|
2711
|
+
'Content-Type': mime,
|
|
2712
|
+
'X-Audio-Filename': mime.includes('mp4') ? 'recording.m4a' : 'recording.webm'
|
|
2713
|
+
},
|
|
2714
|
+
body: blob
|
|
2715
|
+
});
|
|
2386
2716
|
const body = await response.json().catch(() => ({}));
|
|
2387
2717
|
if (!response.ok) throw Error(body.error?.message || `HTTP ${response.status}`);
|
|
2388
2718
|
const text = body.data?.text?.trim();
|
|
@@ -2397,7 +2727,8 @@
|
|
|
2397
2727
|
toast(`语音识别失败:${error.message}`);
|
|
2398
2728
|
} finally {
|
|
2399
2729
|
$('voiceInput').textContent = '🎙';
|
|
2400
|
-
$('voiceInput').
|
|
2730
|
+
$('voiceInput').disabled = false;
|
|
2731
|
+
$('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
|
|
2401
2732
|
}
|
|
2402
2733
|
}
|
|
2403
2734
|
const handledDrafts = new Set();
|
|
@@ -2773,6 +3104,58 @@
|
|
|
2773
3104
|
: `Backend「${backend.name}」未配置 MailDispatch endpoint、API key 和通知邮箱。`;
|
|
2774
3105
|
}
|
|
2775
3106
|
|
|
3107
|
+
function sottoConfigBackend() {
|
|
3108
|
+
return localBackend();
|
|
3109
|
+
}
|
|
3110
|
+
async function loadSottoSettings() {
|
|
3111
|
+
const backend = sottoConfigBackend();
|
|
3112
|
+
const requestId = ++state.sottoSettingsRequest;
|
|
3113
|
+
state.sottoBackendId = null;
|
|
3114
|
+
$('saveSotto').disabled = true;
|
|
3115
|
+
if (!backend) {
|
|
3116
|
+
$('sottoSettingsStatus').textContent = '请先连接提供当前页面的本机 Backend';
|
|
3117
|
+
return;
|
|
3118
|
+
}
|
|
3119
|
+
$('sottoSettingsStatus').textContent = `正在读取「${backend.name}」的语音输入设置…`;
|
|
3120
|
+
try {
|
|
3121
|
+
const settings = await requestBackend(backend.id, '/api/v1/sotto-stt');
|
|
3122
|
+
if (requestId !== state.sottoSettingsRequest || !$('configDialog').open) return;
|
|
3123
|
+
state.sottoBackendId = backend.id;
|
|
3124
|
+
$('sottoEnabled').checked = settings.enabled;
|
|
3125
|
+
$('sottoEndpoint').value = settings.endpoint;
|
|
3126
|
+
$('saveSotto').disabled = false;
|
|
3127
|
+
$('sottoSettingsStatus').textContent =
|
|
3128
|
+
`语音识别固定使用本机 Backend「${backend.name}」的设置;远端会话共用此配置。`;
|
|
3129
|
+
} catch (error) {
|
|
3130
|
+
if (requestId === state.sottoSettingsRequest)
|
|
3131
|
+
$('sottoSettingsStatus').textContent = `读取失败:${error.message}`;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
async function saveSottoSettings() {
|
|
3135
|
+
const backendId = state.sottoBackendId;
|
|
3136
|
+
if (!backendId) return toast('请先连接 Backend 并读取设置');
|
|
3137
|
+
$('saveSotto').disabled = true;
|
|
3138
|
+
try {
|
|
3139
|
+
const settings = await requestBackend(backendId, '/api/v1/sotto-stt', {
|
|
3140
|
+
method: 'PUT',
|
|
3141
|
+
body: JSON.stringify({enabled: $('sottoEnabled').checked, endpoint: $('sottoEndpoint').value.trim()})
|
|
3142
|
+
});
|
|
3143
|
+
const owner = backendOf(backendId);
|
|
3144
|
+
if (owner) applyVoiceStatus(owner, {...owner.statusInfo, sottoStt: settings});
|
|
3145
|
+
const statusInfo = await requestBackend(backendId, '/api/v1/system/status');
|
|
3146
|
+
const backend = backendOf(backendId);
|
|
3147
|
+
if (backend) applyVoiceStatus(backend, statusInfo);
|
|
3148
|
+
updateVoiceAvailability();
|
|
3149
|
+
$('sottoSettingsStatus').textContent = settings.enabled
|
|
3150
|
+
? 'Sotto 已连通;输入框旁的麦克风可用,转写使用 Sotto 的同一个 ASR 实例。'
|
|
3151
|
+
: 'Sotto 语音输入已关闭。';
|
|
3152
|
+
toast('语音输入设置已保存');
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
toast(`Sotto 设置失败:${error.message}`);
|
|
3155
|
+
} finally {
|
|
3156
|
+
$('saveSotto').disabled = !backendOf(backendId);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
2776
3159
|
function updateConfigUi() {
|
|
2777
3160
|
const connected = connectedBackends().length;
|
|
2778
3161
|
$('configConnectionStatus').textContent = connected ? `已连接 ${connected} 个 backend` : '当前未配对';
|
|
@@ -2787,6 +3170,7 @@
|
|
|
2787
3170
|
updateConfigUi();
|
|
2788
3171
|
renderKeyboardBindings();
|
|
2789
3172
|
if (!$('configDialog').open) $('configDialog').showModal();
|
|
3173
|
+
void loadSottoSettings();
|
|
2790
3174
|
}
|
|
2791
3175
|
|
|
2792
3176
|
async function saveMailSettings() {
|
|
@@ -2818,6 +3202,11 @@
|
|
|
2818
3202
|
}
|
|
2819
3203
|
}
|
|
2820
3204
|
|
|
3205
|
+
const el = (tag, props = {}, ...children) => {
|
|
3206
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
3207
|
+
node.append(...children);
|
|
3208
|
+
return node;
|
|
3209
|
+
};
|
|
2821
3210
|
function modal(title, build, okText = '确定') {
|
|
2822
3211
|
const dialog = $('modal'),
|
|
2823
3212
|
body = $('modalBody');
|
|
@@ -2931,6 +3320,7 @@
|
|
|
2931
3320
|
const workspace = state.workspace;
|
|
2932
3321
|
if (!workspace) return toast('请先选择 Workspace');
|
|
2933
3322
|
const cwd = $('agentCwd').value.trim() || '.';
|
|
3323
|
+
const preferenceKey = `rpLastTemplate:${workspace.backendId}`;
|
|
2934
3324
|
let sessions,
|
|
2935
3325
|
templates = [];
|
|
2936
3326
|
try {
|
|
@@ -2965,6 +3355,12 @@
|
|
|
2965
3355
|
l.append(s);
|
|
2966
3356
|
body.append(l);
|
|
2967
3357
|
const t = templateSelect(body, templates);
|
|
3358
|
+
const preferred = globalThis.localStorage?.getItem(preferenceKey);
|
|
3359
|
+
if (preferred && [...t.options].some(option => option.value === preferred)) t.value = preferred;
|
|
3360
|
+
else if (preferred) {
|
|
3361
|
+
globalThis.localStorage?.removeItem(preferenceKey);
|
|
3362
|
+
toast('上次使用的 System Prompt 模板已删除,改用 Pi 默认');
|
|
3363
|
+
}
|
|
2968
3364
|
const preview = promptPreviewBox(body, () => ({
|
|
2969
3365
|
backendId: workspace.backendId,
|
|
2970
3366
|
workspaceId: workspace.id,
|
|
@@ -2989,6 +3385,7 @@
|
|
|
2989
3385
|
sessionFile: result.sessionFile,
|
|
2990
3386
|
templateId: result.templateId
|
|
2991
3387
|
});
|
|
3388
|
+
localStorage.setItem(preferenceKey, result.templateId || '');
|
|
2992
3389
|
const agent = stampNewItem(workspace.backendId, created);
|
|
2993
3390
|
const binding = result.sessionFile ? null : autoBindNewSession(agent);
|
|
2994
3391
|
await refreshAgents();
|
|
@@ -3114,6 +3511,31 @@
|
|
|
3114
3511
|
'关闭'
|
|
3115
3512
|
);
|
|
3116
3513
|
}
|
|
3514
|
+
async function switchAgentPrompt() {
|
|
3515
|
+
const agent = state.agent;
|
|
3516
|
+
if (!agent) return toast('请先选择 Agent');
|
|
3517
|
+
const [session, catalog] = await Promise.all([
|
|
3518
|
+
requestAgent(agent.agentId, '/session'),
|
|
3519
|
+
requestBackend(agent.backendId, '/api/v1/agent-templates')
|
|
3520
|
+
]);
|
|
3521
|
+
if (agent.profile !== 'default' || session.entries?.some(entry => entry.type === 'message'))
|
|
3522
|
+
return toast('会话开始后不能切换 System Prompt,请新建会话');
|
|
3523
|
+
const choice = await modal(
|
|
3524
|
+
'切换 System Prompt(仅首次对话前)',
|
|
3525
|
+
body => {
|
|
3526
|
+
const picker = templateSelect(body, catalog.templates || []);
|
|
3527
|
+
picker.value = agent.template?.id || '';
|
|
3528
|
+
return () => picker.value;
|
|
3529
|
+
},
|
|
3530
|
+
'切换'
|
|
3531
|
+
);
|
|
3532
|
+
if (choice === null) return;
|
|
3533
|
+
await postAgent(agent.agentId, '/system-prompt', {templateId: choice});
|
|
3534
|
+
localStorage.setItem(`rpLastTemplate:${agent.backendId}`, choice);
|
|
3535
|
+
agent.template = (catalog.templates || []).find(item => item.id === choice) || undefined;
|
|
3536
|
+
await refreshAgents(true);
|
|
3537
|
+
toast('System Prompt 已切换');
|
|
3538
|
+
}
|
|
3117
3539
|
/** Manage Pi's global prompt files and the shareable Agent templates of one backend. */
|
|
3118
3540
|
async function systemPromptManager() {
|
|
3119
3541
|
if (!requireConnection()) return;
|
|
@@ -3135,11 +3557,6 @@
|
|
|
3135
3557
|
return toast(`读取失败:${error.message}(Backend 可能版本过旧)`);
|
|
3136
3558
|
}
|
|
3137
3559
|
if ($('configDialog').open) $('configDialog').close();
|
|
3138
|
-
const el = (tag, props = {}, ...children) => {
|
|
3139
|
-
const node = Object.assign(document.createElement(tag), props);
|
|
3140
|
-
node.append(...children);
|
|
3141
|
-
return node;
|
|
3142
|
-
};
|
|
3143
3560
|
const section = (title, note) =>
|
|
3144
3561
|
el(
|
|
3145
3562
|
'section',
|
|
@@ -3336,6 +3753,108 @@
|
|
|
3336
3753
|
if (result.editorText !== undefined) $('input').value = result.editorText;
|
|
3337
3754
|
toast(result.editorText !== undefined ? '已回退到该用户输入,可编辑后重新发送' : '已回退到所选输出');
|
|
3338
3755
|
}
|
|
3756
|
+
const performanceTime = ms =>
|
|
3757
|
+
ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`;
|
|
3758
|
+
function coveredTime(spans) {
|
|
3759
|
+
const intervals = spans.map(span => [span.start, span.end]).sort((a, b) => a[0] - b[0]);
|
|
3760
|
+
let total = 0,
|
|
3761
|
+
end = -Infinity;
|
|
3762
|
+
for (const [from, to] of intervals) {
|
|
3763
|
+
total += Math.max(0, to - Math.max(end, from));
|
|
3764
|
+
end = Math.max(end, to);
|
|
3765
|
+
}
|
|
3766
|
+
return total;
|
|
3767
|
+
}
|
|
3768
|
+
async function showSessionPerformance() {
|
|
3769
|
+
const agent = state.agent;
|
|
3770
|
+
if (!agent) return toast('请选择 Agent');
|
|
3771
|
+
const {runs, legacyTools} = await requestAgent(agent.agentId, '/performance');
|
|
3772
|
+
await modal(
|
|
3773
|
+
'会话耗时分析',
|
|
3774
|
+
body => {
|
|
3775
|
+
const info = document.createElement('p');
|
|
3776
|
+
info.className = 'muted';
|
|
3777
|
+
info.textContent =
|
|
3778
|
+
'横轴为每轮运行的实际时间;同一时间段的工具调用可能并行。模型耗时包含首响应等待与输出;网络、排队和其他开销可能落在空白时间。';
|
|
3779
|
+
body.append(info);
|
|
3780
|
+
if (!runs.length && !legacyTools.length) {
|
|
3781
|
+
body.append(el('p', {textContent: '当前分支没有可分析的耗时记录;新运行结束后会记录精确时间。'}));
|
|
3782
|
+
return null;
|
|
3783
|
+
}
|
|
3784
|
+
const list = el('div', {className: 'performance-list'});
|
|
3785
|
+
const allSpans = runs.flatMap(run => run.spans || []);
|
|
3786
|
+
const total = runs.reduce((sum, run) => sum + Math.max(0, run.end - run.start), 0);
|
|
3787
|
+
const tools = allSpans.filter(span => span.kind === 'tool');
|
|
3788
|
+
const models = allSpans.filter(span => span.kind === 'model');
|
|
3789
|
+
const head = el('p', {
|
|
3790
|
+
textContent: `${runs.length} 轮 · 总运行 ${performanceTime(total)} · 模型 ${performanceTime(coveredTime(models))} · 工具占用 ${performanceTime(coveredTime(tools))}(调用时长累计 ${performanceTime(tools.reduce((n, s) => n + s.end - s.start, 0))},可能并行)`
|
|
3791
|
+
});
|
|
3792
|
+
list.append(head);
|
|
3793
|
+
const legend = el('p', {
|
|
3794
|
+
className: 'muted',
|
|
3795
|
+
textContent: '蓝色:模型 · 黄色:工具 · 紫色:压缩/重试;空白:其他等待'
|
|
3796
|
+
});
|
|
3797
|
+
list.append(legend);
|
|
3798
|
+
for (const run of [...runs].reverse()) {
|
|
3799
|
+
const duration = Math.max(1, run.end - run.start);
|
|
3800
|
+
const spans = [...(run.spans || [])].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
3801
|
+
const modelTime = coveredTime(spans.filter(span => span.kind === 'model'));
|
|
3802
|
+
const toolTime = coveredTime(spans.filter(span => span.kind === 'tool'));
|
|
3803
|
+
const summary = el('summary', {
|
|
3804
|
+
textContent: `${new Date(run.start).toLocaleString()} · ${performanceTime(duration)} · 模型 ${performanceTime(modelTime)} · 工具 ${performanceTime(toolTime)}${run.incomplete ? ' · 已中断' : ''}`
|
|
3805
|
+
});
|
|
3806
|
+
const details = el('details', {className: 'performance-run'}, summary);
|
|
3807
|
+
for (const span of spans) {
|
|
3808
|
+
const length = Math.max(0, span.end - span.start);
|
|
3809
|
+
const first =
|
|
3810
|
+
span.kind === 'model' && span.firstResponse !== undefined
|
|
3811
|
+
? ` · 首响应 ${performanceTime(span.firstResponse - span.start)} · 响应 ${performanceTime(span.end - span.firstResponse)}`
|
|
3812
|
+
: '';
|
|
3813
|
+
const label =
|
|
3814
|
+
span.kind === 'model'
|
|
3815
|
+
? '模型调用'
|
|
3816
|
+
: span.kind === 'tool'
|
|
3817
|
+
? `工具 ${span.name || ''}`
|
|
3818
|
+
: span.kind === 'retry'
|
|
3819
|
+
? '重试等待'
|
|
3820
|
+
: '上下文压缩';
|
|
3821
|
+
const row = el('div', {className: 'performance-row'});
|
|
3822
|
+
const text = el('span', {
|
|
3823
|
+
className: 'performance-label',
|
|
3824
|
+
textContent: `${label} · +${performanceTime(span.start - run.start)} · ${performanceTime(length)}${first}${span.incomplete ? ' · 未完成' : ''}`
|
|
3825
|
+
});
|
|
3826
|
+
const track = el('div', {className: 'performance-track'});
|
|
3827
|
+
const bar = el('i', {className: `performance-${span.kind}`});
|
|
3828
|
+
bar.style.left = `${Math.max(0, Math.min(100, ((span.start - run.start) / duration) * 100))}%`;
|
|
3829
|
+
bar.style.width = `${Math.max(0.35, Math.min(100 - parseFloat(bar.style.left), (length / duration) * 100))}%`;
|
|
3830
|
+
track.append(bar);
|
|
3831
|
+
row.append(text, track);
|
|
3832
|
+
details.append(row);
|
|
3833
|
+
}
|
|
3834
|
+
list.append(details);
|
|
3835
|
+
}
|
|
3836
|
+
if (legacyTools.length) {
|
|
3837
|
+
const old = el('details', {className: 'performance-run'});
|
|
3838
|
+
old.append(
|
|
3839
|
+
el('summary', {
|
|
3840
|
+
textContent: `旧会话:${legacyTools.length} 次工具调用(仅有工具耗时;缺少模型请求数据)`
|
|
3841
|
+
})
|
|
3842
|
+
);
|
|
3843
|
+
for (const span of legacyTools.sort((a, b) => b.end - a.end))
|
|
3844
|
+
old.append(
|
|
3845
|
+
el('div', {
|
|
3846
|
+
className: 'performance-row',
|
|
3847
|
+
textContent: `${new Date(span.end).toLocaleString()} · ${span.name} · ${performanceTime(span.end - span.start)}`
|
|
3848
|
+
})
|
|
3849
|
+
);
|
|
3850
|
+
list.append(old);
|
|
3851
|
+
}
|
|
3852
|
+
body.append(list);
|
|
3853
|
+
return null;
|
|
3854
|
+
},
|
|
3855
|
+
'关闭'
|
|
3856
|
+
);
|
|
3857
|
+
}
|
|
3339
3858
|
async function showSessionTree() {
|
|
3340
3859
|
const sourceAgent = state.agent;
|
|
3341
3860
|
if (!sourceAgent) return toast('请选择 Agent');
|
|
@@ -3562,6 +4081,7 @@
|
|
|
3562
4081
|
$('openConfig').onclick = openConfig;
|
|
3563
4082
|
$('configClose').onclick = () => $('configDialog').close();
|
|
3564
4083
|
$('configSave').onclick = saveMailSettings;
|
|
4084
|
+
$('saveSotto').onclick = saveSottoSettings;
|
|
3565
4085
|
$('connect').onclick = () => openPair();
|
|
3566
4086
|
$('keyboardConnect').onclick = async () => {
|
|
3567
4087
|
try {
|
|
@@ -3665,16 +4185,7 @@
|
|
|
3665
4185
|
try {
|
|
3666
4186
|
await postAgent(agent.agentId, '/archive');
|
|
3667
4187
|
sessionKeyboard.unbind(agent.agentId);
|
|
3668
|
-
if (state.agent?.agentId === agent.agentId)
|
|
3669
|
-
state.agent = null;
|
|
3670
|
-
localStorage.removeItem('rpAgentId');
|
|
3671
|
-
$('agentTitle').textContent = '请选择 Agent';
|
|
3672
|
-
$('agentStatus').textContent = '';
|
|
3673
|
-
$('agentStateBadge').textContent = '未选择';
|
|
3674
|
-
$('agentStateBadge').className = 'state-badge state-none';
|
|
3675
|
-
$('contextUsage').hidden = true;
|
|
3676
|
-
$('messages').innerHTML = '<div class="empty">选择或创建 Agent 开始对话</div>';
|
|
3677
|
-
}
|
|
4188
|
+
if (state.agent?.agentId === agent.agentId) clearAgentSelection();
|
|
3678
4189
|
await refreshAgents();
|
|
3679
4190
|
toast('Agent 已 Archive');
|
|
3680
4191
|
} catch (e) {
|
|
@@ -3713,8 +4224,10 @@
|
|
|
3713
4224
|
};
|
|
3714
4225
|
$('mcp').onclick = () => mcpControls().catch(e => toast(e.message));
|
|
3715
4226
|
$('systemPrompt').onclick = () => showAgentSystemPrompt().catch(e => toast(e.message));
|
|
4227
|
+
$('switchPrompt').onclick = () => switchAgentPrompt().catch(e => toast(e.message));
|
|
3716
4228
|
$('openSystemPrompts').onclick = () => systemPromptManager().catch(e => toast(e.message));
|
|
3717
4229
|
$('sessionTree').onclick = () => showSessionTree().catch(e => toast(e.message));
|
|
4230
|
+
$('sessionPerformance').onclick = () => showSessionPerformance().catch(e => toast(e.message));
|
|
3718
4231
|
$('undo').onclick = () => revert().catch(e => toast(e.message));
|
|
3719
4232
|
$('controls').onclick = () => modelControls().catch(e => toast(e.message));
|
|
3720
4233
|
$('compact').onclick = async () => {
|
|
@@ -3732,13 +4245,22 @@
|
|
|
3732
4245
|
toast('Compact 完成');
|
|
3733
4246
|
}
|
|
3734
4247
|
};
|
|
4248
|
+
function prependInput(text) {
|
|
4249
|
+
$('input').value = text + ($('input').value ? `\n${$('input').value}` : '');
|
|
4250
|
+
}
|
|
3735
4251
|
$('prompt').onsubmit = async event => {
|
|
3736
4252
|
event.preventDefault();
|
|
3737
4253
|
if (!state.agent) return toast('请先创建或选择 Agent');
|
|
3738
4254
|
const text = $('input').value.trim();
|
|
3739
4255
|
if (!text) return;
|
|
3740
4256
|
closeCommandPicker();
|
|
3741
|
-
const
|
|
4257
|
+
const selectedMode = $('sendMode').value,
|
|
4258
|
+
mode =
|
|
4259
|
+
state.agent.status === 'streaming' || state.agent.status === 'waiting_for_user'
|
|
4260
|
+
? selectedMode === 'steer'
|
|
4261
|
+
? 'steer'
|
|
4262
|
+
: 'follow-up'
|
|
4263
|
+
: 'prompt',
|
|
3742
4264
|
agentId = state.agent.agentId;
|
|
3743
4265
|
$('messages').querySelector('.empty')?.remove();
|
|
3744
4266
|
// `!cmd` runs inside the Agent session (output is recorded in the Session); `!!cmd` keeps the output out of the model context.
|
|
@@ -3763,10 +4285,24 @@
|
|
|
3763
4285
|
if (mode !== 'prompt' && sent?.disposition)
|
|
3764
4286
|
toast(sent.disposition === 'queued' ? '已排队,等待当前回合' : '已送达当前回合');
|
|
3765
4287
|
} catch (e) {
|
|
3766
|
-
|
|
4288
|
+
// The composer is shared: only restore into it while the failed Agent is still selected.
|
|
4289
|
+
if (state.agent?.agentId === agentId) {
|
|
4290
|
+
prependInput(text);
|
|
4291
|
+
addCard('发送失败', e.message, 'error', true);
|
|
4292
|
+
} else {
|
|
4293
|
+
const pending = state.failedSends.get(agentId);
|
|
4294
|
+
state.failedSends.set(agentId, pending === undefined ? text : `${text}\n${pending}`);
|
|
4295
|
+
const agent = state.agents.find(item => item.agentId === agentId);
|
|
4296
|
+
toast(`发送到「${agent ? agentLabel(agent) : agentId}」失败:${e.message};切回该会话时会放回输入框`);
|
|
4297
|
+
}
|
|
3767
4298
|
}
|
|
3768
4299
|
};
|
|
3769
4300
|
$('input').onkeydown = event => {
|
|
4301
|
+
if (event.altKey && event.shiftKey && event.key.toLowerCase() === 'v' && !event.isComposing) {
|
|
4302
|
+
event.preventDefault();
|
|
4303
|
+
if (!event.repeat) void toggleVoiceInput();
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
3770
4306
|
if (!$('commandPicker').hidden) {
|
|
3771
4307
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
3772
4308
|
event.preventDefault();
|