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/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
- throw Error(body.error?.message || `HTTP ${response.status}`);
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 (backendId && splitResourceKey(state.mediaAgentId).backendId !== backendId) return;
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 = backends.some(backend => backend.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
- try {
1496
- const backendId = state.workspace.backendId;
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 (state.mediaRecorder && state.mediaRecorder.state === 'recording') {
2320
- state.mediaRecorder.stop();
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
- () => state.mediaRecorder?.state === 'recording' && state.mediaRecorder.stop(),
2344
- 60000
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 {backendId, remoteId} = splitResourceKey(agentId),
2368
- transcribeBackend = backendOf(backendId);
2369
- if (!transcribeBackend) {
2700
+ const transcribeBackend = backendOf(speechBackendId);
2701
+ if (transcribeBackend?.status !== 'connected') {
2370
2702
  $('voiceInput').textContent = '🎙';
2371
- return toast('该 Session 的 backend 已断开');
2703
+ $('voiceInput').disabled = false;
2704
+ return toast('语音识别 Backend 已断开');
2372
2705
  }
2373
2706
  try {
2374
- const response = await fetch(
2375
- transcribeBackend.base + `/api/v1/agents/${encodeURIComponent(remoteId)}/transcribe`,
2376
- {
2377
- method: 'POST',
2378
- headers: {
2379
- Authorization: `Bearer ${transcribeBackend.token}`,
2380
- 'Content-Type': mime,
2381
- 'X-Audio-Filename': mime.includes('mp4') ? 'recording.m4a' : 'recording.webm'
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').title = '开始语音输入';
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 mode = $('sendMode').value,
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
- addCard('发送失败', e.message, 'error', true);
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();