pi2web 0.2.6 → 0.2.12

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
@@ -69,6 +69,8 @@
69
69
  voiceEnabled: false,
70
70
  voiceSttEnabled: false,
71
71
  voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true',
72
+ // Per browser: 'enter' sends on Enter (Shift+Enter for a new line), 'ctrl-enter' on Ctrl/⌘+Enter.
73
+ enterSendMode: localStorage.rpEnterSendMode === 'enter' ? 'enter' : 'ctrl-enter',
72
74
  voiceAudio: {
73
75
  context: null,
74
76
  nextTime: 0,
@@ -79,12 +81,20 @@
79
81
  generation: 0
80
82
  },
81
83
  mediaRecorder: null,
84
+ voiceStream: null,
82
85
  mediaChunks: [],
83
86
  mediaStream: null,
84
87
  mediaTimer: null,
85
88
  mediaAgentId: null,
89
+ mediaBackendId: null,
90
+ /** Messages that failed after the user switched away; restored when that Agent is selected again. */
91
+ failedSends: new Map(),
92
+ /** Lost acknowledgements are notices, not drafts to send again. Kept across focus snapshots. */
93
+ uncertainSends: new Map(),
86
94
  mailBackendId: null,
87
95
  mailRequest: 0,
96
+ sottoBackendId: null,
97
+ sottoSettingsRequest: 0,
88
98
  mailNotificationsAvailable: false,
89
99
  mailSettings: {
90
100
  enabled: false,
@@ -169,15 +179,24 @@
169
179
  const body = await response.json().catch(() => ({}));
170
180
  if (!response.ok) {
171
181
  if (response.status === 401) onUnauthorized?.();
172
- throw Error(body.error?.message || `HTTP ${response.status}`);
182
+ const error = Error(body.error?.message || `HTTP ${response.status}`);
183
+ error.status = response.status;
184
+ throw error;
173
185
  }
174
186
  return body.data;
175
187
  }
176
188
  async function backendRequiresAuth(base) {
177
189
  try {
178
190
  return (await request(base, '', '/health')).authRequired !== false;
179
- } catch {
180
- return true;
191
+ } catch (error) {
192
+ if (error.status === 401) return true;
193
+ if (error.status === 403)
194
+ throw Error(
195
+ '服务拒绝访问(403):无认证服务默认仅允许本机访问;请检查服务地址、代理和 --no-auth 设置'
196
+ );
197
+ if (error.status === 404) throw Error('服务未提供 /health(404):请检查地址和远端版本');
198
+ if (error instanceof TypeError) throw Error('无法访问服务:请检查地址、网络及浏览器跨域限制(CORS)');
199
+ throw error;
181
200
  }
182
201
  }
183
202
  async function requestBackend(backendId, url, options = {}) {
@@ -245,8 +264,20 @@
245
264
  }
246
265
 
247
266
  function cancelVoiceRecording(backendId) {
267
+ if (
268
+ state.voiceStream &&
269
+ (!backendId ||
270
+ splitResourceKey(state.voiceStream.agentId).backendId === backendId ||
271
+ state.voiceStream.transcribeBackendId === backendId)
272
+ )
273
+ finishSottoStream(state.voiceStream, true);
248
274
  if (!state.mediaAgentId) return;
249
- if (backendId && splitResourceKey(state.mediaAgentId).backendId !== backendId) return;
275
+ if (
276
+ backendId &&
277
+ splitResourceKey(state.mediaAgentId).backendId !== backendId &&
278
+ state.mediaBackendId !== backendId
279
+ )
280
+ return;
250
281
  clearTimeout(state.mediaTimer);
251
282
  if (state.mediaRecorder) {
252
283
  state.mediaRecorder.onstop = null;
@@ -257,7 +288,9 @@
257
288
  state.mediaStream = null;
258
289
  state.mediaChunks = [];
259
290
  state.mediaAgentId = null;
291
+ state.mediaBackendId = null;
260
292
  $('voiceInput').textContent = '🎙';
293
+ $('voiceInput').disabled = false;
261
294
  $('voiceInput').classList.remove('recording');
262
295
  }
263
296
 
@@ -265,6 +298,7 @@
265
298
  cancelVoiceRecording(backendId);
266
299
  if (backendId && state.agent?.backendId !== backendId) return;
267
300
  state.agent = null;
301
+ updateVoiceAvailability();
268
302
  state.timeline = null;
269
303
  sendFocus();
270
304
  localStorage.removeItem('rpAgentId');
@@ -334,12 +368,52 @@
334
368
  }
335
369
  }
336
370
 
371
+ function applyVoiceStatus(backend, statusInfo) {
372
+ const voice = state.voiceStream;
373
+ if (
374
+ voice &&
375
+ voice.transcribeBackendId === backend.id &&
376
+ (!statusInfo.sottoStt?.enabled ||
377
+ statusInfo.sottoStt.endpoint !== backend.statusInfo?.sottoStt?.endpoint)
378
+ )
379
+ finishSottoStream(voice, true);
380
+ backend.statusInfo = statusInfo;
381
+ backend.voiceEnabled = Boolean(statusInfo.voiceCapabilities?.tts ?? statusInfo.voiceEnabled);
382
+ backend.voiceSttEnabled = Boolean(statusInfo.voiceCapabilities?.stt ?? statusInfo.voiceEnabled);
383
+ }
384
+ // The page is opened on the user's own pi2web server. Never route microphone audio to a remote Backend.
385
+ function localBackend() {
386
+ return (
387
+ connectedBackends().find(backend => {
388
+ if (backend.status !== 'connected') return false;
389
+ try {
390
+ const url = new URL(backend.base);
391
+ return (
392
+ url.origin === location.origin &&
393
+ url.pathname === '/' &&
394
+ !url.username &&
395
+ !url.password &&
396
+ !url.search &&
397
+ !url.hash
398
+ );
399
+ } catch {
400
+ return false;
401
+ }
402
+ }) || null
403
+ );
404
+ }
405
+ function transcriptionBackend() {
406
+ if (!state.agent || backendOf(state.agent.backendId)?.status !== 'connected') return null;
407
+ const backend = localBackend();
408
+ return backend?.voiceSttEnabled ? backend : null;
409
+ }
337
410
  function updateVoiceAvailability() {
338
411
  const backends = connectedBackends();
339
412
  state.voiceEnabled = backends.some(backend => backend.voiceEnabled);
340
- state.voiceSttEnabled = backends.some(backend => backend.voiceSttEnabled);
413
+ state.voiceSttEnabled = Boolean(transcriptionBackend());
341
414
  $('voicePlayback').hidden = !state.voiceEnabled;
342
- $('voiceInput').hidden = !state.voiceSttEnabled;
415
+ $('voiceInput').hidden = !state.voiceSttEnabled && !state.voiceStream;
416
+ $('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
343
417
  if (!state.voiceEnabled) stopVoiceAudio();
344
418
  updateVoiceButton();
345
419
  }
@@ -382,10 +456,9 @@
382
456
  statusInfo,
383
457
  authRequired,
384
458
  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)
459
+ reconnectAttempt: 0
388
460
  });
461
+ applyVoiceStatus(backend, statusInfo);
389
462
 
