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/README.md +15 -5
- package/dist/activity.js +83 -9
- package/dist/agents.js +244 -43
- package/dist/http/agent-routes.js +37 -3
- package/dist/http/utils.js +5 -1
- package/dist/http/web-assets.js +1 -0
- package/dist/mail-notifier.js +7 -2
- package/dist/metadata-store.js +14 -1
- package/dist/safe-pause.js +100 -0
- package/dist/sdk-backend.js +48 -3
- package/dist/server.js +52 -2
- package/dist/session-performance.js +139 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/voice.js +2 -2
- package/dist/ws/agent-socket.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +22 -1
- package/packages/protocol/websocket.schema.json +4 -3
- package/web/activity-view.js +49 -4
- package/web/app.js +764 -94
- package/web/collab.js +18 -4
- package/web/index.html +5 -2
- package/web/styles.css +8 -2
- package/web/voice-pcm.js +36 -0
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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 (
|
|
2320
|
-
|
|
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
|
-
(
|
|
2344
|
-
|
|
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
|
|
2368
|
-
|
|
2369
|
-
if (!transcribeBackend) {
|
|
2714
|
+
const transcribeBackend = backendOf(speechBackendId);
|
|
2715
|
+
if (transcribeBackend?.status !== 'connected') {
|
|
2370
2716
|
$('voiceInput').textContent = '🎙';
|
|
2371
|
-
|
|
2717
|
+
$('voiceInput').disabled = false;
|
|
2718
|
+
return toast('语音识别 Backend 已断开');
|
|
2372
2719
|
}
|
|
2373
2720
|
try {
|
|
2374
|
-
const response = await fetch(
|
|
2375
|
-
|
|
2376
|
-
{
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
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').
|
|
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
|
-
|
|
2935
|
-
|
|
3342
|
+
const preferenceKey = `rpLastTemplate:${workspace.backendId}`;
|
|
3343
|
+
let templates = [],
|
|
3344
|
+
modelCatalog;
|
|
2936
3345
|
try {
|
|
2937
|
-
[
|
|
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
|
|
2955
|
-
|
|
2956
|
-
const
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
.
|
|
2965
|
-
|
|
2966
|
-
|
|
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
|
-
|
|
2979
|
-
|
|
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
|
-
|
|
2990
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
4473
|
+
if (isSendKey(event, state.enterSendMode)) {
|
|
4474
|
+
event.preventDefault();
|
|
3805
4475
|
$('prompt').requestSubmit();
|
|
3806
4476
|
}
|
|
3807
4477
|
};
|