390
463
  try {
391
464
  await refreshBackendData(backend);
@@ -1231,9 +1304,16 @@
1231
1304
  nodes = agent ? activityView.bar(state.activities.get(agent.agentId), agent.backendId) : [];
1232
1305
  bar.replaceChildren(...nodes);
1233
1306
  bar.hidden = !nodes.length;
1307
+ // A pending safe pause can be withdrawn with the same button until a model request was withheld.
1308
+ const pause = agent ? state.activities.get(agent.agentId)?.pauseRequested : null,
1309
+ button = $('safePause');
1310
+ button.textContent = pause?.reached ? '暂停中…' : pause ? '取消暂停' : '安全暂停';
1311
+ button.disabled = !!pause?.reached;
1234
1312
  }
1235
1313
  function setAgentActivity(agentId, activity) {
1236
- if (window.ActivityView.isBusy(activity)) state.activities.set(agentId, activity);
1314
+ // A pause request outlives the run (agent_end → agent_settled); its safe_pause event clears it.
1315
+ if (window.ActivityView.isBusy(activity) || activity?.pauseRequested)
1316
+ state.activities.set(agentId, activity);
1237
1317
  else state.activities.delete(agentId);
1238
1318
  sessionKeyboard.setActivity(agentId, activity);
1239
1319
  refreshToolCards(agentId);
@@ -1253,6 +1333,12 @@
1253
1333
  sessionKeyboard.updateAgent(agent);
1254
1334
  renderAgentList();
1255
1335
  updateAgentHeader();
1336
+ if (state.agent?.agentId === agentId) {
1337
+ const picker = $('sendMode');
1338
+ if (status === 'streaming' || status === 'waiting_for_user') {
1339
+ if (picker.value === 'prompt') picker.value = 'follow-up';
1340
+ } else picker.value = 'prompt';
1341
+ }
1256
1342
  return agent;
1257
1343
  }
1258
1344
  function updateAgentHeader(agent = state.agent) {
@@ -1490,23 +1576,9 @@
1490
1576
  }
1491
1577
  const autoBindNewSession = agent => sessionKeyboard.autoBind(agent.agentId);
1492
1578
  async function startAgent(relativePath) {
1493
- if (!state.workspace) return toast('请先选择 Workspace');
1494
1579
  $('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
- }
1580
+ $('agentCwd').value = relativePath;
1581
+ await createAgent();
1510
1582
  }
1511
1583
  async function openTerminal(relativePath) {
1512
1584
  if (!state.workspace) return toast('请先选择 Workspace');
@@ -1542,14 +1614,24 @@
1542
1614
  }
1543
1615
  }
1544
1616
  async function selectAgent(agent, openView = true) {
1617
+ if (state.agent?.agentId !== agent.agentId) cancelVoiceRecording();
1545
1618
  state.selectedKind = 'agent';
1546
1619
  state.terminalWs?.close();
1547
1620
  state.terminalWs = null;
1548
1621
  state.agent = agent;
1622
+ updateVoiceAvailability();
1623
+ const failed = state.failedSends.get(agent.agentId);
1624
+ if (failed !== undefined) {
1625
+ state.failedSends.delete(agent.agentId);
1626
+ prependInput(failed);
1627
+ toast('之前发送失败的消息已放回输入框');
1628
+ }
1549
1629
  localStorage.rpAgentId = agent.agentId;
1550
1630
  localStorage.rpSelectedKind = 'agent';
1551
1631
  showAgentView();
1552
1632
  updateAgentHeader();
1633
+ $('sendMode').value =
1634
+ agent.status === 'streaming' || agent.status === 'waiting_for_user' ? 'follow-up' : 'prompt';
1553
1635
  if (openView) navigateMobile('agent');
1554
1636
  void syncWorkspaceForSelection(agent);
1555
1637
  if (ownsPath(state.workspace, agent))
@@ -1959,6 +2041,7 @@
1959
2041
  box.scrollTop = box.scrollHeight;
1960
2042
  } else box.innerHTML = '<div class="empty">正在加载…</div>';
1961
2043
  if (!sendFocus(true) && !cached) box.innerHTML = '<div class="empty">等待连接 backend…</div>';
2044
+ for (const text of state.uncertainSends.get(agent.agentId) || []) showUncertainSend(agent.agentId, text);
1962
2045
  }
1963
2046
  /** Ask for a new snapshot, e.g. when the branch changed. */
1964
2047
  function refocus() {
@@ -1975,6 +2058,8 @@
1975
2058
  timeline.ready = true;
1976
2059
  applyLive(message.live);
1977
2060
  restoreExtensionRequests(message.agentId, {dialogRequests: message.dialogRequests || []});
2061
+ for (const text of state.uncertainSends.get(message.agentId) || [])
2062
+ showUncertainSend(message.agentId, text);
1978
2063
  saveTimelineCache();
1979
2064
  box.scrollTop = box.scrollHeight;
1980
2065
  }
@@ -2188,6 +2273,7 @@
2188
2273
  bar = $('queueBar');
2189
2274
  if (!queue) {
2190
2275
  bar.hidden = true;
2276
+ $('queueItems').replaceChildren();
2191
2277
  return;
2192
2278
  }
2193
2279
  const parts = [];
@@ -2196,6 +2282,39 @@
2196
2282
  const preview = [...queue.steering, ...queue.followUp][0] || '';
2197
2283
  $('queueText').textContent =
2198
2284
  `排队中:${parts.join(' · ')}${preview ? ` — ${preview.replace(/\s+/g, ' ').slice(0, 60)}` : ''}`;
2285
+ const items = $('queueItems');
2286
+ items.replaceChildren();
2287
+ for (const [from, label, target] of [
2288
+ ['steering', 'Steer', 'Follow-up'],
2289
+ ['followUp', 'Follow-up', 'Steer']
2290
+ ]) {
2291
+ queue[from].forEach((text, index) => {
2292
+ const row = document.createElement('div');
2293
+ row.className = 'queue-item';
2294
+ const preview = document.createElement('span');
2295
+ preview.textContent = `${label}: ${text}`;
2296
+ preview.title = text;
2297
+ const button = document.createElement('button');
2298
+ button.type = 'button';
2299
+ button.textContent = `改为 ${target}`;
2300
+ button.onclick = async () => {
2301
+ const agentId = state.agent.agentId;
2302
+ button.disabled = true;
2303
+ try {
2304
+ await postAgent(agentId, '/queue/move', {from, index, expected: text});
2305
+ } catch (error) {
2306
+ toast(`切换失败:${error.message}`);
2307
+ try {
2308
+ setAgentQueue(agentId, await requestAgent(agentId, '/queue'));
2309
+ } catch {}
2310
+ } finally {
2311
+ button.disabled = false;
2312
+ }
2313
+ };
2314
+ row.append(preview, button);
2315
+ items.append(row);
2316
+ });
2317
+ }
2199
2318
  bar.hidden = false;
2200
2319
  }
2201
2320
  function restoreExtensionRequests(agentId, activity) {
@@ -2313,11 +2432,225 @@
2313
2432
  }
2314
2433
  } else stopVoiceAudio();
2315
2434
  }
2435
+ function updateSottoDraft(voice) {
2436
+ if (state.voiceStream !== voice || state.agent?.agentId !== voice.agentId) return;
2437
+ const input = voice.input,
2438
+ text = [...voice.finals.values(), voice.partial].filter(Boolean).join(' ');
2439
+ if (text === voice.inserted) return;
2440
+ if (input.value.slice(voice.at, voice.at + voice.inserted.length) !== voice.inserted) {
2441
+ finishSottoStream(voice, true);
2442
+ toast('输入框内容已被修改,语音输入已停止;现有文字不会覆盖');
2443
+ return;
2444
+ }
2445
+ input.setRangeText(text, voice.at, voice.at + voice.inserted.length, 'end');
2446
+ voice.inserted = text;
2447
+ }
2448
+ function finishSottoStream(voice, abort = false, reason = '') {
2449
+ if (state.voiceStream !== voice) return;
2450
+ state.voiceStream = null;
2451
+ clearTimeout(voice.timer);
2452
+ clearTimeout(voice.idleTimer);
2453
+ voice.stream?.getTracks().forEach(track => {
2454
+ track.onended = null;
2455
+ track.stop();
2456
+ });
2457
+ voice.node?.disconnect();
2458
+ voice.source?.disconnect();
2459
+ voice.sink?.disconnect();
2460
+ if (voice.node?.port) voice.node.port.onmessage = null;
2461
+ if (voice.node) voice.node.onaudioprocess = null;
2462
+ if (voice.context) void voice.context.close().catch(() => {});
2463
+ if (voice.socket?.readyState === WebSocket.OPEN || voice.socket?.readyState === WebSocket.CONNECTING)
2464
+ voice.socket.close();
2465
+ $('voiceInput').disabled = false;
2466
+ $('voiceInput').classList.remove('recording');
2467
+ $('voiceInput').textContent = '🎙';
2468
+ $('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
2469
+ if (
2470
+ abort &&
2471
+ voice.partial &&
2472
+ voice.input.value.slice(voice.at, voice.at + voice.inserted.length) === voice.inserted
2473
+ ) {
2474
+ const text = [...voice.finals.values()].filter(Boolean).join(' ');
2475
+ voice.input.setRangeText(text, voice.at, voice.at + voice.inserted.length, 'end');
2476
+ }
2477
+ if (reason) toast(reason);
2478
+ }
2479
+ function finishSottoAudio(voice) {
2480
+ if (state.voiceStream !== voice || voice.phase !== 'draining') return;
2481
+ if (voice.socket.readyState !== WebSocket.OPEN)
2482
+ return finishSottoStream(voice, true, '语音连接已断开,无法完成识别');
2483
+ voice.phase = 'finishing';
2484
+ voice.node?.disconnect();
2485
+ voice.socket.send(JSON.stringify({type: 'stop'}));
2486
+ clearTimeout(voice.timer);
2487
+ voice.timer = setTimeout(() => finishSottoStream(voice, true, '等待语音识别结果超时'), 12000);
2488
+ }
2489
+ function stopSottoStream(voice) {
2490
+ if (state.voiceStream !== voice) return;
2491
+ if (voice.phase === 'starting') return finishSottoStream(voice, true);
2492
+ if (voice.phase !== 'recording') return;
2493
+ voice.phase = 'draining';
2494
+ clearTimeout(voice.idleTimer);
2495
+ voice.source?.disconnect();
2496
+ voice.stream.getTracks().forEach(track => {
2497
+ track.onended = null;
2498
+ track.stop();
2499
+ });
2500
+ $('voiceInput').classList.remove('recording');
2501
+ $('voiceInput').textContent = '…';
2502
+ $('voiceInput').disabled = true;
2503
+ // Keep accepting queued PCM until the worklet confirms that its final buffer was sent.
2504
+ if (voice.node?.port) {
2505
+ voice.timer = setTimeout(() => finishSottoStream(voice, true, '麦克风尾部音频处理超时'), 3000);
2506
+ voice.node.port.postMessage({type: 'flush'});
2507
+ } else finishSottoAudio(voice);
2508
+ }
2509
+ function sendSottoPcm(voice, pcm) {
2510
+ if (state.voiceStream !== voice || !['recording', 'draining'].includes(voice.phase)) return;
2511
+ if (voice.socket.readyState !== WebSocket.OPEN || voice.socket.bufferedAmount >= 500000)
2512
+ return finishSottoStream(voice, true, '实时语音连接中断或处理不过来,录音已停止');
2513
+ voice.socket.send(pcm);
2514
+ }
2515
+ // Dictation survives switching views; it ends when the user stops it or nothing new is recognised.
2516
+ const SOTTO_IDLE_MS = 60000;
2517
+ function touchSottoIdle(voice) {
2518
+ clearTimeout(voice.idleTimer);
2519
+ voice.idleTimer = setTimeout(() => {
2520
+ if (state.voiceStream !== voice || voice.phase !== 'recording') return;
2521
+ toast('1 分钟没有新的识别文字,语音输入已自动停止');
2522
+ stopSottoStream(voice);
2523
+ }, SOTTO_IDLE_MS);
2524
+ }
2525
+ async function startSottoStream() {
2526
+ if (state.voiceStream) return;
2527
+ if (!navigator.mediaDevices?.getUserMedia) return toast('当前浏览器不支持麦克风录音');
2528
+ const Audio = window.AudioContext || window.webkitAudioContext;
2529
+ if (!Audio) return toast('当前浏览器不支持实时音频采集');
2530
+ const agentId = state.agent.agentId,
2531
+ speechBackend = transcriptionBackend(),
2532
+ input = $('input');
2533
+ // One owner from permission acquisition through final result. Cancelling invalidates pending awaits.
2534
+ const voice = {
2535
+ agentId,
2536
+ transcribeBackendId: speechBackend.id,
2537
+ input,
2538
+ at: input.selectionStart,
2539
+ inserted: '',
2540
+ finals: new Map(),
2541
+ partial: '',
2542
+ phase: 'starting',
2543
+ stream: null,
2544
+ context: null,
2545
+ socket: null,
2546
+ timer: null,
2547
+ idleTimer: null,
2548
+ node: null,
2549
+ source: null,
2550
+ sink: null
2551
+ };
2552
+ state.voiceStream = voice;
2553
+ $('voiceInput').textContent = '…';
2554
+ $('voiceInput').title = '正在启动语音输入,点击取消';
2555
+ try {
2556
+ const stream = await navigator.mediaDevices.getUserMedia({audio: true});
2557
+ if (state.voiceStream !== voice) {
2558
+ stream.getTracks().forEach(track => track.stop());
2559
+ return;
2560
+ }
2561
+ voice.stream = stream;
2562
+ stream.getTracks().forEach(track => {
2563
+ track.onended = () => finishSottoStream(voice, true, '麦克风已断开或录音权限被撤销');
2564
+ });
2565
+ const context = (voice.context = new Audio());
2566
+ voice.timer = setTimeout(() => finishSottoStream(voice, true, '启动实时语音服务超时'), 30000);
2567
+ if (context.audioWorklet && window.AudioWorkletNode)
2568
+ await context.audioWorklet.addModule('/voice-pcm.js');
2569
+ if (state.voiceStream !== voice) return;
2570
+ if (state.agent?.agentId !== agentId) throw Error('会话已切换');
2571
+ const backend = backendOf(voice.transcribeBackendId);
2572
+ if (backend?.status !== 'connected' || !backend.statusInfo?.sottoStt?.enabled)
2573
+ throw Error('语音识别 Backend 已断开');
2574
+ const url = backend.base.replace(/^http/, 'ws') + '/api/v1/transcribe/ws';
2575
+ const socket = (voice.socket = new WebSocket(
2576
+ url,
2577
+ backend.token ? ['access-token.' + backend.token] : []
2578
+ ));
2579
+ socket.onmessage = event => {
2580
+ if (state.voiceStream !== voice) return;
2581
+ let msg;
2582
+ try {
2583
+ msg = JSON.parse(event.data);
2584
+ } catch {
2585
+ return;
2586
+ }
2587
+ if (msg.type === 'ready') {
2588
+ if (voice.phase !== 'starting') return;
2589
+ try {
2590
+ socket.send(JSON.stringify({type: 'start', sample_rate: context.sampleRate, format: 'float32'}));
2591
+ voice.source = context.createMediaStreamSource(stream);
2592
+ voice.sink = context.createGain();
2593
+ voice.sink.gain.value = 0;
2594
+ if (context.audioWorklet && window.AudioWorkletNode) {
2595
+ voice.node = new AudioWorkletNode(context, 'voice-pcm');
2596
+ voice.node.port.onmessage = e =>
2597
+ e.data?.type === 'flushed' ? finishSottoAudio(voice) : sendSottoPcm(voice, e.data);
2598
+ } else {
2599
+ voice.node = context.createScriptProcessor(4096, 1, 1);
2600
+ voice.node.onaudioprocess = e =>
2601
+ sendSottoPcm(voice, new Float32Array(e.inputBuffer.getChannelData(0)).buffer);
2602
+ }
2603
+ voice.phase = 'recording';
2604
+ voice.source.connect(voice.node);
2605
+ voice.node.connect(voice.sink);
2606
+ voice.sink.connect(context.destination);
2607
+ void context
2608
+ .resume()
2609
+ .catch(error => finishSottoStream(voice, true, `无法启动麦克风:${error.message}`));
2610
+ clearTimeout(voice.timer);
2611
+ touchSottoIdle(voice);
2612
+ $('voiceInput').textContent = '■';
2613
+ $('voiceInput').title = '正在录音,点击停止(停顿不会结束录音)';
2614
+ $('voiceInput').classList.add('recording');
2615
+ } catch (error) {
2616
+ finishSottoStream(voice, true, `无法采集音频:${error.message}`);
2617
+ }
2618
+ } else if (msg.type === 'partial' && voice.phase === 'recording') {
2619
+ const text = String(msg.text || '');
2620
+ if (text && text !== voice.partial) touchSottoIdle(voice);
2621
+ voice.partial = text;
2622
+ updateSottoDraft(voice);
2623
+ } else if (msg.type === 'final') {
2624
+ // A final ends one sentence, not the recording. Only the user's stop ends capture.
2625
+ const text = String(msg.text || '');
2626
+ if (text && voice.phase === 'recording') touchSottoIdle(voice);
2627
+ voice.finals.set(msg.segment, text);
2628
+ voice.partial = '';
2629
+ updateSottoDraft(voice);
2630
+ } else if (msg.type === 'error') {
2631
+ finishSottoStream(voice, true, `Sotto 语音识别:${msg.message || '未知错误'}`);
2632
+ }
2633
+ };
2634
+ socket.onerror = () => finishSottoStream(voice, true, '无法连接 Sotto 实时语音服务');
2635
+ socket.onclose = event => {
2636
+ const expected = voice.phase === 'finishing' && event.code === 1000;
2637
+ finishSottoStream(voice, !expected, expected ? '' : `实时语音连接意外断开(${event.code})`);
2638
+ };
2639
+ } catch (error) {
2640
+ finishSottoStream(voice, true, `无法开始语音输入:${error.message}`);
2641
+ }
2642
+ }
2316
2643
  async function toggleVoiceInput() {
2644
+ if (state.voiceStream) return stopSottoStream(state.voiceStream);
2645
+ if ($('voiceInput').disabled) return;
2317
2646
  if (!state.voiceSttEnabled) return toast('服务端未启用语音识别');
2318
2647
  if (!state.agent) return toast('请先选择 Agent');
2319
- if (state.mediaRecorder && state.mediaRecorder.state === 'recording') {
2320
- state.mediaRecorder.stop();
2648
+ if (transcriptionBackend()?.statusInfo?.sottoStt?.enabled) return startSottoStream();
2649
+ if (state.mediaRecorder) {
2650
+ if (state.mediaRecorder.state === 'recording') {
2651
+ $('voiceInput').disabled = true;
2652
+ state.mediaRecorder.stop();
2653
+ }
2321
2654
  return;
2322
2655
  }
2323
2656
  if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)
@@ -2334,16 +2667,26 @@
2334
2667
  if (event.data.size) state.mediaChunks.push(event.data);
2335
2668
  };
2336
2669
  state.mediaAgentId = state.agent.agentId;
2670
+ state.mediaBackendId = transcriptionBackend()?.id;
2337
2671
  state.mediaRecorder.onstop = () => void transcribeVoice();
2338
2672
  state.mediaRecorder.start(250);
2339
2673
  $('voiceInput').textContent = '■';
2340
2674
  $('voiceInput').title = '停止并识别';
2341
2675
  $('voiceInput').classList.add('recording');
2342
- state.mediaTimer = setTimeout(
2343
- () => state.mediaRecorder?.state === 'recording' && state.mediaRecorder.stop(),
2344
- 60000
2345
- );
2676
+ state.mediaTimer = setTimeout(() => {
2677
+ if (state.mediaRecorder?.state === 'recording') {
2678
+ $('voiceInput').disabled = true;
2679
+ state.mediaRecorder.stop();
2680
+ }
2681
+ }, 60000);
2346
2682
  } catch (error) {
2683
+ state.mediaStream?.getTracks().forEach(track => track.stop());
2684
+ state.mediaStream = null;
2685
+ state.mediaRecorder = null;
2686
+ state.mediaChunks = [];
2687
+ state.mediaAgentId = null;
2688
+ state.mediaBackendId = null;
2689
+ $('voiceInput').disabled = false;
2347
2690
  toast(`无法录音:${error.message}`);
2348
2691
  }
2349
2692
  }
@@ -2352,37 +2695,38 @@
2352
2695
  const recorder = state.mediaRecorder,
2353
2696
  mime = recorder?.mimeType || 'audio/webm',
2354
2697
  blob = new Blob(state.mediaChunks, {type: mime}),
2355
- agentId = state.mediaAgentId;
2698
+ agentId = state.mediaAgentId,
2699
+ speechBackendId = state.mediaBackendId;
2356
2700
  state.mediaStream?.getTracks().forEach(track => track.stop());
2357
2701
  state.mediaRecorder = null;
2358
2702
  state.mediaStream = null;
2359
2703
  state.mediaChunks = [];
2360
2704
  state.mediaAgentId = null;
2705
+ state.mediaBackendId = null;
2361
2706
  $('voiceInput').textContent = '…';
2707
+ $('voiceInput').disabled = true;
2362
2708
  $('voiceInput').classList.remove('recording');
2363
2709
  if (!agentId) {
2364
2710
  $('voiceInput').textContent = '🎙';
2711
+ $('voiceInput').disabled = false;
2365
2712
  return;
2366
2713
  }
2367
- const {backendId, remoteId} = splitResourceKey(agentId),
2368
- transcribeBackend = backendOf(backendId);
2369
- if (!transcribeBackend) {
2714
+ const transcribeBackend = backendOf(speechBackendId);
2715
+ if (transcribeBackend?.status !== 'connected') {
2370
2716
  $('voiceInput').textContent = '🎙';
2371
- return toast('该 Session 的 backend 已断开');
2717
+ $('voiceInput').disabled = false;
2718
+ return toast('语音识别 Backend 已断开');
2372
2719
  }
2373
2720
  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
- );
2721
+ const response = await fetch(transcribeBackend.base + '/api/v1/transcribe', {
2722
+ method: 'POST',
2723
+ headers: {
2724
+ ...(transcribeBackend.token ? {Authorization: `Bearer ${transcribeBackend.token}`} : {}),
2725
+ 'Content-Type': mime,
2726
+ 'X-Audio-Filename': mime.includes('mp4') ? 'recording.m4a' : 'recording.webm'
2727
+ },
2728
+ body: blob
2729
+ });
2386
2730
  const body = await response.json().catch(() => ({}));
2387
2731
  if (!response.ok) throw Error(body.error?.message || `HTTP ${response.status}`);
2388
2732
  const text = body.data?.text?.trim();
@@ -2397,7 +2741,8 @@
2397
2741
  toast(`语音识别失败:${error.message}`);
2398
2742
  } finally {
2399
2743
  $('voiceInput').textContent = '🎙';
2400
- $('voiceInput').title = '开始语音输入';
2744
+ $('voiceInput').disabled = false;
2745
+ $('voiceInput').title = '开始语音输入(输入框内 Alt+Shift+V)';
2401
2746
  }
2402
2747
  }
2403
2748
  const handledDrafts = new Set();
@@ -2483,6 +2828,11 @@
2483
2828
  setAgentActivity(message.agentId, ev.activity);
2484
2829
  return;
2485
2830
  }
2831
+ if (ev.type === 'safe_pause') {
2832
+ if (selected && ev.state === 'paused') toast('已安全暂停:工具结果已保存,发送消息即可继续');
2833
+ else if (selected && ev.state === 'finished') toast('运行已结束,未到达暂停点');
2834
+ return;
2835
+ }
2486
2836
  if (ev.type === 'agent_unloaded') {
2487
2837
  setAgentStatus(message.agentId, 'unloaded');
2488
2838
  setAgentQueue(message.agentId, null);
@@ -2773,6 +3123,58 @@
2773
3123
  : `Backend「${backend.name}」未配置 MailDispatch endpoint、API key 和通知邮箱。`;
2774
3124
  }
2775
3125
 
3126
+ function sottoConfigBackend() {
3127
+ return localBackend();
3128
+ }
3129
+ async function loadSottoSettings() {
3130
+ const backend = sottoConfigBackend();
3131
+ const requestId = ++state.sottoSettingsRequest;
3132
+ state.sottoBackendId = null;
3133
+ $('saveSotto').disabled = true;
3134
+ if (!backend) {
3135
+ $('sottoSettingsStatus').textContent = '请先连接提供当前页面的本机 Backend';
3136
+ return;
3137
+ }
3138
+ $('sottoSettingsStatus').textContent = `正在读取「${backend.name}」的语音输入设置…`;
3139
+ try {
3140
+ const settings = await requestBackend(backend.id, '/api/v1/sotto-stt');
3141
+ if (requestId !== state.sottoSettingsRequest || !$('configDialog').open) return;
3142
+ state.sottoBackendId = backend.id;
3143
+ $('sottoEnabled').checked = settings.enabled;
3144
+ $('sottoEndpoint').value = settings.endpoint;
3145
+ $('saveSotto').disabled = false;
3146
+ $('sottoSettingsStatus').textContent =
3147
+ `语音识别固定使用本机 Backend「${backend.name}」的设置;远端会话共用此配置。`;
3148
+ } catch (error) {
3149
+ if (requestId === state.sottoSettingsRequest)
3150
+ $('sottoSettingsStatus').textContent = `读取失败:${error.message}`;
3151
+ }
3152
+ }
3153
+ async function saveSottoSettings() {
3154
+ const backendId = state.sottoBackendId;
3155
+ if (!backendId) return toast('请先连接 Backend 并读取设置');
3156
+ $('saveSotto').disabled = true;
3157
+ try {
3158
+ const settings = await requestBackend(backendId, '/api/v1/sotto-stt', {
3159
+ method: 'PUT',
3160
+ body: JSON.stringify({enabled: $('sottoEnabled').checked, endpoint: $('sottoEndpoint').value.trim()})
3161
+ });
3162
+ const owner = backendOf(backendId);
3163
+ if (owner) applyVoiceStatus(owner, {...owner.statusInfo, sottoStt: settings});
3164
+ const statusInfo = await requestBackend(backendId, '/api/v1/system/status');
3165
+ const backend = backendOf(backendId);
3166
+ if (backend) applyVoiceStatus(backend, statusInfo);
3167
+ updateVoiceAvailability();
3168
+ $('sottoSettingsStatus').textContent = settings.enabled
3169
+ ? 'Sotto 已连通;输入框旁的麦克风可用,转写使用 Sotto 的同一个 ASR 实例。'
3170
+ : 'Sotto 语音输入已关闭。';
3171
+ toast('语音输入设置已保存');
3172
+ } catch (error) {
3173
+ toast(`Sotto 设置失败:${error.message}`);
3174
+ } finally {
3175
+ $('saveSotto').disabled = !backendOf(backendId);
3176
+ }
3177
+ }
2776
3178
  function updateConfigUi() {
2777
3179
  const connected = connectedBackends().length;
2778
3180
  $('configConnectionStatus').textContent = connected ? `已连接 ${connected} 个 backend` : '当前未配对';
@@ -2787,6 +3189,7 @@
2787
3189
  updateConfigUi();
2788
3190
  renderKeyboardBindings();
2789
3191
  if (!$('configDialog').open) $('configDialog').showModal();
3192
+ void loadSottoSettings();
2790
3193
  }
2791
3194
 
2792
3195
  async function saveMailSettings() {
@@ -2818,6 +3221,11 @@
2818
3221
  }
2819
3222
  }
2820
3223
 
3224
+ const el = (tag, props = {}, ...children) => {
3225
+ const node = Object.assign(document.createElement(tag), props);
3226
+ node.append(...children);
3227
+ return node;
3228
+ };
2821
3229
  function modal(title, build, okText = '确定') {
2822
3230
  const dialog = $('modal'),
2823
3231
  body = $('modalBody');
@@ -2931,18 +3339,19 @@
2931
3339
  const workspace = state.workspace;
2932
3340
  if (!workspace) return toast('请先选择 Workspace');
2933
3341
  const cwd = $('agentCwd').value.trim() || '.';
2934
- let sessions,
2935
- templates = [];
3342
+ const preferenceKey = `rpLastTemplate:${workspace.backendId}`;
3343
+ let templates = [],
3344
+ modelCatalog;
2936
3345
  try {
2937
- [sessions, templates] = await Promise.all([
2938
- requestBackend(
2939
- workspace.backendId,
2940
- `/api/v1/sessions?workspaceId=${encodeURIComponent(workspace.id)}&path=${encodeURIComponent(cwd)}`
2941
- ),
3346
+ [templates, modelCatalog] = await Promise.all([
2942
3347
  // Older backends have no templates endpoint; they simply offer Pi's default prompt.
2943
3348
  requestBackend(workspace.backendId, '/api/v1/agent-templates')
2944
3349
  .then(data => data.templates || [])
2945
- .catch(() => [])
3350
+ .catch(() => []),
3351
+ requestBackend(
3352
+ workspace.backendId,
3353
+ `/api/v1/agents/models?workspaceId=${encodeURIComponent(workspace.id)}&relativeCwd=${encodeURIComponent(cwd)}`
3354
+ ).catch(() => null)
2946
3355
  ]);
2947
3356
  } catch (error) {
2948
3357
  return toast(error.message);
@@ -2951,20 +3360,78 @@
2951
3360
  '新建 Agent',
2952
3361
  body => {
2953
3362
  const c = field(body, '工作路径', cwd);
2954
- const l = document.createElement('label');
2955
- l.textContent = 'Session';
2956
- const s = document.createElement('select');
2957
- s.innerHTML =
2958
- '<option value="">新 Session</option>' +
2959
- sessions
2960
- .map(
2961
- x =>
2962
- `<option value="${esc(x.path)}">${esc(x.name || x.sessionName || x.id || x.path)} · ${esc(x.modified || '')}</option>`
2963
- )
2964
- .join('');
2965
- l.append(s);
2966
- body.append(l);
3363
+ const modelLabel = document.createElement('label');
3364
+ modelLabel.textContent = '模型';
3365
+ const modelSelect = document.createElement('select');
3366
+ const renderModels = (catalog, selected = modelSelect.value) => {
3367
+ modelSelect.replaceChildren();
3368
+ const fallback = document.createElement('option');
3369
+ fallback.value = '';
3370
+ fallback.textContent = 'Pi 默认模型';
3371
+ modelSelect.append(fallback);
3372
+ for (const model of catalog?.models || []) {
3373
+ const option = document.createElement('option');
3374
+ option.value = JSON.stringify([model.provider, model.id]);
3375
+ option.textContent = `${model.provider} / ${model.name || model.id}`;
3376
+ modelSelect.append(option);
3377
+ }
3378
+ if (selected && [...modelSelect.options].some(option => option.value === selected))
3379
+ modelSelect.value = selected;
3380
+ else if (catalog?.model) {
3381
+ const current = JSON.stringify([catalog.model.provider, catalog.model.id]);
3382
+ if ([...modelSelect.options].some(option => option.value === current))
3383
+ modelSelect.value = current;
3384
+ }
3385
+ if (selected && modelSelect.value !== selected)
3386
+ toast('所选模型在新工作路径不可用,已改用当前默认模型;请确认选择。');
3387
+ };
3388
+ renderModels(modelCatalog);
3389
+ modelLabel.append(modelSelect);
3390
+ body.append(modelLabel);
3391
+ let modelRequest = 0;
3392
+ const submit = $('modalOk');
3393
+ $('modal').addEventListener(
3394
+ 'close',
3395
+ () => {
3396
+ ++modelRequest;
3397
+ submit.disabled = false;
3398
+ },
3399
+ {once: true}
3400
+ );
3401
+ c.addEventListener('change', async () => {
3402
+ const request = ++modelRequest;
3403
+ const selected = modelSelect.value;
3404
+ submit.disabled = true;
3405
+ modelSelect.disabled = true;
3406
+ try {
3407
+ const catalog = await requestBackend(
3408
+ workspace.backendId,
3409
+ `/api/v1/agents/models?workspaceId=${encodeURIComponent(workspace.id)}&relativeCwd=${encodeURIComponent(c.value.trim() || '.')}`
3410
+ );
3411
+ if (request === modelRequest) {
3412
+ modelCatalog = catalog;
3413
+ renderModels(catalog, selected);
3414
+ }
3415
+ } catch (error) {
3416
+ if (request === modelRequest) {
3417
+ modelCatalog = null;
3418
+ renderModels(null, '');
3419
+ toast(`读取模型失败:${error.message};模型选择已重置,请确认后创建。`);
3420
+ }
3421
+ } finally {
3422
+ if (request === modelRequest) {
3423
+ modelSelect.disabled = false;
3424
+ submit.disabled = false;
3425
+ }
3426
+ }
3427
+ });
2967
3428
  const t = templateSelect(body, templates);
3429
+ const preferred = globalThis.localStorage?.getItem(preferenceKey);
3430
+ if (preferred && [...t.options].some(option => option.value === preferred)) t.value = preferred;
3431
+ else if (preferred) {
3432
+ globalThis.localStorage?.removeItem(preferenceKey);
3433
+ toast('上次使用的 System Prompt 模板已删除,改用 Pi 默认');
3434
+ }
2968
3435
  const preview = promptPreviewBox(body, () => ({
2969
3436
  backendId: workspace.backendId,
2970
3437
  workspaceId: workspace.id,
@@ -2975,8 +3442,8 @@
2975
3442
  t.onchange = () => preview.reset();
2976
3443
  return () => ({
2977
3444
  cwd: c.value.trim() || '.',
2978
- sessionFile: s.value || undefined,
2979
- templateId: t.value || undefined
3445
+ templateId: t.value || undefined,
3446
+ model: modelSelect.value ? JSON.parse(modelSelect.value) : null
2980
3447
  });
2981
3448
  },
2982
3449
  '创建'
@@ -2986,11 +3453,12 @@
2986
3453
  const created = await postBackend(workspace.backendId, '/api/v1/agents', {
2987
3454
  workspaceId: workspace.id,
2988
3455
  relativeCwd: result.cwd,
2989
- sessionFile: result.sessionFile,
2990
- templateId: result.templateId
3456
+ templateId: result.templateId,
3457
+ ...(result.model ? {model: {provider: result.model[0], modelId: result.model[1]}} : {})
2991
3458
  });
3459
+ localStorage.setItem(preferenceKey, result.templateId || '');
2992
3460
  const agent = stampNewItem(workspace.backendId, created);
2993
- const binding = result.sessionFile ? null : autoBindNewSession(agent);
3461
+ const binding = autoBindNewSession(agent);
2994
3462
  await refreshAgents();
2995
3463
  await selectAgent(agent);
2996
3464
  if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot + 1}`);
@@ -3114,6 +3582,31 @@
3114
3582
  '关闭'
3115
3583
  );
3116
3584
  }
3585
+ async function switchAgentPrompt() {
3586
+ const agent = state.agent;
3587
+ if (!agent) return toast('请先选择 Agent');
3588
+ const [session, catalog] = await Promise.all([
3589
+ requestAgent(agent.agentId, '/session'),
3590
+ requestBackend(agent.backendId, '/api/v1/agent-templates')
3591
+ ]);
3592
+ if (agent.profile !== 'default' || session.entries?.some(entry => entry.type === 'message'))
3593
+ return toast('会话开始后不能切换 System Prompt,请新建会话');
3594
+ const choice = await modal(
3595
+ '切换 System Prompt(仅首次对话前)',
3596
+ body => {
3597
+ const picker = templateSelect(body, catalog.templates || []);
3598
+ picker.value = agent.template?.id || '';
3599
+ return () => picker.value;
3600
+ },
3601
+ '切换'
3602
+ );
3603
+ if (choice === null) return;
3604
+ await postAgent(agent.agentId, '/system-prompt', {templateId: choice});
3605
+ localStorage.setItem(`rpLastTemplate:${agent.backendId}`, choice);
3606
+ agent.template = (catalog.templates || []).find(item => item.id === choice) || undefined;
3607
+ await refreshAgents(true);
3608
+ toast('System Prompt 已切换');
3609
+ }
3117
3610
  /** Manage Pi's global prompt files and the shareable Agent templates of one backend. */
3118
3611
  async function systemPromptManager() {
3119
3612
  if (!requireConnection()) return;
@@ -3135,11 +3628,6 @@
3135
3628
  return toast(`读取失败:${error.message}(Backend 可能版本过旧)`);
3136
3629
  }
3137
3630
  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
3631
  const section = (title, note) =>
3144
3632
  el(
3145
3633
  'section',
@@ -3336,6 +3824,112 @@
3336
3824
  if (result.editorText !== undefined) $('input').value = result.editorText;
3337
3825
  toast(result.editorText !== undefined ? '已回退到该用户输入,可编辑后重新发送' : '已回退到所选输出');
3338
3826
  }
3827
+ const performanceTime = ms =>
3828
+ ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(ms < 10000 ? 2 : 1)} s`;
3829
+ function coveredTime(spans) {
3830
+ const intervals = spans.map(span => [span.start, span.end]).sort((a, b) => a[0] - b[0]);
3831
+ let total = 0,
3832
+ end = -Infinity;
3833
+ for (const [from, to] of intervals) {
3834
+ total += Math.max(0, to - Math.max(end, from));
3835
+ end = Math.max(end, to);
3836
+ }
3837
+ return total;
3838
+ }
3839
+ async function showSessionPerformance() {
3840
+ const agent = state.agent;
3841
+ if (!agent) return toast('请选择 Agent');
3842
+ const {runs, legacyTools} = await requestAgent(agent.agentId, '/performance');
3843
+ await modal(
3844
+ '会话耗时分析',
3845
+ body => {
3846
+ const info = document.createElement('p');
3847
+ info.className = 'muted';
3848
+ info.textContent =
3849
+ '横轴为每轮运行的实际时间;同一时间段的工具调用可能并行。模型耗时包含首响应等待与输出;网络、排队和其他开销可能落在空白时间。实际输出 token 可能包含思考 token。';
3850
+ body.append(info);
3851
+ if (!runs.length && !legacyTools.length) {
3852
+ body.append(el('p', {textContent: '当前分支没有可分析的耗时记录;新运行结束后会记录精确时间。'}));
3853
+ return null;
3854
+ }
3855
+ const list = el('div', {className: 'performance-list'});
3856
+ const allSpans = runs.flatMap(run => run.spans || []);
3857
+ const total = runs.reduce((sum, run) => sum + Math.max(0, run.end - run.start), 0);
3858
+ const tools = allSpans.filter(span => span.kind === 'tool');
3859
+ const models = allSpans.filter(span => span.kind === 'model');
3860
+ const head = el('p', {
3861
+ textContent: `${runs.length} 轮 · 总运行 ${performanceTime(total)} · 模型 ${performanceTime(coveredTime(models))} · 工具占用 ${performanceTime(coveredTime(tools))}(调用时长累计 ${performanceTime(tools.reduce((n, s) => n + s.end - s.start, 0))},可能并行)`
3862
+ });
3863
+ list.append(head);
3864
+ const legend = el('p', {
3865
+ className: 'muted',
3866
+ textContent: '蓝色:模型 · 黄色:工具 · 紫色:压缩/重试;空白:其他等待'
3867
+ });
3868
+ list.append(legend);
3869
+ for (const run of [...runs].reverse()) {
3870
+ const duration = Math.max(1, run.end - run.start);
3871
+ const spans = [...(run.spans || [])].sort((a, b) => a.start - b.start || a.end - b.end);
3872
+ const modelTime = coveredTime(spans.filter(span => span.kind === 'model'));
3873
+ const toolTime = coveredTime(spans.filter(span => span.kind === 'tool'));
3874
+ const summary = el('summary', {
3875
+ textContent: `${new Date(run.start).toLocaleString()} · ${performanceTime(duration)} · 模型 ${performanceTime(modelTime)} · 工具 ${performanceTime(toolTime)}${run.incomplete ? ' · 已中断' : ''}`
3876
+ });
3877
+ const details = el('details', {className: 'performance-run'}, summary);
3878
+ for (const span of spans) {
3879
+ const length = Math.max(0, span.end - span.start);
3880
+ const first =
3881
+ span.kind === 'model' && span.firstResponse !== undefined
3882
+ ? ` · 首响应 ${performanceTime(span.firstResponse - span.start)} · 响应 ${performanceTime(span.end - span.firstResponse)}`
3883
+ : '';
3884
+ const tokens =
3885
+ span.kind === 'model' && span.outputTokens !== undefined
3886
+ ? ` · 输出 ${span.outputTokens} token${span.firstResponse !== undefined && span.end > span.firstResponse ? ` · 均速 ${((span.outputTokens * 1000) / (span.end - span.firstResponse)).toFixed(1)} token/s` : ''}`
3887
+ : '';
3888
+ const label =
3889
+ span.kind === 'model'
3890
+ ? '模型调用'
3891
+ : span.kind === 'tool'
3892
+ ? `工具 ${span.name || ''}`
3893
+ : span.kind === 'retry'
3894
+ ? '重试等待'
3895
+ : '上下文压缩';
3896
+ const row = el('div', {className: 'performance-row'});
3897
+ const text = el('span', {
3898
+ className: 'performance-label',
3899
+ textContent: `${label} · +${performanceTime(span.start - run.start)} · ${performanceTime(length)}${first}${tokens}${span.incomplete ? ' · 未完成' : ''}`
3900
+ });
3901
+ const track = el('div', {className: 'performance-track'});
3902
+ const bar = el('i', {className: `performance-${span.kind}`});
3903
+ bar.style.left = `${Math.max(0, Math.min(100, ((span.start - run.start) / duration) * 100))}%`;
3904
+ bar.style.width = `${Math.max(0.35, Math.min(100 - parseFloat(bar.style.left), (length / duration) * 100))}%`;
3905
+ track.append(bar);
3906
+ row.append(text, track);
3907
+ details.append(row);
3908
+ }
3909
+ list.append(details);
3910
+ }
3911
+ if (legacyTools.length) {
3912
+ const old = el('details', {className: 'performance-run'});
3913
+ old.append(
3914
+ el('summary', {
3915
+ textContent: `旧会话:${legacyTools.length} 次工具调用(仅有工具耗时;缺少模型请求数据)`
3916
+ })
3917
+ );
3918
+ for (const span of legacyTools.sort((a, b) => b.end - a.end))
3919
+ old.append(
3920
+ el('div', {
3921
+ className: 'performance-row',
3922
+ textContent: `${new Date(span.end).toLocaleString()} · ${span.name} · ${performanceTime(span.end - span.start)}`
3923
+ })
3924
+ );
3925
+ list.append(old);
3926
+ }
3927
+ body.append(list);
3928
+ return null;
3929
+ },
3930
+ '关闭'
3931
+ );
3932
+ }
3339
3933
  async function showSessionTree() {
3340
3934
  const sourceAgent = state.agent;
3341
3935
  if (!sourceAgent) return toast('请选择 Agent');
@@ -3562,6 +4156,7 @@
3562
4156
  $('openConfig').onclick = openConfig;
3563
4157
  $('configClose').onclick = () => $('configDialog').close();
3564
4158
  $('configSave').onclick = saveMailSettings;
4159
+ $('saveSotto').onclick = saveSottoSettings;
3565
4160
  $('connect').onclick = () => openPair();
3566
4161
  $('keyboardConnect').onclick = async () => {
3567
4162
  try {
@@ -3606,6 +4201,18 @@
3606
4201
  }
3607
4202
  };
3608
4203
  $('workspaces').onchange = () => void selectWorkspace();
4204
+ function applyEnterSendMode() {
4205
+ $('enterSendMode').value = state.enterSendMode;
4206
+ $('input').placeholder = `输入 @ 引用文件,/ 使用命令,! 执行 Shell;${
4207
+ state.enterSendMode === 'enter' ? 'Enter 发送,Shift+Enter 换行' : 'Ctrl/⌘+Enter 发送'
4208
+ }`;
4209
+ }
4210
+ applyEnterSendMode();
4211
+ $('enterSendMode').onchange = () => {
4212
+ state.enterSendMode = $('enterSendMode').value === 'enter' ? 'enter' : 'ctrl-enter';
4213
+ localStorage.rpEnterSendMode = state.enterSendMode;
4214
+ applyEnterSendMode();
4215
+ };
3609
4216
  $('agentPageSize').value = String(state.agentPageSize);
3610
4217
  $('agentPageSize').onchange = () => {
3611
4218
  state.agentPageSize = Number($('agentPageSize').value) || 10;
@@ -3665,16 +4272,7 @@
3665
4272
  try {
3666
4273
  await postAgent(agent.agentId, '/archive');
3667
4274
  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
- }
4275
+ if (state.agent?.agentId === agent.agentId) clearAgentSelection();
3678
4276
  await refreshAgents();
3679
4277
  toast('Agent 已 Archive');
3680
4278
  } catch (e) {
@@ -3692,6 +4290,13 @@
3692
4290
  const id = state.agent.agentId;
3693
4291
  Promise.all([postAgent(id, '/abort'), postAgent(id, '/bash-abort')]).catch(e => toast(e.message));
3694
4292
  };
4293
+ $('safePause').onclick = () => {
4294
+ if (!state.agent) return;
4295
+ const id = state.agent.agentId,
4296
+ pause = state.activities.get(id)?.pauseRequested;
4297
+ if (pause?.reached) return;
4298
+ requestAgent(id, '/pause', {method: pause ? 'DELETE' : 'POST'}).catch(e => toast(e.message));
4299
+ };
3695
4300
  $('stop').onclick = async () => {
3696
4301
  if (state.agent && confirm('停止这个 Agent?之后再次使用时会按需启动。')) {
3697
4302
  await requestAgent(state.agent.agentId, '', {method: 'DELETE'});
@@ -3713,8 +4318,10 @@
3713
4318
  };
3714
4319
  $('mcp').onclick = () => mcpControls().catch(e => toast(e.message));
3715
4320
  $('systemPrompt').onclick = () => showAgentSystemPrompt().catch(e => toast(e.message));
4321
+ $('switchPrompt').onclick = () => switchAgentPrompt().catch(e => toast(e.message));
3716
4322
  $('openSystemPrompts').onclick = () => systemPromptManager().catch(e => toast(e.message));
3717
4323
  $('sessionTree').onclick = () => showSessionTree().catch(e => toast(e.message));
4324
+ $('sessionPerformance').onclick = () => showSessionPerformance().catch(e => toast(e.message));
3718
4325
  $('undo').onclick = () => revert().catch(e => toast(e.message));
3719
4326
  $('controls').onclick = () => modelControls().catch(e => toast(e.message));
3720
4327
  $('compact').onclick = async () => {
@@ -3732,13 +4339,41 @@
3732
4339
  toast('Compact 完成');
3733
4340
  }
3734
4341
  };
4342
+ function prependInput(text) {
4343
+ $('input').value = text + ($('input').value ? `\n${$('input').value}` : '');
4344
+ }
4345
+ function showUncertainSend(agentId, text) {
4346
+ const card = addCard(
4347
+ '送达状态未知',
4348
+ `连接中断,以下消息可能已送达。请先核对会话和队列,不要直接重复发送。\n\n${text}`,
4349
+ 'error',
4350
+ true
4351
+ );
4352
+ const dismiss = document.createElement('button');
4353
+ dismiss.type = 'button';
4354
+ dismiss.textContent = '已核对,关闭提示';
4355
+ dismiss.onclick = () => {
4356
+ const pending = state.uncertainSends.get(agentId) || [],
4357
+ index = pending.indexOf(text);
4358
+ if (index >= 0) pending.splice(index, 1);
4359
+ if (!pending.length) state.uncertainSends.delete(agentId);
4360
+ removeCard(card);
4361
+ };
4362
+ card.details.append(dismiss);
4363
+ }
3735
4364
  $('prompt').onsubmit = async event => {
3736
4365
  event.preventDefault();
3737
4366
  if (!state.agent) return toast('请先创建或选择 Agent');
3738
4367
  const text = $('input').value.trim();
3739
4368
  if (!text) return;
3740
4369
  closeCommandPicker();
3741
- const mode = $('sendMode').value,
4370
+ const selectedMode = $('sendMode').value,
4371
+ mode =
4372
+ state.agent.status === 'streaming' || state.agent.status === 'waiting_for_user'
4373
+ ? selectedMode === 'steer'
4374
+ ? 'steer'
4375
+ : 'follow-up'
4376
+ : 'prompt',
3742
4377
  agentId = state.agent.agentId;
3743
4378
  $('messages').querySelector('.empty')?.remove();
3744
4379
  // `!cmd` runs inside the Agent session (output is recorded in the Session); `!!cmd` keeps the output out of the model context.
@@ -3763,10 +4398,44 @@
3763
4398
  if (mode !== 'prompt' && sent?.disposition)
3764
4399
  toast(sent.disposition === 'queued' ? '已排队,等待当前回合' : '已送达当前回合');
3765
4400
  } catch (e) {
3766
- addCard('发送失败', e.message, 'error', true);
4401
+ // A lost response (including a gateway timeout) says nothing about whether Pi accepted the
4402
+ // input. Keep the text for review, but never turn it back into a sendable draft automatically.
4403
+ if (!e.status || e.status === 408 || e.status >= 500) {
4404
+ const pending = state.uncertainSends.get(agentId) || [];
4405
+ pending.push(text);
4406
+ state.uncertainSends.set(agentId, pending);
4407
+ if (state.selectedKind === 'agent' && state.agent?.agentId === agentId)
4408
+ showUncertainSend(agentId, text);
4409
+ else toast('消息送达状态未知;切回该会话后请核对会话和队列');
4410
+ return;
4411
+ }
4412
+ // The composer is shared: only restore into it while the failed Agent is still selected.
4413
+ if (state.agent?.agentId === agentId) {
4414
+ prependInput(text);
4415
+ addCard('发送失败', e.message, 'error', true);
4416
+ } else {
4417
+ const pending = state.failedSends.get(agentId);
4418
+ state.failedSends.set(agentId, pending === undefined ? text : `${text}\n${pending}`);
4419
+ const agent = state.agents.find(item => item.agentId === agentId);
4420
+ toast(`发送到「${agent ? agentLabel(agent) : agentId}」失败:${e.message};切回该会话时会放回输入框`);
4421
+ }
3767
4422
  }
3768
4423
  };
4424
+ /**
4425
+ * Whether a keydown in the prompt sends it. Ctrl/⌘+Enter always sends; in 'enter' mode a plain Enter
4426
+ * does too. Enter that confirms an IME composition never sends.
4427
+ */
4428
+ function isSendKey(event, mode) {
4429
+ if (event.key !== 'Enter' || event.isComposing || event.keyCode === 229) return false;
4430
+ if (event.ctrlKey || event.metaKey) return true;
4431
+ return mode === 'enter' && !event.shiftKey && !event.altKey;
4432
+ }
3769
4433
  $('input').onkeydown = event => {
4434
+ if (event.altKey && event.shiftKey && event.key.toLowerCase() === 'v' && !event.isComposing) {
4435
+ event.preventDefault();
4436
+ if (!event.repeat) void toggleVoiceInput();
4437
+ return;
4438
+ }
3770
4439
  if (!$('commandPicker').hidden) {
3771
4440
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3772
4441
  event.preventDefault();
@@ -3801,7 +4470,8 @@
3801
4470
  return;
3802
4471
  }
3803
4472
  }
3804
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
4473
+ if (isSendKey(event, state.enterSendMode)) {
4474
+ event.preventDefault();
3805
4475
  $('prompt').requestSubmit();
3806
4476
  }
3807
4477
  };