pi2web 0.2.0 → 0.2.6

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.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
package/web/app.js CHANGED
@@ -1,544 +1,3910 @@
1
1
  (() => {
2
2
  'use strict';
3
3
  const $ = id => document.getElementById(id);
4
+ const {
5
+ BackendRegistry,
6
+ createBackendId,
7
+ resourceKey,
8
+ splitResourceKey,
9
+ normalizePath,
10
+ isInsidePath,
11
+ matchWorkspace
12
+ } = window.RemotePiBackendState;
13
+ const backendRegistry = new BackendRegistry(localStorage, location.origin);
14
+ // The registry owns backend connections and their resource caches. This object
15
+ // only keeps cross-backend UI selections and transient view state.
4
16
  const state = {
5
- base: localStorage.rpBase || location.origin, token: '', workspace: null, workspaces: [], treePath: '.',
6
- filePath: null, fileOffset: 0, fileSize: 0, fileLimit: 64 * 1024, agent: null, agents: [], terminals: [], terminal: null, selectedKind: 'agent', ws: null, terminalWs: null,
7
- terminalEmulator: null, fitAddon: null, resizeObserver: null, reconnectTimer: null, reconnectAttempt: 0, manuallyClosed: false, streams: new Map(), contextTarget: null,
8
- mentionPath: '.', mentionStart: null, mentionEnd: null, mentionPrefix: '', mentionOptions: [], mentionFiltered: [], mentionIndex: 0, mentionRequest: 0, extensionStatus: new Map(), widgets: new Map(), contexts: new Map(), connected: false, mobileView: 'home',
9
- agentPageSize: Number(localStorage.rpAgentPageSize || 10), agentVisibleCount: Number(localStorage.rpAgentPageSize || 10), messagePageStart: 0, messageTotal: 0, messagePageSize: 25,
10
- voiceEnabled: false, voiceSttEnabled: false, voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true', voiceAudio: {context:null,nextTime:0,playbackId:null,sources:new Set(),decodeChain:Promise.resolve(),generation:0}, mediaRecorder:null, mediaChunks:[], mediaStream:null, mediaTimer:null, mediaAgentId:null,
11
- mailNotificationsAvailable:false,mailSettings:{enabled:false,aggregationDelaySeconds:0,includeResponse:true,includeSessionDetails:true},
12
- };
13
- $('api').value = state.base;
14
- $('pairBase').value = state.base;
15
-
16
- const clean = value => String(value || '').split('\\').join('/').replace(/\/+/g, '/').replace(/\/$/, '') || '/';
17
- const joinPath = (base, name) => base === '.' ? name : `${base}/${name}`;
18
- const parentPath = value => { const p = value.split('/'); p.pop(); return p.join('/') || '.'; };
17
+ backends: backendRegistry.items,
18
+ backendFilter: localStorage.rpBackendFilter || '',
19
+ workspace: null,
20
+ treePath: '.',
21
+ filePath: null,
22
+ fileOffset: 0,
23
+ fileSize: 0,
24
+ fileLimit: 64 * 1024,
25
+ agent: null,
26
+ agents: [],
27
+ terminals: [],
28
+ terminal: null,
29
+ selectedKind: 'agent',
30
+ terminalWs: null,
31
+ terminalEmulator: null,
32
+ terminalAssetsPromise: null,
33
+ terminalConnectAttempt: 0,
34
+ fitAddon: null,
35
+ resizeObserver: null,
36
+ streams: new Map(),
37
+ contextTarget: null,
38
+ mentionPath: '.',
39
+ mentionStart: null,
40
+ mentionEnd: null,
41
+ mentionPrefix: '',
42
+ mentionOptions: [],
43
+ mentionFiltered: [],
44
+ mentionIndex: 0,
45
+ mentionRequest: 0,
46
+ extensionStatus: new Map(),
47
+ widgets: new Map(),
48
+ contexts: new Map(),
49
+ mobileView: 'home',
50
+ queues: new Map(),
51
+ // Server-maintained activity of each busy Agent; idle ones are absent.
52
+ activities: new Map(),
53
+ // Timeline of the viewed Agent; see loadTimeline.
54
+ timeline: null,
55
+ timelineCache: new Map(),
56
+ itemBodies: new Map(),
57
+ // Cards of the message being streamed, and of running `!` commands, until they are recorded.
58
+ liveCards: [],
59
+ bashCards: new Map(),
60
+ // Prompts shown before the server records them.
61
+ optimistic: [],
62
+ commands: [],
63
+ commandsAgentId: null,
64
+ commandFiltered: [],
65
+ commandIndex: 0,
66
+ commandRequest: 0,
67
+ agentPageSize: Number(localStorage.rpAgentPageSize || 10),
68
+ agentVisibleCount: Number(localStorage.rpAgentPageSize || 10),
69
+ voiceEnabled: false,
70
+ voiceSttEnabled: false,
71
+ voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true',
72
+ voiceAudio: {
73
+ context: null,
74
+ nextTime: 0,
75
+ playbackId: null,
76
+ backendId: null,
77
+ sources: new Set(),
78
+ decodeChain: Promise.resolve(),
79
+ generation: 0
80
+ },
81
+ mediaRecorder: null,
82
+ mediaChunks: [],
83
+ mediaStream: null,
84
+ mediaTimer: null,
85
+ mediaAgentId: null,
86
+ mailBackendId: null,
87
+ mailRequest: 0,
88
+ mailNotificationsAvailable: false,
89
+ mailSettings: {
90
+ enabled: false,
91
+ aggregationDelaySeconds: 0,
92
+ includeResponse: true,
93
+ includeSessionDetails: true,
94
+ collabEscalations: true
95
+ }
96
+ };
97
+ $('pairBase').value = state.backends[0]?.base || location.origin;
98
+ $('pairName').value = state.backends[0]?.name || '';
99
+
100
+ const clean = normalizePath;
101
+ const joinPath = (base, name) => (base === '.' ? name : `${base}/${name}`);
102
+ const parentPath = value => {
103
+ const p = value.split('/');
104
+ p.pop();
105
+ return p.join('/') || '.';
106
+ };
19
107
  const absolutePath = relative => clean(`${state.workspace.rootPath}/${relative === '.' ? '' : relative}`);
20
- const isInside = (file, dir) => file === dir || file.startsWith(`${dir}/`);
21
- const relativeTo = (file, dir) => file === dir ? '.' : file.slice(dir.length + 1);
22
- const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
23
- const toast = text => { $('toast').textContent = text; $('toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('toast').hidden = true, 3000); };
24
- const requireConnection = () => { if (!state.connected) { openPair(); return false; } return true; };
108
+ const isInside = isInsidePath;
109
+ /** A cwd only belongs to a Workspace of the same Backend: the same path on two machines is two directories. */
110
+ const ownsPath = (workspace, item) =>
111
+ !!workspace &&
112
+ workspace.backendId === item.backendId &&
113
+ isInside(clean(item.cwd), clean(workspace.rootPath));
114
+ const relativeTo = (file, dir) => (file === dir ? '.' : file.slice(dir.length + 1));
115
+ const esc = value =>
116
+ String(value ?? '').replace(
117
+ /[&<>"']/g,
118
+ c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'})[c]
119
+ );
120
+ const toast = text => {
121
+ $('toast').textContent = text;
122
+ $('toast').hidden = false;
123
+ clearTimeout(toast.timer);
124
+ toast.timer = setTimeout(() => ($('toast').hidden = true), 3000);
125
+ };
126
+ const backendOf = id => backendRegistry.find(id);
127
+ // Must equal the server's PROTOCOL_VERSION (src/ws/agent-socket.ts); test/web-protocol-version.test.ts checks it.
128
+ const PROTOCOL_VERSION = 2;
129
+ const connectedBackends = () => backendRegistry.connected();
130
+ const primaryBackend = () => backendRegistry.primary();
131
+ const hasConnection = () => connectedBackends().length > 0;
132
+ const requireConnection = () => {
133
+ if (!hasConnection()) {
134
+ openPair(state.backends[0] || null);
135
+ return false;
136
+ }
137
+ return true;
138
+ };
25
139
  const mobileMedia = matchMedia('(max-width: 700px)');
26
140
  const isMobile = () => mobileMedia.matches;
27
141
  function setMobileView(view, {push = false, replace = false} = {}) {
28
- if (!['home','workspace','file','agent'].includes(view)) view = 'home';
142
+ if (!['home', 'workspace', 'file', 'agent'].includes(view)) view = 'home';
29
143
  const changed = state.mobileView !== view;
30
- state.mobileView = view; document.body.dataset.mobileView = view;
144
+ state.mobileView = view;
145
+ document.body.dataset.mobileView = view;
31
146
  $('workspaceHeading').textContent = view === 'home' ? 'Workspace' : state.workspace?.label || 'Workspace';
32
147
  $('mobileAgentActions').closest('.toolbar')?.classList.remove('actions-open');
33
148
  if (!isMobile()) return;
34
- const historyState = {...(history.state || {}), rpView:view};
149
+ const historyState = {...(history.state || {}), rpView: view};
35
150
  if (replace) history.replaceState(historyState, '');
36
151
  else if (push && (changed || history.state?.rpView !== view)) history.pushState(historyState, '');
37
152
  }
38
- const navigateMobile = view => { if (isMobile()) setMobileView(view, {push:true}); };
39
- const mobileBack = fallback => { if (isMobile() && history.state?.rpView === state.mobileView && state.mobileView !== 'home') history.back(); else setMobileView(fallback, {replace:true}); };
153
+ const navigateMobile = view => {
154
+ if (isMobile()) setMobileView(view, {push: true});
155
+ };
156
+ const mobileBack = fallback => {
157
+ if (isMobile() && history.state?.rpView === state.mobileView && state.mobileView !== 'home')
158
+ history.back();
159
+ else setMobileView(fallback, {replace: true});
160
+ };
40
161
 
41
- async function request(base, token, url, options = {}, disconnectOnUnauthorized = false) {
42
- if (!token) throw Error('请先输入配对码');
43
- const headers = {Authorization: `Bearer ${token}`, ...(options.body ? {'Content-Type':'application/json'} : {}), ...options.headers};
162
+ async function request(base, token, url, options = {}, onUnauthorized) {
163
+ const headers = {
164
+ ...(token ? {Authorization: `Bearer ${token}`} : {}),
165
+ ...(options.body ? {'Content-Type': 'application/json'} : {}),
166
+ ...options.headers
167
+ };
44
168
  const response = await fetch(base + url, {...options, headers});
45
169
  const body = await response.json().catch(() => ({}));
46
170
  if (!response.ok) {
47
- if (disconnectOnUnauthorized && response.status === 401) disconnect('配对码无效或已失效');
171
+ if (response.status === 401) onUnauthorized?.();
48
172
  throw Error(body.error?.message || `HTTP ${response.status}`);
49
173
  }
50
174
  return body.data;
51
175
  }
52
- async function api(url, options = {}) { return request(state.base, state.token, url, options, true); }
53
- const post = (url, body = {}) => api(url, {method:'POST', body:JSON.stringify(body)});
176
+ async function backendRequiresAuth(base) {
177
+ try {
178
+ return (await request(base, '', '/health')).authRequired !== false;
179
+ } catch {
180
+ return true;
181
+ }
182
+ }
183
+ async function requestBackend(backendId, url, options = {}) {
184
+ const backend = backendOf(backendId);
185
+ if (!backend) throw Error('Backend 不存在');
186
+ if (backend.status !== 'connected') throw Error(`Backend「${backend.name || backendId}」未连接`);
187
+ return request(backend.base, backend.token, url, options, () => {
188
+ if (backend.status === 'connected')
189
+ void deactivateBackend(backend, {status: 'error', error: '配对码无效或已失效'});
190
+ });
191
+ }
192
+ const postBackend = (backendId, url, body = {}) =>
193
+ requestBackend(backendId, url, {method: 'POST', body: JSON.stringify(body)});
194
+ const requestAgent = (agentId, suffix = '', options = {}) => {
195
+ const {backendId, remoteId} = splitResourceKey(agentId);
196
+ return requestBackend(backendId, `/api/v1/agents/${encodeURIComponent(remoteId)}${suffix}`, options);
197
+ };
198
+ const postAgent = (agentId, suffix = '', body = {}) =>
199
+ requestAgent(agentId, suffix, {method: 'POST', body: JSON.stringify(body)});
54
200
 
55
- function openPair() {
56
- if($('configDialog').open)$('configDialog').close();
57
- $('pairBase').value = state.base;
58
- $('pairToken').value = localStorage.rpToken || '';
201
+ function openPair(backend = null) {
202
+ if ($('pairDialog').open) return;
203
+ if ($('configDialog').open) $('configDialog').close();
204
+ $('pairDialog').dataset.backendId = backend?.id || '';
205
+ $('pairName').value = backend?.name || '';
206
+ $('pairBase').value = backend?.base || location.origin;
207
+ $('pairToken').value = backend?.token || '';
59
208
  if (!$('pairDialog').open) $('pairDialog').showModal();
60
209
  }
61
- async function connect(base, token) {
62
- const candidateBase=base.trim().replace(/\/$/, '')||location.origin,candidateToken=token.trim();
63
- const status=await request(candidateBase,candidateToken,'/api/v1/system/status');
64
- if(status.protocolVersion!==1)throw Error(`不支持的协议版本 ${status.protocolVersion}(需要 1)`);
65
- disconnect();state.base=candidateBase;state.token=candidateToken;state.connected=true;localStorage.rpBase=state.base;$('api').value=state.base;
66
- $('status').className='ok';$('status').textContent='已配对';$('serverInfo').textContent=`v${status.version} · Pi ${status.piVersion}`;
67
- state.voiceEnabled=!!(status.voiceCapabilities?.tts??status.voiceEnabled);state.voiceSttEnabled=!!(status.voiceCapabilities?.stt??status.voiceEnabled);$('voicePlayback').hidden=!state.voiceEnabled;$('voiceInput').hidden=!state.voiceSttEnabled;updateVoiceButton();
68
- await loadMailNotificationSettings(status);updateConfigUi();await refreshWs();await refreshAgents(true);setMobileView('home',{replace:true});
69
- if(localStorage.rpToken!==state.token){if(confirm('是否将配对码保存到浏览器本地存储?\n\n请仅在可信设备上保存。'))localStorage.rpToken=state.token;else localStorage.removeItem('rpToken');}
70
- $('pairDialog').close();
210
+ function persistConnections() {
211
+ backendRegistry.persist();
71
212
  }
72
- function disconnect(reason = '未配对') {
73
- state.connected = false; state.token = ''; state.manuallyClosed = true;state.voiceEnabled=false;state.voiceSttEnabled=false;state.mailNotificationsAvailable=false;$('voicePlayback').hidden=true;$('voiceInput').hidden=true;
74
- clearTimeout(state.reconnectTimer); state.ws?.close(); state.ws = null; state.terminalWs?.close(); state.terminalWs = null; if(state.mediaRecorder?.state==='recording')state.mediaRecorder.stop();stopVoiceAudio();
75
- $('status').className = 'bad'; $('status').textContent = reason; $('serverInfo').textContent = '';updateConfigUi();
213
+
214
+ function updateHeaderStatus() {
215
+ const connected = connectedBackends();
216
+ const reconnecting = connected.some(backend => backend.reconnecting);
217
+ if (!connected.length) {
218
+ $('status').className = 'bad';
219
+ $('status').textContent = '未配对';
220
+ $('serverInfo').textContent = '';
221
+ return;
222
+ }
223
+ $('status').className = 'ok';
224
+ $('status').textContent =
225
+ state.backends.length > 1 ? `已连接 ${connected.length}/${state.backends.length}` : '已配对';
226
+ if (reconnecting) $('status').textContent += ' · 重连中';
227
+ $('serverInfo').textContent = connected.map(backend => backend.name).join(' · ');
76
228
  }
77
229
 
78
- async function refreshWs() {
79
- if (!requireConnection()) return;
80
- state.workspaces = await api('/api/v1/workspaces');
81
- $('workspaces').replaceChildren(...state.workspaces.map(workspace => {
82
- const option = document.createElement('option'); option.value = workspace.id;
83
- option.textContent = `${workspace.label} (${workspace.rootPath})`; return option;
230
+ function closeBackendSocket(backend) {
231
+ clearTimeout(backend.reconnectTimer);
232
+ backend.manuallyClosed = true;
233
+ backend.reconnecting = false;
234
+ backend.ws?.close();
235
+ backend.ws = null;
236
+ }
237
+
238
+ function clearWorkspaceSelection(backendId) {
239
+ if (backendId && state.workspace?.backendId !== backendId) return;
240
+ state.workspace = null;
241
+ localStorage.removeItem('rpWorkspaceId');
242
+ $('filePanel').hidden = true;
243
+ $('tree').innerHTML = '<div class="muted">请添加 Workspace</div>';
244
+ $('workspaceHeading').textContent = 'Workspace';
245
+ }
246
+
247
+ function cancelVoiceRecording(backendId) {
248
+ if (!state.mediaAgentId) return;
249
+ if (backendId && splitResourceKey(state.mediaAgentId).backendId !== backendId) return;
250
+ clearTimeout(state.mediaTimer);
251
+ if (state.mediaRecorder) {
252
+ state.mediaRecorder.onstop = null;
253
+ if (state.mediaRecorder.state === 'recording') state.mediaRecorder.stop();
254
+ }
255
+ state.mediaStream?.getTracks().forEach(track => track.stop());
256
+ state.mediaRecorder = null;
257
+ state.mediaStream = null;
258
+ state.mediaChunks = [];
259
+ state.mediaAgentId = null;
260
+ $('voiceInput').textContent = '🎙';
261
+ $('voiceInput').classList.remove('recording');
262
+ }
263
+
264
+ function clearAgentSelection(backendId) {
265
+ cancelVoiceRecording(backendId);
266
+ if (backendId && state.agent?.backendId !== backendId) return;
267
+ state.agent = null;
268
+ state.timeline = null;
269
+ sendFocus();
270
+ localStorage.removeItem('rpAgentId');
271
+ $('agentTitle').textContent = '请选择 Agent';
272
+ $('agentStatus').textContent = '';
273
+ $('agentStateBadge').textContent = '未选择';
274
+ $('agentStateBadge').className = 'state-badge state-none';
275
+ $('contextUsage').hidden = true;
276
+ $('messages').innerHTML = '<div class="empty">选择或创建 Agent 开始对话</div>';
277
+ state.extensionStatus.clear();
278
+ state.widgets.clear();
279
+ renderExtensionStatus();
280
+ renderWidgets();
281
+ }
282
+
283
+ function clearTerminalSelection(backendId) {
284
+ if (backendId && state.terminal?.backendId !== backendId) return;
285
+ state.terminalConnectAttempt++;
286
+ state.terminalWs?.close();
287
+ state.terminalWs = null;
288
+ state.terminal = null;
289
+ localStorage.removeItem('rpTerminalId');
290
+ if (state.selectedKind === 'terminal') {
291
+ state.selectedKind = 'agent';
292
+ localStorage.rpSelectedKind = 'agent';
293
+ showAgentView();
294
+ }
295
+ }
296
+
297
+ function deactivateBackendState(backend, status, error = '') {
298
+ closeBackendSocket(backend);
299
+ if (state.voiceAudio.backendId === backend.id) stopVoiceAudio();
300
+ backendRegistry.deactivate(backend, status, error);
301
+ clearWorkspaceSelection(backend.id);
302
+ clearAgentSelection(backend.id);
303
+ clearTerminalSelection(backend.id);
304
+ }
305
+
306
+ async function deactivateBackend(backend, {status = 'off', error = ''} = {}) {
307
+ deactivateBackendState(backend, status, error);
308
+ syncSessionLists();
309
+ updateVoiceAvailability();
310
+ updateHeaderStatus();
311
+ renderBackendList();
312
+ renderBackendFilter();
313
+ await renderWorkspaces();
314
+ await loadPrimaryMailSettings();
315
+ updateConfigUi();
316
+ }
317
+
318
+ async function disconnectAll(reason = '已断开全部连接') {
319
+ for (const backend of state.backends) deactivateBackendState(backend, 'off');
320
+ clearWorkspaceSelection();
321
+ clearAgentSelection();
322
+ clearTerminalSelection();
323
+ syncSessionLists();
324
+ updateVoiceAvailability();
325
+ updateHeaderStatus();
326
+ renderBackendList();
327
+ renderBackendFilter();
328
+ await renderWorkspaces();
329
+ await loadPrimaryMailSettings();
330
+ updateConfigUi();
331
+ if (reason) {
332
+ $('status').className = 'bad';
333
+ $('status').textContent = reason;
334
+ }
335
+ }
336
+
337
+ function updateVoiceAvailability() {
338
+ const backends = connectedBackends();
339
+ state.voiceEnabled = backends.some(backend => backend.voiceEnabled);
340
+ state.voiceSttEnabled = backends.some(backend => backend.voiceSttEnabled);
341
+ $('voicePlayback').hidden = !state.voiceEnabled;
342
+ $('voiceInput').hidden = !state.voiceSttEnabled;
343
+ if (!state.voiceEnabled) stopVoiceAudio();
344
+ updateVoiceButton();
345
+ }
346
+ async function connectBackend(entry, {confirmSave = true, pair = true} = {}) {
347
+ const base =
348
+ String(entry.base || '')
349
+ .trim()
350
+ .replace(/\/$/, '') || location.origin;
351
+ let token = String(entry.token || '').trim();
352
+ if (!token && (await backendRequiresAuth(base))) {
353
+ if (pair) openPair(entry);
354
+ throw Error('请先输入配对码');
355
+ }
356
+
357
+ const statusInfo = await request(base, token, '/api/v1/system/status');
358
+ if (statusInfo.protocolVersion !== PROTOCOL_VERSION) {
359
+ throw Error(`不支持的协议版本 ${statusInfo.protocolVersion}(需要 ${PROTOCOL_VERSION})`);
360
+ }
361
+ const authRequired = statusInfo.authRequired !== false;
362
+ if (!authRequired) token = '';
363
+
364
+ let backend = backendOf(entry.id);
365
+ if (!backend) {
366
+ backend = backendRegistry.add({
367
+ id: entry.id || createBackendId(),
368
+ name: '',
369
+ base: '',
370
+ token: '',
371
+ saved: false
372
+ });
373
+ }
374
+
375
+ closeBackendSocket(backend);
376
+ Object.assign(backend, {
377
+ name: String(entry.name || '').trim() || base,
378
+ base,
379
+ token,
380
+ status: 'connected',
381
+ error: '',
382
+ statusInfo,
383
+ authRequired,
384
+ 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)
388
+ });
389
+
390
+ try {
391
+ await refreshBackendData(backend);
392
+ } catch (error) {
393
+ if (backend.status !== 'error')
394
+ await deactivateBackend(backend, {status: 'error', error: error.message});
395
+ throw error;
396
+ }
397
+
398
+ if (entry.saved) backend.saved = true;
399
+ if (
400
+ confirmSave &&
401
+ authRequired &&
402
+ !backend.saved &&
403
+ confirm(`是否将「${backend.name}」的配对码保存到浏览器本地存储?\n\n请仅在可信设备上保存。`)
404
+ ) {
405
+ backend.saved = true;
406
+ }
407
+ persistConnections();
408
+ connectSocket(backend);
409
+ updateVoiceAvailability();
410
+ updateHeaderStatus();
411
+ renderBackendList();
412
+ renderBackendFilter();
413
+ await renderWorkspaces();
414
+ await restorePreviousSelection();
415
+ await loadPrimaryMailSettings();
416
+ updateConfigUi();
417
+ return backend;
418
+ }
419
+
420
+ async function refreshBackendData(backend) {
421
+ const [workspaces, agents, terminals] = await Promise.all([
422
+ requestBackend(backend.id, '/api/v1/workspaces'),
423
+ requestBackend(backend.id, '/api/v1/agents'),
424
+ requestBackend(backend.id, '/api/v1/terminals')
425
+ ]);
426
+ if (backend.status !== 'connected') return;
427
+ backend.workspaces = workspaces.map(workspace => ({
428
+ ...workspace,
429
+ backendId: backend.id,
430
+ backendName: backend.name
431
+ }));
432
+ backend.agents = agents.map(agent => backendRegistry.stamp(backend, agent, 'agentId'));
433
+ backend.terminals = terminals.map(terminal => backendRegistry.stamp(backend, terminal, 'terminalId'));
434
+ syncSessionLists();
435
+ }
436
+
437
+ async function refreshBackendWorkspaces(backend) {
438
+ const workspaces = await requestBackend(backend.id, '/api/v1/workspaces');
439
+ if (backend.status !== 'connected') return;
440
+ backend.workspaces = workspaces.map(workspace => ({
441
+ ...workspace,
442
+ backendId: backend.id,
443
+ backendName: backend.name
84
444
  }));
445
+ }
446
+
447
+ async function restorePreviousSelection() {
448
+ if (state.agent || state.terminal) return;
449
+ const terminal = state.terminals.find(item => item.terminalId === localStorage.rpTerminalId);
450
+ const agent = state.agents.find(item => item.agentId === localStorage.rpAgentId);
451
+ if (localStorage.rpSelectedKind === 'terminal' && terminal) await selectTerminal(terminal, false);
452
+ else if (agent) await selectAgent(agent, false);
453
+ }
454
+
455
+ const visibleWorkspaces = () => backendRegistry.resources('workspaces', state.backendFilter);
456
+ const workspaceKey = workspace => resourceKey(workspace.backendId, workspace.id);
457
+ const workspaceLabel = workspace =>
458
+ connectedBackends().length > 1 ? `${workspace.backendName} · ${workspace.label}` : workspace.label;
459
+ async function renderWorkspaces() {
460
+ const workspaces = visibleWorkspaces();
461
+ $('workspaces').replaceChildren(
462
+ ...workspaces.map(workspace => {
463
+ const option = document.createElement('option');
464
+ option.value = workspaceKey(workspace);
465
+ option.textContent = `${workspaceLabel(workspace)} (${workspace.rootPath})`;
466
+ return option;
467
+ })
468
+ );
85
469
  renderMobileWorkspaces();
86
- const wanted = state.workspace?.id || localStorage.rpWorkspaceId;
87
- if (state.workspaces.length) {
88
- $('workspaces').value = state.workspaces.some(x => x.id === wanted) ? wanted : state.workspaces[0].id;
89
- await selectWorkspace();
470
+ if (workspaces.length) {
471
+ const wanted = state.workspace ? workspaceKey(state.workspace) : localStorage.rpWorkspaceId;
472
+ const values = workspaces.map(workspaceKey);
473
+ if (values.includes(wanted)) {
474
+ $('workspaces').value = wanted;
475
+ if (!state.workspace) await selectWorkspace();
476
+ } else {
477
+ $('workspaces').value = values[0];
478
+ await selectWorkspace();
479
+ }
90
480
  } else {
91
- state.workspace = null; $('tree').innerHTML = '<div class="muted">请添加 Workspace</div>';
481
+ clearWorkspaceSelection();
92
482
  }
93
483
  }
484
+ function renderBackendFilter() {
485
+ const select = $('backendFilter');
486
+ if (!select) return;
487
+ // Every saved Backend is listed, not only the connected ones: this also runs before the automatic
488
+ // connections start, where dropping the filter of a still disconnected Backend would discard the
489
+ // stored choice on every reload. Removing a Backend clears the filter where it is removed.
490
+ select.replaceChildren(
491
+ ...state.backends.map(backend => {
492
+ const option = document.createElement('option');
493
+ option.value = backend.id;
494
+ option.textContent = backend.name || backend.base;
495
+ return option;
496
+ })
497
+ );
498
+ const all = document.createElement('option');
499
+ all.value = '';
500
+ all.textContent = '全部 Backend';
501
+ select.prepend(all);
502
+ select.value = state.backendFilter;
503
+ select.hidden = state.backends.length < 2;
504
+ }
505
+ function renderBackendList() {
506
+ const list = $('backendList');
507
+ if (!list) return;
508
+ list.replaceChildren(
509
+ ...state.backends.map(backend => {
510
+ const row = document.createElement('div');
511
+ row.className = 'backend-row';
512
+ const info = document.createElement('div');
513
+ info.className = 'backend-row-info';
514
+ const name = document.createElement('b');
515
+ name.textContent = backend.name || backend.base;
516
+ const detail = document.createElement('small');
517
+ const statusText =
518
+ backend.status === 'connected'
519
+ ? backend.reconnecting
520
+ ? '已连接 · 重连中'
521
+ : `已连接 · ${backend.authRequired === false ? '无需认证 · ' : ''}${backend.serverInfo}`
522
+ : backend.status === 'error'
523
+ ? `连接失败 · ${backend.error || ''}`
524
+ : '未连接';
525
+ detail.textContent = `${backend.base} · ${statusText}`;
526
+ info.append(name, detail);
527
+ const actions = document.createElement('div');
528
+ actions.className = 'backend-row-actions';
529
+ const toggle = document.createElement('button');
530
+ toggle.type = 'button';
531
+ toggle.textContent = backend.status === 'connected' ? '断开' : '连接';
532
+ toggle.onclick = async () => {
533
+ toggle.disabled = true;
534
+ try {
535
+ if (backend.status === 'connected') {
536
+ await deactivateBackend(backend);
537
+ } else {
538
+ await connectBackend(backend, {confirmSave: false});
539
+ toast(`已连接 ${backend.name}`);
540
+ }
541
+ } catch (error) {
542
+ if (backend.status !== 'error')
543
+ await deactivateBackend(backend, {status: 'error', error: error.message});
544
+ toast(error.message);
545
+ } finally {
546
+ toggle.disabled = false;
547
+ }
548
+ };
549
+ const edit = document.createElement('button');
550
+ edit.type = 'button';
551
+ edit.textContent = '修改';
552
+ edit.onclick = () => openPair(backend);
553
+ const remove = document.createElement('button');
554
+ remove.type = 'button';
555
+ remove.className = 'danger-action';
556
+ remove.textContent = '删除';
557
+ remove.onclick = async () => {
558
+ if (!confirm(`删除 backend「${backend.name}」?只影响这个页面,不会停止该机器上的服务。`)) return;
559
+ const agentIds = backend.agents.map(agent => agent.agentId);
560
+ deactivateBackendState(backend, 'off');
561
+ backendRegistry.remove(backend);
562
+ persistConnections();
563
+ for (const agentId of agentIds) sessionKeyboard.unbind(agentId);
564
+ if (state.backendFilter === backend.id) {
565
+ state.backendFilter = '';
566
+ localStorage.removeItem('rpBackendFilter');
567
+ }
568
+ syncSessionLists();
569
+ updateVoiceAvailability();
570
+ updateHeaderStatus();
571
+ renderBackendFilter();
572
+ await renderWorkspaces();
573
+ await loadPrimaryMailSettings();
574
+ updateConfigUi();
575
+ toast('已删除 backend');
576
+ };
577
+ actions.append(toggle, edit, remove);
578
+ row.append(info, actions);
579
+ return row;
580
+ })
581
+ );
582
+ }
583
+
584
+ async function refreshWs() {
585
+ if (!requireConnection()) return;
586
+ await Promise.allSettled(connectedBackends().map(refreshBackendWorkspaces));
587
+ await renderWorkspaces();
588
+ }
94
589
  function renderMobileWorkspaces() {
95
- $('mobileWorkspaces').replaceChildren(...state.workspaces.map(workspace => {
96
- const button=document.createElement('button');button.type='button';button.className='workspace-card';
97
- const name=document.createElement('b'),path=document.createElement('small');name.textContent=workspace.label;path.textContent=workspace.rootPath;button.append(name,path);
98
- button.onclick=async()=>{try{$('workspaces').value=workspace.id;await selectWorkspace();navigateMobile('workspace');}catch(error){toast(error.message);}};return button;
99
- }));
590
+ $('mobileWorkspaces').replaceChildren(
591
+ ...visibleWorkspaces().map(workspace => {
592
+ const button = document.createElement('button');
593
+ button.type = 'button';
594
+ button.className = 'workspace-card';
595
+ const name = document.createElement('b'),
596
+ path = document.createElement('small');
597
+ name.textContent = workspaceLabel(workspace);
598
+ path.textContent = workspace.rootPath;
599
+ button.append(name, path);
600
+ button.onclick = async () => {
601
+ try {
602
+ $('workspaces').value = workspaceKey(workspace);
603
+ await selectWorkspace();
604
+ navigateMobile('workspace');
605
+ } catch (error) {
606
+ toast(error.message);
607
+ }
608
+ };
609
+ return button;
610
+ })
611
+ );
100
612
  }
101
- async function selectWorkspace() {
102
- state.workspace = state.workspaces.find(x => x.id === $('workspaces').value);
613
+ async function selectWorkspace(selectedKey = $('workspaces').value) {
614
+ const {backendId, remoteId} = splitResourceKey(selectedKey);
615
+ state.workspace = backendOf(backendId)?.workspaces.find(workspace => workspace.id === remoteId) || null;
103
616
  if (!state.workspace) return;
104
- localStorage.rpWorkspaceId = state.workspace.id; state.treePath = '.'; $('agentCwd').value = '.'; $('filePanel').hidden = true;
105
- $('workspaceHeading').textContent = isMobile() && state.mobileView !== 'home' ? state.workspace.label : 'Workspace';
617
+ // A Backend filter can hide the owning Workspace from the picker; the selection still has to follow it.
618
+ if ([...$('workspaces').options].some(option => option.value === selectedKey))
619
+ $('workspaces').value = selectedKey;
620
+ localStorage.rpWorkspaceId = selectedKey;
621
+ state.treePath = '.';
622
+ $('agentCwd').value = '.';
623
+ $('filePanel').hidden = true;
624
+ $('workspaceHeading').textContent =
625
+ isMobile() && state.mobileView !== 'home' ? workspaceLabel(state.workspace) : 'Workspace';
106
626
  await loadTree();
107
627
  }
108
628
  async function loadTree() {
109
- if (!state.workspace) return;
110
- $('treePath').textContent = absolutePath(state.treePath);
629
+ const workspace = state.workspace;
630
+ const treePath = state.treePath;
631
+ if (!workspace) return;
632
+ $('treePath').textContent = absolutePath(treePath);
111
633
  try {
112
- const items = await api(`/api/v1/workspaces/${state.workspace.id}/tree?path=${encodeURIComponent(state.treePath)}`);
113
- $('tree').replaceChildren(...items.map(item => {
114
- const el = document.createElement('div'); el.className = `entry ${item.type === 'directory' ? 'dir' : 'file-entry'}`;
115
- el.textContent = `${item.type === 'directory' ? '📁' : '📄'} ${item.name}`;
116
- const relativePath = joinPath(state.treePath, item.name);
117
- el.onclick = () => item.type === 'directory' ? openDirectory(relativePath) : openFile(relativePath, 0);
118
- const contextTarget={relativePath,type:item.type};el.oncontextmenu=event=>showContextMenu(event,contextTarget);enableLongPressMenu(el,event=>showContextMenu(event,contextTarget));return el;
119
- }));
120
- } catch (error) { $('tree').textContent = error.message; }
121
- }
122
- async function openDirectory(relativePath) { state.treePath = relativePath; $('filePanel').hidden = true; await loadTree(); }
634
+ const items = await requestBackend(
635
+ workspace.backendId,
636
+ `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/tree?path=${encodeURIComponent(treePath)}`
637
+ );
638
+ if (
639
+ !state.workspace ||
640
+ workspaceKey(state.workspace) !== workspaceKey(workspace) ||
641
+ state.treePath !== treePath
642
+ )
643
+ return;
644
+ $('tree').replaceChildren(
645
+ ...items.map(item => {
646
+ const el = document.createElement('div');
647
+ el.className = `entry ${item.type === 'directory' ? 'dir' : 'file-entry'}`;
648
+ el.textContent = `${item.type === 'directory' ? '📁' : '📄'} ${item.name}`;
649
+ const relativePath = joinPath(treePath, item.name);
650
+ el.onclick = () =>
651
+ item.type === 'directory' ? openDirectory(relativePath) : openFile(relativePath, 0);
652
+ const contextTarget = {relativePath, type: item.type};
653
+ el.oncontextmenu = event => showContextMenu(event, contextTarget);
654
+ enableLongPressMenu(el, event => showContextMenu(event, contextTarget));
655
+ return el;
656
+ })
657
+ );
658
+ } catch (error) {
659
+ $('tree').textContent = error.message;
660
+ }
661
+ }
662
+ async function openDirectory(relativePath) {
663
+ state.treePath = relativePath;
664
+ $('filePanel').hidden = true;
665
+ await loadTree();
666
+ }
123
667
  async function openFile(relativePath, offset, showView = true) {
668
+ const workspace = state.workspace;
669
+ if (!workspace) return;
124
670
  try {
125
- const file = await api(`/api/v1/workspaces/${state.workspace.id}/file?path=${encodeURIComponent(relativePath)}&offset=${offset}&limit=${state.fileLimit}`);
126
- state.filePath = relativePath; state.fileOffset = file.offset; state.fileSize = file.size;
127
- $('filePanel').hidden = false; $('fileName').textContent = relativePath;
671
+ const file = await requestBackend(
672
+ workspace.backendId,
673
+ `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(relativePath)}&offset=${offset}&limit=${state.fileLimit}`
674
+ );
675
+ if (!state.workspace || workspaceKey(state.workspace) !== workspaceKey(workspace)) return;
676
+ state.filePath = relativePath;
677
+ state.fileOffset = file.offset;
678
+ state.fileSize = file.size;
679
+ $('filePanel').hidden = false;
680
+ $('fileName').textContent = relativePath;
128
681
  $('file').textContent = file.binary ? `[二进制文件,${file.size} bytes,无法预览]` : file.content;
129
- $('filePage').textContent = `${file.offset.toLocaleString()}–${Math.min(file.offset + file.limit, file.size).toLocaleString()} / ${file.size.toLocaleString()} bytes`;
130
- $('filePrev').disabled = file.offset <= 0; $('fileNext').disabled = file.binary || file.offset + file.limit >= file.size;
682
+ $('filePage').textContent =
683
+ `${file.offset.toLocaleString()}–${Math.min(file.offset + file.limit, file.size).toLocaleString()} / ${file.size.toLocaleString()} bytes`;
684
+ $('filePrev').disabled = file.offset <= 0;
685
+ $('fileNext').disabled = file.binary || file.offset + file.limit >= file.size;
131
686
  if (showView) navigateMobile('file');
132
- } catch (error) { toast(error.message); }
687
+ } catch (error) {
688
+ toast(error.message);
689
+ }
133
690
  }
134
691
  function showContextMenu(event, target) {
135
- event.preventDefault(); state.contextTarget = target; const isDirectory = target.type === 'directory'; $('menuStartAgent').hidden = !isDirectory; $('menuSetCwd').hidden = !isDirectory; $('menuOpenTerminal').hidden = !isDirectory;
136
- const menu = $('contextMenu'); menu.hidden = false; const margin=8;menu.style.left=`${Math.max(margin,Math.min(event.clientX,innerWidth-menu.offsetWidth-margin))}px`;menu.style.top=`${Math.max(margin,Math.min(event.clientY,innerHeight-menu.offsetHeight-margin))}px`;
692
+ event.preventDefault();
693
+ state.contextTarget = target;
694
+ const isDirectory = target.type === 'directory';
695
+ $('menuStartAgent').hidden = !isDirectory;
696
+ $('menuSetCwd').hidden = !isDirectory;
697
+ $('menuOpenTerminal').hidden = !isDirectory;
698
+ const menu = $('contextMenu');
699
+ menu.hidden = false;
700
+ const margin = 8;
701
+ menu.style.left = `${Math.max(margin, Math.min(event.clientX, innerWidth - menu.offsetWidth - margin))}px`;
702
+ menu.style.top = `${Math.max(margin, Math.min(event.clientY, innerHeight - menu.offsetHeight - margin))}px`;
137
703
  }
138
704
  function enableLongPressMenu(element, openMenu) {
139
- let timer=null,pointerId=null,startX=0,startY=0,suppressClickUntil=0;
140
- const cancel=()=>{if(timer!==null)clearTimeout(timer);timer=null;pointerId=null;element.classList.remove('long-press-pending');};
705
+ let timer = null,
706
+ pointerId = null,
707
+ startX = 0,
708
+ startY = 0,
709
+ suppressClickUntil = 0;
710
+ const cancel = () => {
711
+ if (timer !== null) clearTimeout(timer);
712
+ timer = null;
713
+ pointerId = null;
714
+ element.classList.remove('long-press-pending');
715
+ };
141
716
  element.classList.add('long-press-menu');
142
- element.addEventListener('pointerdown',event=>{
143
- if(!isMobile()||event.pointerType==='mouse'||event.button!==0||!event.isPrimary)return;
144
- cancel();pointerId=event.pointerId;startX=event.clientX;startY=event.clientY;element.classList.add('long-press-pending');
145
- timer=setTimeout(()=>{timer=null;suppressClickUntil=Date.now()+700;element.classList.remove('long-press-pending');navigator.vibrate?.(15);openMenu({preventDefault(){},clientX:startX,clientY:startY});},550);
717
+ element.addEventListener('pointerdown', event => {
718
+ if (!isMobile() || event.pointerType === 'mouse' || event.button !== 0 || !event.isPrimary) return;
719
+ cancel();
720
+ pointerId = event.pointerId;
721
+ startX = event.clientX;
722
+ startY = event.clientY;
723
+ element.classList.add('long-press-pending');
724
+ timer = setTimeout(() => {
725
+ timer = null;
726
+ suppressClickUntil = Date.now() + 700;
727
+ element.classList.remove('long-press-pending');
728
+ navigator.vibrate?.(15);
729
+ openMenu({preventDefault() {}, clientX: startX, clientY: startY});
730
+ }, 550);
731
+ });
732
+ element.addEventListener('pointermove', event => {
733
+ if (event.pointerId === pointerId && Math.hypot(event.clientX - startX, event.clientY - startY) > 10)
734
+ cancel();
146
735
  });
147
- element.addEventListener('pointermove',event=>{if(event.pointerId===pointerId&&Math.hypot(event.clientX-startX,event.clientY-startY)>10)cancel();});
148
- element.addEventListener('pointerup',event=>{if(event.pointerId===pointerId)cancel();});
149
- element.addEventListener('pointercancel',cancel);element.addEventListener('pointerleave',event=>{if(event.pointerId===pointerId)cancel();});
150
- element.addEventListener('click',event=>{if(Date.now()<suppressClickUntil){event.preventDefault();event.stopImmediatePropagation();}},true);
736
+ element.addEventListener('pointerup', event => {
737
+ if (event.pointerId === pointerId) cancel();
738
+ });
739
+ element.addEventListener('pointercancel', cancel);
740
+ element.addEventListener('pointerleave', event => {
741
+ if (event.pointerId === pointerId) cancel();
742
+ });
743
+ element.addEventListener(
744
+ 'click',
745
+ event => {
746
+ if (Date.now() < suppressClickUntil) {
747
+ event.preventDefault();
748
+ event.stopImmediatePropagation();
749
+ }
750
+ },
751
+ true
752
+ );
151
753
  }
152
754
  function mentionContext() {
153
- const input=$('input'),end=input.selectionStart,before=input.value.slice(0,end),match=before.match(/(^|\s)@(?:"([^"]*)"?|([^\s]*))$/);
154
- if(!match)return null;const token=match[0].slice(match[1]?.length||0),start=end-token.length,replacementEnd=end+(token.startsWith('@"')&&input.value[end]==='"'?1:0);return {start,end,replacementEnd,query:match[2]??match[3]??''};
755
+ const input = $('input'),
756
+ end = input.selectionStart,
757
+ before = input.value.slice(0, end),
758
+ match = before.match(/(^|\s)@(?:"([^"]*)"?|([^\s]*))$/);
759
+ if (!match) return null;
760
+ const token = match[0].slice(match[1]?.length || 0),
761
+ start = end - token.length,
762
+ replacementEnd = end + (token.startsWith('@"') && input.value[end] === '"' ? 1 : 0);
763
+ return {start, end, replacementEnd, query: match[2] ?? match[3] ?? ''};
155
764
  }
156
- function mentionValue(relativePath, directory=false) {
157
- const target=absolutePath(relativePath),cwd=clean(state.agent?.cwd||absolutePath($('agentCwd').value.trim()||'.'));
158
- let value=isInside(target,cwd)?relativeTo(target,cwd):target;if(directory&&value!=='.')value+='/';return value;
765
+ function mentionValue(relativePath, directory = false) {
766
+ const target = absolutePath(relativePath),
767
+ cwd = clean(state.agent?.cwd || absolutePath($('agentCwd').value.trim() || '.'));
768
+ let value = isInside(target, cwd) ? relativeTo(target, cwd) : target;
769
+ if (directory && value !== '.') value += '/';
770
+ return value;
159
771
  }
160
- function writeMention(relativePath,{directory=false,close=true}={}) {
161
- if(!state.workspace)return;const input=$('input'),context=mentionContext(),from=state.mentionStart??context?.start??input.selectionStart,to=state.mentionEnd??context?.replacementEnd??input.selectionStart;
162
- const value=mentionValue(relativePath,directory),quoted=value.includes(' '),mention=`@${quoted?`"${value}"`:value}`;input.setRangeText(`${mention}${close?' ':''}`,from,to,'end');state.mentionStart=from;state.mentionEnd=from+mention.length;if(directory&&!close&&quoted)input.setSelectionRange(state.mentionEnd-1,state.mentionEnd-1);input.focus();if(close)closeMention();
772
+ function writeMention(relativePath, {directory = false, close = true} = {}) {
773
+ if (!state.workspace) return;
774
+ const input = $('input'),
775
+ context = mentionContext(),
776
+ from = state.mentionStart ?? context?.start ?? input.selectionStart,
777
+ to = state.mentionEnd ?? context?.replacementEnd ?? input.selectionStart;
778
+ const value = mentionValue(relativePath, directory),
779
+ quoted = value.includes(' '),
780
+ mention = `@${quoted ? `"${value}"` : value}`;
781
+ input.setRangeText(`${mention}${close ? ' ' : ''}`, from, to, 'end');
782
+ state.mentionStart = from;
783
+ state.mentionEnd = from + mention.length;
784
+ if (directory && !close && quoted) input.setSelectionRange(state.mentionEnd - 1, state.mentionEnd - 1);
785
+ input.focus();
786
+ if (close) closeMention();
787
+ }
788
+ function insertMention(relativePath) {
789
+ state.mentionStart = null;
790
+ state.mentionEnd = null;
791
+ writeMention(relativePath);
163
792
  }
164
- function insertMention(relativePath) { state.mentionStart=null;state.mentionEnd=null;writeMention(relativePath); }
165
793
  function setMentionSelection(index) {
166
- const count=state.mentionFiltered.length;if(!count){state.mentionIndex=0;return;}state.mentionIndex=(index+count)%count;
167
- $('mentionItems').querySelectorAll('.picker-item').forEach((element,i)=>element.classList.toggle('selected',i===state.mentionIndex));
168
- $('mentionItems').querySelector('.picker-item.selected')?.scrollIntoView({block:'nearest'});
169
- }
170
- function browseMention(path) { writeMention(path,{directory:true,close:false});state.mentionPrefix=mentionValue(path,true);void openMention(path,''); }
171
- function chooseMention(option,close) {
172
- if(!option)return;if(option.type==='directory'&&!close&&!option.current){browseMention(option.path);return;}
173
- writeMention(option.path,{close});if(!close)state.mentionPrefix='';
174
- }
175
- function renderMentionItems(filter='') {
176
- const query=filter.toLocaleLowerCase(),options=state.mentionOptions.filter(option=>!query||(!option.current&&(option.name.toLocaleLowerCase().includes(query)||option.path.toLocaleLowerCase().includes(query))));
177
- state.mentionFiltered=options;state.mentionIndex=0;
178
- if(!options.length){const empty=document.createElement('div');empty.className='picker-empty muted';empty.textContent='没有匹配的文件或目录';$('mentionItems').replaceChildren(empty);return;}
179
- $('mentionItems').replaceChildren(...options.map((option,index)=>{const el=document.createElement('div');el.className=`picker-item ${option.type==='directory'?'dir':'file-entry'}${index===0?' selected':''}`;el.textContent=option.label;el.onmouseenter=()=>setMentionSelection(index);el.onclick=()=>chooseMention(option,option.type!=='directory'||option.current);return el;}));
180
- }
181
- async function openMention(path=state.treePath,filter='') {
182
- if(!state.workspace)return;const request=++state.mentionRequest;state.mentionPath=path;$('mentionPicker').hidden=false;$('mentionPath').textContent=absolutePath(path);
794
+ const count = state.mentionFiltered.length;
795
+ if (!count) {
796
+ state.mentionIndex = 0;
797
+ return;
798
+ }
799
+ state.mentionIndex = (index + count) % count;
800
+ $('mentionItems')
801
+ .querySelectorAll('.picker-item')
802
+ .forEach((element, i) => element.classList.toggle('selected', i === state.mentionIndex));
803
+ $('mentionItems').querySelector('.picker-item.selected')?.scrollIntoView({block: 'nearest'});
804
+ }
805
+ function browseMention(path) {
806
+ writeMention(path, {directory: true, close: false});
807
+ state.mentionPrefix = mentionValue(path, true);
808
+ void openMention(path, '');
809
+ }
810
+ function chooseMention(option, close) {
811
+ if (!option) return;
812
+ if (option.type === 'directory' && !close && !option.current) {
813
+ browseMention(option.path);
814
+ return;
815
+ }
816
+ writeMention(option.path, {close});
817
+ if (!close) state.mentionPrefix = '';
818
+ }
819
+ function renderMentionItems(filter = '') {
820
+ const query = filter.toLocaleLowerCase(),
821
+ options = state.mentionOptions.filter(
822
+ option =>
823
+ !query ||
824
+ (!option.current &&
825
+ (option.name.toLocaleLowerCase().includes(query) ||
826
+ option.path.toLocaleLowerCase().includes(query)))
827
+ );
828
+ state.mentionFiltered = options;
829
+ state.mentionIndex = 0;
830
+ if (!options.length) {
831
+ const empty = document.createElement('div');
832
+ empty.className = 'picker-empty muted';
833
+ empty.textContent = '没有匹配的文件或目录';
834
+ $('mentionItems').replaceChildren(empty);
835
+ return;
836
+ }
837
+ $('mentionItems').replaceChildren(
838
+ ...options.map((option, index) => {
839
+ const el = document.createElement('div');
840
+ el.className = `picker-item ${option.type === 'directory' ? 'dir' : 'file-entry'}${index === 0 ? ' selected' : ''}`;
841
+ el.textContent = option.label;
842
+ el.onmouseenter = () => setMentionSelection(index);
843
+ el.onclick = () => chooseMention(option, option.type !== 'directory' || option.current);
844
+ return el;
845
+ })
846
+ );
847
+ }
848
+ async function openMention(path = state.treePath, filter = '') {
849
+ if (!state.workspace) return;
850
+ const request = ++state.mentionRequest;
851
+ state.mentionPath = path;
852
+ $('mentionPicker').hidden = false;
853
+ $('mentionPath').textContent = absolutePath(path);
183
854
  try {
184
- const items=await api(`/api/v1/workspaces/${state.workspace.id}/tree?path=${encodeURIComponent(path)}`);if(request!==state.mentionRequest)return;
185
- state.mentionOptions=[{name:'引用当前目录',path,type:'directory',current:true,label:'📁 引用当前目录'},...items.map(item=>({name:item.name,path:joinPath(path,item.name),type:item.type,current:false,label:`${item.type==='directory'?'📁':'📄'} ${item.name}`}))];renderMentionItems(filter);
186
- } catch(error){if(request===state.mentionRequest)$('mentionItems').textContent=error.message;}
187
- }
188
- function closeMention(){state.mentionRequest++;state.mentionStart=null;state.mentionEnd=null;state.mentionPrefix='';$('mentionPicker').hidden=true;}
189
-
190
- function agentName(agent) { return agent.sessionName || agent.name; }
191
- function agentLabel(agent) { return agentName(agent) || agent.cwd || agent.agentId; }
192
- function agentSubtitle(agent) { return agentName(agent) ? agent.cwd : agent.agentId; }
193
- const formatTokens = value => { const n=Number(value); if(!Number.isFinite(n)||n<=0)return '0'; if(n>=1e6)return `${(n/1e6).toFixed(1)}M`;if(n>=1e3)return `${(n/1e3).toFixed(n>=1e4?0:1)}k`;return String(Math.round(n)); };
194
- const usageLevel = usage => Number(usage?.percent)>90?'usage-danger':Number(usage?.percent)>70?'usage-warning':'';
195
- const usageText = usage => { if(!usage||!Number(usage.contextWindow))return 'Context ?';const percent=usage.percent==null?'?':`${Number(usage.percent).toFixed(1)}%`;return `${percent} · ${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)}`; };
196
- const costText = usage => { const cost=Number(usage?.cost);if(!Number.isFinite(cost))return 'Session cost ?';return `Session cost ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`; };
197
- const sessionUsage = session => ({...(session.contextUsage||{}),cost:session.stats?.cost});
855
+ const items = await requestBackend(
856
+ state.workspace.backendId,
857
+ `/api/v1/workspaces/${encodeURIComponent(state.workspace.id)}/tree?path=${encodeURIComponent(path)}`
858
+ );
859
+ if (request !== state.mentionRequest) return;
860
+ state.mentionOptions = [
861
+ {name: '引用当前目录', path, type: 'directory', current: true, label: '📁 引用当前目录'},
862
+ ...items.map(item => ({
863
+ name: item.name,
864
+ path: joinPath(path, item.name),
865
+ type: item.type,
866
+ current: false,
867
+ label: `${item.type === 'directory' ? '📁' : '📄'} ${item.name}`
868
+ }))
869
+ ];
870
+ renderMentionItems(filter);
871
+ } catch (error) {
872
+ if (request === state.mentionRequest) $('mentionItems').textContent = error.message;
873
+ }
874
+ }
875
+ function closeMention() {
876
+ state.mentionRequest++;
877
+ state.mentionStart = null;
878
+ state.mentionEnd = null;
879
+ state.mentionPrefix = '';
880
+ $('mentionPicker').hidden = true;
881
+ }
882
+
883
+ // Slash commands: prompt templates and skills are expanded by the Pi session itself
884
+ // (AgentSession.prompt() expands them by default), so the composer only has to help discover them.
885
+ function commandContext() {
886
+ const input = $('input'),
887
+ end = input.selectionStart,
888
+ before = input.value.slice(0, end);
889
+ const match = before.match(/^\/([^\s]*)$/);
890
+ return match ? {end, query: match[1]} : null;
891
+ }
892
+ async function loadCommands(agentId) {
893
+ if (state.commandsAgentId === agentId) return state.commands;
894
+ const commands = await requestAgent(agentId, '/commands');
895
+ return Array.isArray(commands) ? commands : [];
896
+ }
897
+ function setCommandSelection(index) {
898
+ const count = state.commandFiltered.length;
899
+ if (!count) {
900
+ state.commandIndex = 0;
901
+ return;
902
+ }
903
+ state.commandIndex = (index + count) % count;
904
+ $('commandItems')
905
+ .querySelectorAll('.picker-item')
906
+ .forEach((element, i) => element.classList.toggle('selected', i === state.commandIndex));
907
+ $('commandItems').querySelector('.picker-item.selected')?.scrollIntoView({block: 'nearest'});
908
+ }
909
+ function chooseCommand(option) {
910
+ if (!option) return;
911
+ const input = $('input'),
912
+ context = commandContext();
913
+ input.setRangeText(`/${option.name} `, 0, context ? context.end : input.value.length, 'end');
914
+ closeCommandPicker();
915
+ input.focus();
916
+ }
917
+ function renderCommandItems(query = '') {
918
+ const filter = query.toLocaleLowerCase(),
919
+ options = state.commands.filter(
920
+ command => !filter || command.name.toLocaleLowerCase().includes(filter)
921
+ );
922
+ state.commandFiltered = options;
923
+ state.commandIndex = 0;
924
+ if (!options.length) {
925
+ const empty = document.createElement('div');
926
+ empty.className = 'picker-empty muted';
927
+ empty.textContent = state.commands.length ? '没有匹配的命令' : '当前目录没有 prompt template 或 skill';
928
+ $('commandItems').replaceChildren(empty);
929
+ return;
930
+ }
931
+ $('commandItems').replaceChildren(
932
+ ...options.map((option, index) => {
933
+ const el = document.createElement('div');
934
+ el.className = `picker-item${index === 0 ? ' selected' : ''}`;
935
+ const name = document.createElement('b');
936
+ name.textContent = `/${option.name}${option.argumentHint ? ` ${option.argumentHint}` : ''}`;
937
+ const description = document.createElement('small');
938
+ description.className = 'muted';
939
+ description.textContent = `${option.source === 'skill' ? 'skill' : 'prompt'}${option.description ? ` · ${option.description}` : ''}`;
940
+ el.append(name, description);
941
+ el.onmouseenter = () => setCommandSelection(index);
942
+ el.onclick = () => chooseCommand(option);
943
+ return el;
944
+ })
945
+ );
946
+ }
947
+ async function openCommandPicker(query = '') {
948
+ if (!state.agent) return;
949
+ const agentId = state.agent.agentId,
950
+ request = ++state.commandRequest;
951
+ $('commandPicker').hidden = false;
952
+ if (state.commandsAgentId !== agentId) {
953
+ state.commands = [];
954
+ $('commandItems').textContent = '正在加载命令…';
955
+ }
956
+ try {
957
+ const commands = await loadCommands(agentId);
958
+ if (request !== state.commandRequest || state.agent?.agentId !== agentId || $('commandPicker').hidden)
959
+ return;
960
+ state.commands = commands;
961
+ state.commandsAgentId = agentId;
962
+ renderCommandItems(query);
963
+ } catch (error) {
964
+ if (request === state.commandRequest && state.agent?.agentId === agentId) {
965
+ state.commands = [];
966
+ $('commandItems').textContent = '无法加载命令';
967
+ console.error('Unable to load slash commands', error);
968
+ }
969
+ }
970
+ }
971
+ function closeCommandPicker() {
972
+ state.commandRequest++;
973
+ $('commandPicker').hidden = true;
974
+ state.commandFiltered = [];
975
+ state.commandIndex = 0;
976
+ }
977
+
978
+ function agentName(agent) {
979
+ return agent.sessionName || agent.name;
980
+ }
981
+ function agentLabel(agent) {
982
+ return agentName(agent) || agent.cwd || agent.agentId;
983
+ }
984
+ function agentSubtitle(agent) {
985
+ return agentName(agent) ? agent.cwd : agent.agentId;
986
+ }
987
+ function updateKeyboardUi(connection) {
988
+ const connected = connection?.connected ?? sessionKeyboard.connected,
989
+ name = connection?.name || sessionKeyboard.device?.productName || 'Codex Micro';
990
+ $('keyboardConnect').hidden = connected;
991
+ $('keyboardDisconnect').hidden = !connected;
992
+ const unavailable = !window.isSecureContext
993
+ ? '需要 HTTPS 或 localhost'
994
+ : !navigator.hid
995
+ ? '当前浏览器不支持 WebHID'
996
+ : '';
997
+ $('keyboardConnect').disabled = Boolean(unavailable);
998
+ $('keyboardConnect').title = unavailable;
999
+ $('keyboardStatus').textContent = connected
1000
+ ? `已连接 ${name} · LLM 慢速呼吸,Tool 中速呼吸,Retry 快速呼吸,等待输入浅呼吸,空闲常亮`
1001
+ : unavailable || '未连接 · 绑定保存在当前浏览器';
1002
+ renderKeyboardBindings();
1003
+ }
1004
+ function renderKeyboardBindings() {
1005
+ const slots = $('keyboardSlots');
1006
+ if (!slots) return;
1007
+ const focusedId = slots.contains(document.activeElement) ? document.activeElement.id : '';
1008
+ const agents = new Map(state.agents.map(agent => [agent.agentId, agent]));
1009
+ const canBind = hasConnection() && state.agents.some(agent => !sessionKeyboard.getBinding(agent.agentId));
1010
+ slots.replaceChildren(
1011
+ ...Array.from({length: 6}, (_, slot) => {
1012
+ const binding = sessionKeyboard.getSlot(slot),
1013
+ agent = binding && agents.get(binding.agentId);
1014
+ const el = document.createElement('div');
1015
+ el.className = 'keyboard-slot';
1016
+ el.style.setProperty('--key-color', binding?.color || '#586174');
1017
+ const title = document.createElement('b');
1018
+ title.textContent = `键 ${slot + 1}${binding ? ' · ' + binding.color : ''}`;
1019
+ const detail = document.createElement('small');
1020
+ detail.textContent = binding ? (agent ? agentLabel(agent) : 'Session 当前不可用') : '未绑定';
1021
+ const actions = document.createElement('div');
1022
+ actions.className = 'keyboard-slot-actions';
1023
+ if (binding) {
1024
+ const color = document.createElement('button');
1025
+ color.type = 'button';
1026
+ color.textContent = '颜色…';
1027
+ color.id = `keyboardSlot${slot}Color`;
1028
+ color.setAttribute('aria-label', `更换按键 ${slot + 1} 的颜色`);
1029
+ color.onclick = () => changeKeyboardColor(binding).catch(error => toast(error.message));
1030
+ actions.append(color);
1031
+ }
1032
+ const action = document.createElement('button');
1033
+ action.type = 'button';
1034
+ action.id = `keyboardSlot${slot}Binding`;
1035
+ action.textContent = binding ? '取消绑定' : '绑定 Session…';
1036
+ action.setAttribute('aria-label', `${binding ? '取消' : '设置'}按键 ${slot + 1} 的绑定`);
1037
+ action.disabled = !binding && !canBind;
1038
+ action.onclick = async () => {
1039
+ try {
1040
+ if (binding) {
1041
+ sessionKeyboard.unbind(binding.agentId);
1042
+ toast('已取消键盘按键绑定');
1043
+ } else await bindKeyboard({slot});
1044
+ } catch (error) {
1045
+ toast(error.message);
1046
+ }
1047
+ };
1048
+ actions.append(action);
1049
+ el.append(title, detail, actions);
1050
+ return el;
1051
+ })
1052
+ );
1053
+ if (focusedId) $(focusedId)?.focus();
1054
+ }
1055
+ const sessionKeyboard = new window.SessionKeyboardController({
1056
+ onKey: binding => {
1057
+ const agent = state.agents.find(item => item.agentId === binding.agentId);
1058
+ if (!agent) return toast(`按键 ${binding.slot + 1} 绑定的 Session 当前不可用`);
1059
+ if (state.selectedKind === 'agent' && state.agent?.agentId === agent.agentId)
1060
+ return navigateMobile('agent');
1061
+ selectAgent(agent).catch(error => toast(error.message));
1062
+ },
1063
+ onStateChange: updateKeyboardUi,
1064
+ onBindingsChange: () => {
1065
+ renderAgentList();
1066
+ renderKeyboardBindings();
1067
+ },
1068
+ onError: error => {
1069
+ console.error('Session keyboard error', error);
1070
+ toast(`键盘:${error.message}`);
1071
+ }
1072
+ });
1073
+ const formatTokens = value => {
1074
+ const n = Number(value);
1075
+ if (!Number.isFinite(n) || n <= 0) return '0';
1076
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
1077
+ if (n >= 1e3) return `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k`;
1078
+ return String(Math.round(n));
1079
+ };
1080
+ const usageLevel = usage =>
1081
+ Number(usage?.percent) > 90 ? 'usage-danger' : Number(usage?.percent) > 70 ? 'usage-warning' : '';
1082
+ const usageText = usage => {
1083
+ if (!usage || !Number(usage.contextWindow)) return 'Context ?';
1084
+ const percent = usage.percent == null ? '?' : `${Number(usage.percent).toFixed(1)}%`;
1085
+ return `${percent} · ${formatTokens(usage.tokens)}/${formatTokens(usage.contextWindow)}`;
1086
+ };
1087
+ const costText = usage => {
1088
+ const cost = Number(usage?.cost);
1089
+ if (!Number.isFinite(cost)) return 'Session cost ?';
1090
+ return `Session cost ~$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}`;
1091
+ };
1092
+ const sessionUsage = session => ({...(session.contextUsage || {}), cost: session.stats?.cost});
1093
+ // Server time for a backend, corrected by the skew measured when its socket subscribed.
1094
+ const activityView = window.ActivityView.createActivityView(
1095
+ backendId => Date.now() + (backendOf(backendId)?.clockOffset || 0)
1096
+ );
1097
+ setInterval(activityView.tick, 1000);
198
1098
  function renderAgentList() {
199
- const recent=(value,fallback)=>{const time=Date.parse(value||fallback||'');return Number.isFinite(time)?time:0;};
200
- const allItems=[...state.agents.map(item=>({kind:'agent',item})),...state.terminals.map(item=>({kind:'terminal',item}))].sort((a,b)=>recent(b.item.lastActiveAt,b.item.createdAt)-recent(a.item.lastActiveAt,a.item.createdAt));
201
- const items=allItems.slice(0,state.agentVisibleCount);
202
- $('agents').replaceChildren(...items.map(({kind,item}) => {
203
- const isAgent=kind==='agent',id=isAgent?item.agentId:item.terminalId,selected=state.selectedKind===kind&&(isAgent?state.agent?.agentId:state.terminal?.terminalId)===id;
204
- const el=document.createElement('div');el.dataset.itemId=id;el.className=`agent ${item.status}${selected?' selected':''}`;
205
- if(isAgent){const usage=state.contexts.get(item.agentId);el.innerHTML=`<div class="agent-top"><b>${esc(agentLabel(item))}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(agentSubtitle(item))}</small><div class="agent-context ${usageLevel(usage)}"><span>Context</span><div class="mini-track"><i style="width:${Math.min(100,Math.max(0,Number(usage?.percent)||0))}%"></i></div><span>${esc(usageText(usage))} · ${esc(costText(usage))}</span></div>`;}
206
- else el.innerHTML=`<div class="agent-top"><span class="terminal-kind">&gt;_</span><b>${esc(item.title||'Terminal')}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(item.cwd)}</small>`;
207
- el.onclick=()=>isAgent?selectAgent(item):selectTerminal(item);el.oncontextmenu=event=>showItemContextMenu(event,kind,item);return el;
208
- }));
209
- $('agentListCount').textContent=`已显示 ${items.length} / ${allItems.length}`;$('loadMoreAgents').hidden=items.length>=allItems.length;$('loadMoreAgents').disabled=items.length>=allItems.length;
1099
+ const recent = (value, fallback) => {
1100
+ const time = Date.parse(value || fallback || '');
1101
+ return Number.isFinite(time) ? time : 0;
1102
+ };
1103
+ let allItems = [
1104
+ ...state.agents.map(item => ({kind: 'agent', item})),
1105
+ ...state.terminals.map(item => ({kind: 'terminal', item}))
1106
+ ].sort(
1107
+ (a, b) => recent(b.item.lastActiveAt, b.item.createdAt) - recent(a.item.lastActiveAt, a.item.createdAt)
1108
+ );
1109
+ if (state.backendFilter)
1110
+ allItems = allItems.filter(entry => entry.item.backendId === state.backendFilter);
1111
+ const items = allItems.slice(0, state.agentVisibleCount);
1112
+ $('agents').replaceChildren(
1113
+ ...items.map(({kind, item}) => {
1114
+ const isAgent = kind === 'agent',
1115
+ id = isAgent ? item.agentId : item.terminalId,
1116
+ selected =
1117
+ state.selectedKind === kind &&
1118
+ (isAgent ? state.agent?.agentId : state.terminal?.terminalId) === id;
1119
+ const el = document.createElement('div');
1120
+ el.dataset.itemId = id;
1121
+ el.className = `agent ${item.status}${selected ? ' selected' : ''}`;
1122
+ if (isAgent) {
1123
+ const usage = state.contexts.get(item.agentId),
1124
+ binding = sessionKeyboard.getBinding(item.agentId);
1125
+ el.innerHTML = `<div class="agent-top"><b>${esc(agentLabel(item))}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(agentSubtitle(item))}</small><div class="agent-context ${usageLevel(usage)}"><span>Context</span><div class="mini-track"><i style="width:${Math.min(100, Math.max(0, Number(usage?.percent) || 0))}%"></i></div><span>${esc(usageText(usage))} · ${esc(costText(usage))}</span></div>`;
1126
+ if (binding) {
1127
+ const badge = document.createElement('span');
1128
+ badge.className = 'key-binding';
1129
+ badge.style.setProperty('--key-color', binding.color);
1130
+ badge.textContent = `K${binding.slot + 1}`;
1131
+ badge.title = `键盘按键 ${binding.slot + 1} · ${binding.color}`;
1132
+ el.querySelector('.agent-top').append(badge);
1133
+ }
1134
+ if (item.template) {
1135
+ const tag = document.createElement('span');
1136
+ tag.className = 'template-tag';
1137
+ tag.textContent = item.template.name || item.template.id;
1138
+ tag.title = `System Prompt 模板:${item.template.name || item.template.id}(${templateModeText(item.template.mode)},创建时固定)`;
1139
+ el.querySelector('.agent-top').append(tag);
1140
+ }
1141
+ const activityLine = activityView.line(state.activities.get(item.agentId), item.backendId);
1142
+ if (activityLine) el.querySelector('small').after(activityLine);
1143
+ } else
1144
+ el.innerHTML = `<div class="agent-top"><span class="terminal-kind">&gt;_</span><b>${esc(item.title || 'Terminal')}</b><span class="state-badge state-${esc(item.status)}">${esc(item.status)}</span></div><small>${esc(item.cwd)}</small>`;
1145
+ if (connectedBackends().length > 1) {
1146
+ const tag = document.createElement('span');
1147
+ tag.className = 'backend-tag';
1148
+ tag.textContent = item.backendName;
1149
+ tag.title = `来自 backend ${item.backendName}`;
1150
+ el.querySelector('.agent-top').append(tag);
1151
+ }
1152
+ el.onclick = () => (isAgent ? selectAgent(item) : selectTerminal(item));
1153
+ el.oncontextmenu = event => showItemContextMenu(event, kind, item);
1154
+ return el;
1155
+ })
1156
+ );
1157
+ $('agentListCount').textContent = `已显示 ${items.length} / ${allItems.length}`;
1158
+ $('loadMoreAgents').hidden = items.length >= allItems.length;
1159
+ $('loadMoreAgents').disabled = items.length >= allItems.length;
1160
+ }
1161
+ async function loadAgentContexts(agents = state.agents) {
1162
+ await Promise.allSettled(
1163
+ agents.map(async agent => {
1164
+ const session = await requestAgent(agent.agentId, '/session?summary=true');
1165
+ agent.sessionName = session.sessionName || undefined;
1166
+ let lastMessageAt = 0;
1167
+ for (const entry of session.entries || []) {
1168
+ if (entry.type !== 'message') continue;
1169
+ const time = Date.parse(entry.timestamp || '');
1170
+ if (Number.isFinite(time)) lastMessageAt = Math.max(lastMessageAt, time);
1171
+ }
1172
+ if (lastMessageAt) agent.lastActiveAt = new Date(lastMessageAt).toISOString();
1173
+ state.contexts.set(agent.agentId, sessionUsage(session));
1174
+ })
1175
+ );
1176
+ renderAgentList();
1177
+ if (state.agent) updateAgentHeader();
1178
+ }
1179
+
1180
+ function stampNewItem(backendId, item) {
1181
+ const backend = backendOf(backendId);
1182
+ const idProperty = item.terminalId ? 'terminalId' : 'agentId';
1183
+ return backendRegistry.stamp(backend, item, idProperty);
1184
+ }
1185
+
1186
+ async function refreshBackendSessions(backend) {
1187
+ const [agents, terminals] = await Promise.all([
1188
+ requestBackend(backend.id, '/api/v1/agents'),
1189
+ requestBackend(backend.id, '/api/v1/terminals')
1190
+ ]);
1191
+ if (backend.status !== 'connected') return;
1192
+ backend.agents = agents.map(agent => backendRegistry.stamp(backend, agent, 'agentId'));
1193
+ backend.terminals = terminals.map(terminal => backendRegistry.stamp(backend, terminal, 'terminalId'));
210
1194
  }
211
- async function loadAgentContexts(agents=state.agents) {
212
- await Promise.allSettled(agents.map(async agent=>{const session=await api(`/api/v1/agents/${agent.agentId}/session?summary=true`);agent.sessionName=session.sessionName||undefined;let lastMessageAt=0;for(const entry of session.entries||[]){if(entry.type!=='message')continue;const time=Date.parse(entry.timestamp||'');if(Number.isFinite(time))lastMessageAt=Math.max(lastMessageAt,time);}if(lastMessageAt)agent.lastActiveAt=new Date(lastMessageAt).toISOString();state.contexts.set(agent.agentId,sessionUsage(session));}));
213
- renderAgentList();if(state.agent)updateAgentHeader();
1195
+
1196
+ function syncSessionLists() {
1197
+ state.agents = backendRegistry.resources('agents');
1198
+ state.terminals = backendRegistry.resources('terminals');
1199
+ if (state.agent) {
1200
+ const current = state.agents.find(agent => agent.agentId === state.agent.agentId);
1201
+ if (current) Object.assign(state.agent, current);
1202
+ else clearAgentSelection(state.agent.backendId);
1203
+ }
1204
+ if (state.terminal) {
1205
+ const current = state.terminals.find(terminal => terminal.terminalId === state.terminal.terminalId);
1206
+ if (current) Object.assign(state.terminal, current);
1207
+ else clearTerminalSelection(state.terminal.backendId);
1208
+ }
1209
+ sessionKeyboard.setAgents(state.agents);
1210
+ renderAgentList();
1211
+ renderKeyboardBindings();
214
1212
  }
1213
+
215
1214
  async function refreshAgents(selectPrevious = false) {
216
- if (!state.connected) return;
217
- [state.agents,state.terminals] = await Promise.all([api('/api/v1/agents'),api('/api/v1/terminals')]); const previous = state.agent?.agentId || localStorage.rpAgentId,previousTerminal=state.terminal?.terminalId||localStorage.rpTerminalId;
218
- if(state.agent){const fresh=state.agents.find(x=>x.agentId===state.agent.agentId);if(fresh)state.agent=Object.assign(state.agent,fresh);}
1215
+ if (!hasConnection()) return;
1216
+ const previousAgentId = state.agent?.agentId || localStorage.rpAgentId;
1217
+ const previousTerminalId = state.terminal?.terminalId || localStorage.rpTerminalId;
1218
+ await Promise.allSettled(connectedBackends().map(refreshBackendSessions));
1219
+ syncSessionLists();
1220
+
1221
+ renderAgentList();
1222
+ if (!selectPrevious) return;
1223
+ const terminal = state.terminals.find(item => item.terminalId === previousTerminalId);
1224
+ const agent = state.agents.find(item => item.agentId === previousAgentId);
1225
+ if (localStorage.rpSelectedKind === 'terminal' && terminal) await selectTerminal(terminal, false);
1226
+ else if (agent) await selectAgent(agent, false);
1227
+ }
1228
+ function renderActivityBar() {
1229
+ const bar = $('activityBar'),
1230
+ agent = state.selectedKind === 'agent' ? state.agent : null,
1231
+ nodes = agent ? activityView.bar(state.activities.get(agent.agentId), agent.backendId) : [];
1232
+ bar.replaceChildren(...nodes);
1233
+ bar.hidden = !nodes.length;
1234
+ }
1235
+ function setAgentActivity(agentId, activity) {
1236
+ if (window.ActivityView.isBusy(activity)) state.activities.set(agentId, activity);
1237
+ else state.activities.delete(agentId);
1238
+ sessionKeyboard.setActivity(agentId, activity);
1239
+ refreshToolCards(agentId);
219
1240
  renderAgentList();
220
- if (selectPrevious) { const foundTerminal=state.terminals.find(x=>x.terminalId===previousTerminal),found = state.agents.find(x => x.agentId === previous); if(localStorage.rpSelectedKind==='terminal'&&foundTerminal)await selectTerminal(foundTerminal,false);else if (found) await selectAgent(found, false); else connectSocket(); }
221
- else if (!state.ws) connectSocket();
1241
+ if (state.agent?.agentId === agentId) renderActivityBar();
222
1242
  }
223
- function setAgentStatus(agentId,status) {
224
- const agent=state.agents.find(x=>x.agentId===agentId);if(!agent){void refreshAgents();return null;}agent.status=status;if(state.agent?.agentId===agentId)state.agent.status=status;renderAgentList();updateAgentHeader();return agent;
1243
+ function setAgentStatus(agentId, status) {
1244
+ const agent = state.agents.find(x => x.agentId === agentId);
1245
+ if (!agent) {
1246
+ void refreshAgents();
1247
+ return null;
1248
+ }
1249
+ // Every event reports the status, so an unchanged one must not rebuild the list for each delta.
1250
+ if (agent.status === status) return agent;
1251
+ agent.status = status;
1252
+ if (state.agent?.agentId === agentId) state.agent.status = status;
1253
+ sessionKeyboard.updateAgent(agent);
1254
+ renderAgentList();
1255
+ updateAgentHeader();
1256
+ return agent;
225
1257
  }
226
1258
  function updateAgentHeader(agent = state.agent) {
227
1259
  if (!agent) return;
228
- $('agentTitle').textContent = agentLabel(agent); $('agentStatus').textContent = agentSubtitle(agent);
229
- const badge=$('agentStateBadge');badge.textContent=agent.status;badge.className=`state-badge state-${agent.status}`;
230
- const usage=state.contexts.get(agent.agentId),panel=$('contextUsage');panel.hidden=false;$('contextText').textContent=`Context ${usageText(usage)} · ${costText(usage)}`;const percent=Math.min(100,Math.max(0,Number(usage?.percent)||0));$('contextBar').style.width=`${percent}%`;panel.className=`context-usage ${usageLevel(usage)}`;
231
- }
232
- function showAgentView(){
233
- $('agentToolbar').hidden=false;$('terminalToolbar').hidden=true;$('terminalView').hidden=true;$('widgets').hidden=false;$('messages').hidden=false;$('prompt').hidden=false;
234
- }
235
- function ensureTerminalEmulator(){
236
- if(state.terminalEmulator)return;
237
- const term=new Terminal({cursorBlink:true,convertEol:false,fontFamily:'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',fontSize:14,scrollback:5000,theme:{background:'#111111',foreground:'#d8dee9',cursor:'#88c0d0'}}),fit=new FitAddon.FitAddon();
238
- term.loadAddon(fit);term.open($('terminal'));term.onData(data=>{if(state.terminalWs?.readyState===WebSocket.OPEN)state.terminalWs.send(JSON.stringify({type:'input',data}));});
239
- term.onResize(size=>{if(state.terminalWs?.readyState===WebSocket.OPEN)state.terminalWs.send(JSON.stringify({type:'resize',cols:size.cols,rows:size.rows}));});
240
- state.resizeObserver=new ResizeObserver(()=>{if(!$('terminalView').hidden)try{fit.fit();}catch{}});state.resizeObserver.observe($('terminalView'));state.terminalEmulator=term;state.fitAddon=fit;
241
- }
242
- function connectTerminalSocket(terminal){
243
- state.terminalWs?.close();ensureTerminalEmulator();const term=state.terminalEmulator;term.reset();term.clear();
244
- const ws=new WebSocket(state.base.replace(/^http/,'ws')+`/api/v1/terminals/${encodeURIComponent(terminal.terminalId)}/ws`,['access-token.'+state.token]);state.terminalWs=ws;
245
- ws.onopen=()=>{if(ws!==state.terminalWs)return;requestAnimationFrame(()=>{try{state.fitAddon.fit();ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));term.focus();}catch{}});};
246
- ws.onmessage=event=>{if(ws!==state.terminalWs)return;try{const message=JSON.parse(event.data);if(message.type==='snapshot'){term.reset();if(message.data)term.write(message.data);terminal.status=message.record.status;updateTerminalHeader();renderAgentList();}else if(message.type==='output')term.write(message.data);else if(message.type==='exit'){terminal.status='exited';terminal.exitCode=message.exitCode;updateTerminalHeader();renderAgentList();void refreshAgents();}else if(message.type==='error')toast(message.message);}catch(error){console.error(error);}};
247
- ws.onerror=()=>{};ws.onclose=()=>{if(ws===state.terminalWs&&terminal.status==='running')$('terminalStatus').textContent=`${terminal.cwd} · 连接已断开`;};
248
- }
249
- function updateTerminalHeader(){const terminal=state.terminal;if(!terminal)return;$('terminalTitle').textContent=terminal.title||'Terminal';$('terminalStatus').textContent=`${terminal.cwd}${terminal.exitCode===undefined?'':` · exit ${terminal.exitCode}`}`;const badge=$('terminalStateBadge');badge.textContent=terminal.status;badge.className=`state-badge state-${terminal.status}`;}
250
- async function selectTerminal(terminal,openView=true){
251
- state.selectedKind='terminal';state.terminal=terminal;localStorage.rpTerminalId=terminal.terminalId;localStorage.rpSelectedKind='terminal';$('agentToolbar').hidden=true;$('terminalToolbar').hidden=false;$('terminalView').hidden=false;$('widgets').hidden=true;$('messages').hidden=true;$('prompt').hidden=true;updateTerminalHeader();renderAgentList();if(openView)navigateMobile('agent');connectTerminalSocket(terminal);
252
- }
253
- async function startAgent(relativePath){if(!state.workspace)return toast('请先选择 Workspace');$('contextMenu').hidden=true;try{const agent=await post('/api/v1/agents',{workspaceId:state.workspace.id,relativeCwd:relativePath});$('agentCwd').value=relativePath;await refreshAgents();await selectAgent(agent);}catch(error){toast(error.message);}}
254
- async function openTerminal(relativePath){if(!state.workspace)return toast('请先选择 Workspace');try{const terminal=await post('/api/v1/terminals',{workspaceId:state.workspace.id,relativeCwd:relativePath});state.terminals.unshift(terminal);$('contextMenu').hidden=true;renderAgentList();await selectTerminal(terminal);}catch(error){toast(error.message);}}
255
- async function closeTerminal(terminal=state.terminal){if(!terminal)return;try{await api(`/api/v1/terminals/${terminal.terminalId}`,{method:'DELETE'});state.terminalWs?.close();state.terminalWs=null;state.terminals=state.terminals.filter(x=>x.terminalId!==terminal.terminalId);if(state.terminal?.terminalId===terminal.terminalId){state.terminal=null;localStorage.removeItem('rpTerminalId');localStorage.rpSelectedKind='agent';showAgentView();}renderAgentList();toast('Terminal 已关闭');}catch(error){toast(error.message);}}
1260
+ $('agentTitle').textContent = agentLabel(agent);
1261
+ $('agentStatus').textContent = agentSubtitle(agent);
1262
+ const badge = $('agentStateBadge');
1263
+ badge.textContent = agent.status;
1264
+ badge.className = `state-badge state-${agent.status}`;
1265
+ const usage = state.contexts.get(agent.agentId),
1266
+ panel = $('contextUsage');
1267
+ panel.hidden = false;
1268
+ $('contextText').textContent = `Context ${usageText(usage)} · ${costText(usage)}`;
1269
+ const percent = Math.min(100, Math.max(0, Number(usage?.percent) || 0));
1270
+ $('contextBar').style.width = `${percent}%`;
1271
+ panel.className = `context-usage ${usageLevel(usage)}`;
1272
+ renderActivityBar();
1273
+ }
1274
+ function showAgentView() {
1275
+ $('agentToolbar').hidden = false;
1276
+ $('terminalToolbar').hidden = true;
1277
+ $('terminalView').hidden = true;
1278
+ $('widgets').hidden = false;
1279
+ $('messages').hidden = false;
1280
+ $('prompt').hidden = false;
1281
+ }
1282
+ const loadTerminalStyle = () =>
1283
+ new Promise((resolve, reject) => {
1284
+ const id = 'xterm-styles',
1285
+ loaded = document.getElementById(id);
1286
+ if (loaded?.dataset.loaded === 'true') return resolve();
1287
+ const link = loaded || document.createElement('link');
1288
+ link.id = id;
1289
+ link.rel = 'stylesheet';
1290
+ link.href = '/vendor/xterm.css';
1291
+ link.onload = () => {
1292
+ link.dataset.loaded = 'true';
1293
+ resolve();
1294
+ };
1295
+ link.onerror = () => {
1296
+ link.remove();
1297
+ reject(Error('无法加载 /vendor/xterm.css'));
1298
+ };
1299
+ if (!loaded) document.head.append(link);
1300
+ });
1301
+ const loadTerminalScript = (id, src) =>
1302
+ new Promise((resolve, reject) => {
1303
+ const loaded = document.getElementById(id);
1304
+ if (loaded?.dataset.loaded === 'true') return resolve();
1305
+ const script = loaded || document.createElement('script');
1306
+ script.id = id;
1307
+ script.src = src;
1308
+ script.async = true;
1309
+ script.onload = () => {
1310
+ script.dataset.loaded = 'true';
1311
+ resolve();
1312
+ };
1313
+ script.onerror = () => {
1314
+ script.remove();
1315
+ reject(Error(`无法加载 ${src}`));
1316
+ };
1317
+ if (!loaded) document.head.append(script);
1318
+ });
1319
+ function loadTerminalAssets() {
1320
+ if (window.Terminal && window.FitAddon?.FitAddon) return Promise.resolve();
1321
+ if (!state.terminalAssetsPromise)
1322
+ state.terminalAssetsPromise = (async () => {
1323
+ await Promise.all([loadTerminalStyle(), loadTerminalScript('xterm-script', '/vendor/xterm.js')]);
1324
+ await loadTerminalScript('xterm-fit-script', '/vendor/addon-fit.js');
1325
+ if (typeof window.Terminal !== 'function' || typeof window.FitAddon?.FitAddon !== 'function')
1326
+ throw Error('xterm.js 初始化失败');
1327
+ })().catch(error => {
1328
+ state.terminalAssetsPromise = null;
1329
+ throw error;
1330
+ });
1331
+ return state.terminalAssetsPromise;
1332
+ }
1333
+ async function ensureTerminalEmulator() {
1334
+ if (state.terminalEmulator) return state.terminalEmulator;
1335
+ await loadTerminalAssets();
1336
+ if (state.terminalEmulator) return state.terminalEmulator;
1337
+ const term = new window.Terminal({
1338
+ cursorBlink: true,
1339
+ convertEol: false,
1340
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
1341
+ fontSize: 14,
1342
+ scrollback: 5000,
1343
+ theme: {background: '#111111', foreground: '#d8dee9', cursor: '#88c0d0'}
1344
+ }),
1345
+ fit = new window.FitAddon.FitAddon();
1346
+ term.loadAddon(fit);
1347
+ term.open($('terminal'));
1348
+ term.onData(data => {
1349
+ if (state.terminalWs?.readyState === WebSocket.OPEN)
1350
+ state.terminalWs.send(JSON.stringify({type: 'input', data}));
1351
+ });
1352
+ term.onResize(size => {
1353
+ if (state.terminalWs?.readyState === WebSocket.OPEN)
1354
+ state.terminalWs.send(JSON.stringify({type: 'resize', cols: size.cols, rows: size.rows}));
1355
+ });
1356
+ state.resizeObserver = new ResizeObserver(() => {
1357
+ if (!$('terminalView').hidden)
1358
+ try {
1359
+ fit.fit();
1360
+ } catch {}
1361
+ });
1362
+ state.resizeObserver.observe($('terminalView'));
1363
+ state.terminalEmulator = term;
1364
+ state.fitAddon = fit;
1365
+ return term;
1366
+ }
1367
+ async function connectTerminalSocket(terminal) {
1368
+ state.terminalWs?.close();
1369
+ state.terminalWs = null;
1370
+ const terminalId = terminal.terminalId,
1371
+ attempt = ++state.terminalConnectAttempt;
1372
+ $('terminalStatus').textContent = `${terminal.cwd} · 正在加载 Terminal…`;
1373
+ try {
1374
+ const term = await ensureTerminalEmulator();
1375
+ if (
1376
+ attempt !== state.terminalConnectAttempt ||
1377
+ state.selectedKind !== 'terminal' ||
1378
+ state.terminal?.terminalId !== terminalId
1379
+ )
1380
+ return;
1381
+ term.reset();
1382
+ term.clear();
1383
+ $('terminalStatus').textContent = `${terminal.cwd} · 正在连接…`;
1384
+ const terminalBackend = backendOf(terminal.backendId);
1385
+ if (!terminalBackend || terminalBackend.status !== 'connected')
1386
+ throw Error(`Backend「${terminal.backendName || terminal.backendId}」未连接`);
1387
+ const ws = new WebSocket(
1388
+ terminalBackend.base.replace(/^http/, 'ws') +
1389
+ `/api/v1/terminals/${encodeURIComponent(terminal.remoteId)}/ws`,
1390
+ ['access-token.' + terminalBackend.token]
1391
+ );
1392
+ state.terminalWs = ws;
1393
+ ws.onopen = () => {
1394
+ if (ws !== state.terminalWs) return;
1395
+ requestAnimationFrame(() => {
1396
+ try {
1397
+ state.fitAddon.fit();
1398
+ ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows}));
1399
+ term.focus();
1400
+ } catch {}
1401
+ });
1402
+ };
1403
+ ws.onmessage = event => {
1404
+ if (ws !== state.terminalWs) return;
1405
+ try {
1406
+ const message = JSON.parse(event.data);
1407
+ if (message.type === 'snapshot') {
1408
+ term.reset();
1409
+ if (message.data) term.write(message.data);
1410
+ terminal.status = message.record.status;
1411
+ updateTerminalHeader();
1412
+ renderAgentList();
1413
+ } else if (message.type === 'output') term.write(message.data);
1414
+ else if (message.type === 'exit') {
1415
+ terminal.status = 'exited';
1416
+ terminal.exitCode = message.exitCode;
1417
+ updateTerminalHeader();
1418
+ renderAgentList();
1419
+ void refreshAgents();
1420
+ } else if (message.type === 'error') toast(message.message);
1421
+ } catch (error) {
1422
+ console.error(error);
1423
+ }
1424
+ };
1425
+ ws.onerror = () => {};
1426
+ ws.onclose = () => {
1427
+ if (ws === state.terminalWs && terminal.status === 'running')
1428
+ $('terminalStatus').textContent = `${terminal.cwd} · 连接已断开`;
1429
+ };
1430
+ } catch (error) {
1431
+ if (
1432
+ attempt === state.terminalConnectAttempt &&
1433
+ state.selectedKind === 'terminal' &&
1434
+ state.terminal?.terminalId === terminalId
1435
+ ) {
1436
+ $('terminalStatus').textContent = `${terminal.cwd} · Terminal 加载失败`;
1437
+ toast(`Terminal 加载失败:${error.message}`);
1438
+ }
1439
+ }
1440
+ }
1441
+ function updateTerminalHeader() {
1442
+ const terminal = state.terminal;
1443
+ if (!terminal) return;
1444
+ $('terminalTitle').textContent = terminal.title || 'Terminal';
1445
+ $('terminalStatus').textContent =
1446
+ `${terminal.cwd}${terminal.exitCode === undefined ? '' : ` · exit ${terminal.exitCode}`}`;
1447
+ const badge = $('terminalStateBadge');
1448
+ badge.textContent = terminal.status;
1449
+ badge.className = `state-badge state-${terminal.status}`;
1450
+ }
1451
+ /**
1452
+ * Mentions, cwd defaults and "start agent here" all read the selected Workspace, so selecting a
1453
+ * session on another Backend must move the file browser with it. Without this the browser keeps
1454
+ * pointing at the previous machine and inserts its paths into prompts sent elsewhere.
1455
+ *
1456
+ * Never await this from a selection: the Workspace is committed synchronously and only the tree
1457
+ * load is asynchronous, so waiting for it would let a slow tree restore the older selection.
1458
+ */
1459
+ async function syncWorkspaceForSelection(item) {
1460
+ const backend = backendOf(item.backendId);
1461
+ if (!backend || backend.status !== 'connected') return;
1462
+ if (ownsPath(state.workspace, item)) return;
1463
+ const match =
1464
+ matchWorkspace(backend.workspaces, item.cwd) ||
1465
+ (state.workspace?.backendId === item.backendId ? state.workspace : backend.workspaces[0]);
1466
+ if (!match) {
1467
+ clearWorkspaceSelection();
1468
+ return;
1469
+ }
1470
+ if (state.workspace && workspaceKey(state.workspace) === workspaceKey(match)) return;
1471
+ await selectWorkspace(workspaceKey(match));
1472
+ }
1473
+ async function selectTerminal(terminal, openView = true) {
1474
+ state.selectedKind = 'terminal';
1475
+ state.terminal = terminal;
1476
+ localStorage.rpTerminalId = terminal.terminalId;
1477
+ localStorage.rpSelectedKind = 'terminal';
1478
+ $('agentToolbar').hidden = true;
1479
+ $('terminalToolbar').hidden = false;
1480
+ $('terminalView').hidden = false;
1481
+ $('widgets').hidden = true;
1482
+ $('messages').hidden = true;
1483
+ $('prompt').hidden = true;
1484
+ updateTerminalHeader();
1485
+ renderAgentList();
1486
+ sendFocus();
1487
+ if (openView) navigateMobile('agent');
1488
+ void syncWorkspaceForSelection(terminal);
1489
+ void connectTerminalSocket(terminal);
1490
+ }
1491
+ const autoBindNewSession = agent => sessionKeyboard.autoBind(agent.agentId);
1492
+ async function startAgent(relativePath) {
1493
+ if (!state.workspace) return toast('请先选择 Workspace');
1494
+ $('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
+ }
1510
+ }
1511
+ async function openTerminal(relativePath) {
1512
+ if (!state.workspace) return toast('请先选择 Workspace');
1513
+ try {
1514
+ const backend = backendOf(state.workspace.backendId);
1515
+ const created = await postBackend(backend.id, '/api/v1/terminals', {
1516
+ workspaceId: state.workspace.id,
1517
+ relativeCwd: relativePath
1518
+ });
1519
+ const terminal = backendRegistry.stamp(backend, created, 'terminalId');
1520
+ backend.terminals.unshift(terminal);
1521
+ syncSessionLists();
1522
+ $('contextMenu').hidden = true;
1523
+ await selectTerminal(terminal);
1524
+ } catch (error) {
1525
+ toast(error.message);
1526
+ }
1527
+ }
1528
+ async function closeTerminal(terminal = state.terminal) {
1529
+ if (!terminal) return;
1530
+ try {
1531
+ await requestBackend(terminal.backendId, `/api/v1/terminals/${encodeURIComponent(terminal.remoteId)}`, {
1532
+ method: 'DELETE'
1533
+ });
1534
+ const backend = backendOf(terminal.backendId);
1535
+ if (backend)
1536
+ backend.terminals = backend.terminals.filter(item => item.terminalId !== terminal.terminalId);
1537
+ if (state.terminal?.terminalId === terminal.terminalId) clearTerminalSelection(terminal.backendId);
1538
+ syncSessionLists();
1539
+ toast('Terminal 已关闭');
1540
+ } catch (error) {
1541
+ toast(error.message);
1542
+ }
1543
+ }
256
1544
  async function selectAgent(agent, openView = true) {
257
- state.selectedKind='agent';state.terminalWs?.close();state.terminalWs=null;state.agent = agent; localStorage.rpAgentId = agent.agentId;localStorage.rpSelectedKind='agent';showAgentView();updateAgentHeader();
1545
+ state.selectedKind = 'agent';
1546
+ state.terminalWs?.close();
1547
+ state.terminalWs = null;
1548
+ state.agent = agent;
1549
+ localStorage.rpAgentId = agent.agentId;
1550
+ localStorage.rpSelectedKind = 'agent';
1551
+ showAgentView();
1552
+ updateAgentHeader();
258
1553
  if (openView) navigateMobile('agent');
259
- if (state.workspace && isInside(clean(agent.cwd), clean(state.workspace.rootPath))) $('agentCwd').value = relativeTo(clean(agent.cwd), clean(state.workspace.rootPath));
260
- $('messages').replaceChildren(); state.streams.clear();state.messagePageStart=0;state.messageTotal=0;
261
- try { await loadMessagePage(agent.agentId,undefined,true); await loadSessionIdentity(); } catch (error) { addCard('加载消息失败', error.message, 'error', true); }
1554
+ void syncWorkspaceForSelection(agent);
1555
+ if (ownsPath(state.workspace, agent))
1556
+ $('agentCwd').value = relativeTo(clean(agent.cwd), clean(state.workspace.rootPath));
1557
+ $('messages').replaceChildren();
1558
+ closeCommandPicker();
1559
+ state.commands = [];
1560
+ state.commandsAgentId = null;
1561
+ renderQueueBar();
1562
+ try {
1563
+ loadTimeline(agent);
1564
+ await loadSessionIdentity();
1565
+ } catch (error) {
1566
+ addCard('加载消息失败', error.message, 'error', true);
1567
+ }
262
1568
  renderAgentList();
263
- connectSocket();
1569
+ const backend = backendOf(agent.backendId);
1570
+ if (backend?.ws?.readyState === WebSocket.OPEN)
1571
+ backend.ws.send(JSON.stringify({type: 'subscribe_drafts'}));
264
1572
  }
265
1573
  async function loadSessionIdentity() {
266
1574
  if (!state.agent) return;
267
- const session = await api(`/api/v1/agents/${state.agent.agentId}/session?summary=true`);
268
- state.agent.sessionName=session.sessionName||undefined;state.contexts.set(state.agent.agentId,sessionUsage(session));renderAgentList();updateAgentHeader();
1575
+ const session = await requestAgent(state.agent.agentId, '/session?summary=true');
1576
+ state.agent.sessionName = session.sessionName || undefined;
1577
+ state.contexts.set(state.agent.agentId, sessionUsage(session));
1578
+ renderAgentList();
1579
+ updateAgentHeader();
269
1580
  }
270
1581
 
271
1582
  function textContent(content) {
272
1583
  if (typeof content === 'string') return content;
273
1584
  if (!Array.isArray(content)) return '';
274
- return content.map(part => part?.type === 'text' ? part.text : part?.type === 'thinking' ? part.thinking || part.text : part?.type === 'toolCall' ? `${part.name || 'Tool'}\n${JSON.stringify(part.arguments || {}, null, 2)}` : '').filter(Boolean).join('\n');
1585
+ return content
1586
+ .map(part =>
1587
+ part?.type === 'text'
1588
+ ? part.text
1589
+ : part?.type === 'thinking'
1590
+ ? part.thinking || part.text
1591
+ : part?.type === 'toolCall'
1592
+ ? `${part.name || 'Tool'}\n${JSON.stringify(part.arguments || {}, null, 2)}`
1593
+ : ''
1594
+ )
1595
+ .filter(Boolean)
1596
+ .join('\n');
275
1597
  }
1598
+ // Pi persists failed provider responses as empty assistant messages with
1599
+ // stopReason="error" and errorMessage. They have no content to render, so
1600
+ // handle the error metadata explicitly instead of silently dropping them.
276
1601
  function renderCardBody(body, content, markdown = false) {
277
- const value=String(content??'');body.classList.toggle('markdown',markdown);
278
- if(markdown&&window.marked&&window.DOMPurify){body.innerHTML=DOMPurify.sanitize(marked.parse(value,{gfm:true,breaks:true}));for(const link of body.querySelectorAll('a')){link.target='_blank';link.rel='noopener noreferrer';}}
279
- else body.textContent=value;
280
- }
281
- function addCard(title, content, kind = 'system', open = false, timestamp, target = $('messages'), autoScroll = true) {
282
- const derivedTitle=!title,details=document.createElement('details'); details.className = `msg ${kind}${derivedTitle?' derived-title':''}`; details.open = open;
283
- const summary=document.createElement('summary'),summaryText=document.createElement('span'),summaryLabel=document.createElement('span');summaryText.className='summary-text';summaryText.textContent=title||String(content).trim()||kind;summaryLabel.className='summary-label';summaryLabel.textContent=kind==='user'?'You':kind==='assistant'?'Assistant':kind;summary.append(summaryText,summaryLabel);
284
- if (timestamp) { const time = document.createElement('span'); time.className = 'event-time'; time.textContent = new Date(timestamp * 1000).toLocaleTimeString(); summary.append(time); }
285
- const body = document.createElement('div'); body.className = 'msg-content';renderCardBody(body,content,kind==='assistant');
286
- details.append(summary, body); target.append(details); if(autoScroll&&target===$('messages'))$('messages').scrollTop = $('messages').scrollHeight; return {details, summary, summaryText, body, content:String(content??'')};
287
- }
288
- function renderMessages(messages, target = $('messages'), showEmpty = true) {
289
- if (!messages?.length) { if(showEmpty&&target===$('messages'))$('messages').innerHTML = '<div class="empty">尚无消息</div>'; return; }
290
- for (const message of messages) {
291
- const role = message.role || 'event';
292
- if (Array.isArray(message.content) && role === 'assistant') {
293
- for (const part of message.content) {
294
- if (part.type === 'text' && part.text) addCard(null,part.text,'assistant',false,undefined,target,false);
295
- else if (part.type === 'thinking' && (part.thinking||part.text)) addCard('Thinking',part.thinking||part.text,'thinking',false,undefined,target,false);
296
- else if (part.type === 'toolCall') addCard(`🔧 ${part.name||'Tool'}`,JSON.stringify(part.arguments||{},null,2),'tool',false,undefined,target,false);
297
- }
298
- } else { const content = textContent(message.content); if (content) addCard(['user','assistant'].includes(role)?null:role, content, role === 'user' ? 'user' : role === 'assistant' ? 'assistant' : role === 'toolResult' ? 'tool' : 'system',false,undefined,target,false); }
1602
+ const value = String(content ?? '');
1603
+ body.classList.toggle('markdown', markdown);
1604
+ if (markdown && window.marked && window.DOMPurify) {
1605
+ body.innerHTML = DOMPurify.sanitize(marked.parse(value, {gfm: true, breaks: true}));
1606
+ for (const link of body.querySelectorAll('a')) {
1607
+ link.target = '_blank';
1608
+ link.rel = 'noopener noreferrer';
1609
+ }
1610
+ } else body.textContent = value;
1611
+ }
1612
+ // The transcript only shows what a human reads linearly: prompts, answers, thinking, dialogs and errors.
1613
+ // Everything else (tool calls, bash output, retries, extension chatter) is folded into one collapsed
1614
+ // activity group per stretch of activity, whose summary tracks the most recent item while it streams.
1615
+ const TRANSCRIPT_KINDS = new Set(['user', 'assistant', 'thinking', 'dialog', 'voice', 'error']);
1616
+ function activityGroup(target) {
1617
+ const last = target.lastElementChild;
1618
+ if (last?.classList?.contains('activity-group')) return last;
1619
+ const group = document.createElement('details');
1620
+ group.className = 'msg activity-group';
1621
+ const summary = document.createElement('summary'),
1622
+ text = document.createElement('span'),
1623
+ count = document.createElement('span'),
1624
+ time = document.createElement('span');
1625
+ text.className = 'summary-text';
1626
+ text.textContent = '活动';
1627
+ count.className = 'activity-count';
1628
+ time.className = 'event-time';
1629
+ summary.append(text, count, time);
1630
+ const items = document.createElement('div');
1631
+ items.className = 'activity-items';
1632
+ group.append(summary, items);
1633
+ target.append(group);
1634
+ return group;
1635
+ }
1636
+ const eventTime = timestamp => {
1637
+ const value = Number(timestamp);
1638
+ return Number.isFinite(value) && value > 0
1639
+ ? new Date(value > 1e12 ? value : value * 1000).toLocaleTimeString()
1640
+ : '';
1641
+ };
1642
+ function updateActivityGroup(group, title, timestamp) {
1643
+ const count = group.querySelector('.activity-items').childElementCount;
1644
+ group.querySelector('.summary-text').textContent = title || '活动';
1645
+ group.querySelector('.activity-count').textContent = `${count} 项`;
1646
+ const time = eventTime(timestamp);
1647
+ if (time) group.querySelector('.event-time').textContent = time;
1648
+ }
1649
+ /** A transcript card that is not yet in the page; see addCard and placeCard. */
1650
+ function createCard(title, content, kind = 'system', open = false, timestamp) {
1651
+ const derivedTitle = !title,
1652
+ details = document.createElement('details');
1653
+ details.className = `msg ${kind}${derivedTitle ? ' derived-title' : ''}`;
1654
+ details.open = open;
1655
+ const summary = document.createElement('summary'),
1656
+ summaryText = document.createElement('span'),
1657
+ summaryLabel = document.createElement('span');
1658
+ summaryText.className = 'summary-text';
1659
+ summaryText.textContent = title || String(content).trim() || kind;
1660
+ summaryLabel.className = 'summary-label';
1661
+ summaryLabel.textContent = kind === 'user' ? 'You' : kind === 'assistant' ? 'Assistant' : kind;
1662
+ summary.append(summaryText, summaryLabel);
1663
+ const time = document.createElement('span');
1664
+ time.className = 'event-time';
1665
+ time.textContent = eventTime(timestamp);
1666
+ summary.append(time);
1667
+ const body = document.createElement('div');
1668
+ body.className = 'msg-content';
1669
+ renderCardBody(body, content, kind === 'assistant');
1670
+ details.append(summary, body);
1671
+ return {details, summary, summaryText, summaryTime: time, body, kind, content: String(content ?? '')};
1672
+ }
1673
+ /** Append a card to the transcript, folding non-transcript kinds into the trailing activity group. */
1674
+ function placeCard(card, target = $('messages'), timestamp) {
1675
+ const group = TRANSCRIPT_KINDS.has(card.kind) ? null : activityGroup(target);
1676
+ (group ? group.querySelector('.activity-items') : target).append(card.details);
1677
+ card.group = group;
1678
+ if (group) updateActivityGroup(group, card.summaryText.textContent, timestamp);
1679
+ return card;
1680
+ }
1681
+ function addCard(title, content, kind = 'system', open = false, timestamp, target = $('messages')) {
1682
+ const card = placeCard(createCard(title, content, kind, open, timestamp), target, timestamp);
1683
+ if (target === $('messages')) $('messages').scrollTop = $('messages').scrollHeight;
1684
+ return card;
1685
+ }
1686
+ /** Pi 0.99+ reports the recorded execution time of a finished tool call. */
1687
+ function setToolDuration(card, durationMs) {
1688
+ const value = Number(durationMs);
1689
+ if (!Number.isFinite(value) || value < 0) return;
1690
+ const label =
1691
+ card.durationLabel ||
1692
+ (card.durationLabel = Object.assign(document.createElement('span'), {className: 'tool-duration'}));
1693
+ label.textContent =
1694
+ value < 1000
1695
+ ? `${Math.round(value)} ms`
1696
+ : value < 60000
1697
+ ? `${(value / 1000).toFixed(1)} s`
1698
+ : `${Math.floor(value / 60000)} 分 ${Math.round((value % 60000) / 1000)} 秒`;
1699
+ card.summaryTime.before(label);
1700
+ }
1701
+ /** Live output of a `!` command; once recorded it reappears as a timeline item. */
1702
+ function updateBashCard(ev, timestamp) {
1703
+ const key = ev.id || 'default';
1704
+ let card = state.bashCards.get(key);
1705
+ if (!card) {
1706
+ card = addCard(ev.command ? `$ ${ev.command}` : '$ bash', '', 'tool', false, timestamp);
1707
+ card.content = '';
1708
+ state.bashCards.set(key, card);
1709
+ }
1710
+ if (ev.type === 'bash_execution_update') card.content += String(ev.delta || '');
1711
+ else if (ev.type === 'bash_execution_end') {
1712
+ state.bashCards.delete(key);
1713
+ if (!ev.isError) {
1714
+ removeCard(card);
1715
+ return;
1716
+ }
1717
+ // A run that failed to start was never recorded; keep it visible.
1718
+ card.details.classList.add('tool-error');
1719
+ card.summaryText.textContent = `✗ $ ${ev.command || 'bash'} · ${ev.errorMessage || '失败'}`;
1720
+ }
1721
+ renderCardBody(card.body, card.content);
1722
+ card.summaryTime.textContent = eventTime(timestamp);
1723
+ if (card.group) updateActivityGroup(card.group, card.summaryText.textContent, timestamp);
1724
+ }
1725
+ function removeCard(card) {
1726
+ card.details.remove();
1727
+ const group = card.group;
1728
+ if (!group) return;
1729
+ if (group.querySelector('.activity-items').childElementCount) refreshActivityGroup(group);
1730
+ else group.remove();
1731
+ }
1732
+ /** A group's summary shows its newest card, which may just have been replaced or removed. */
1733
+ function refreshActivityGroup(group) {
1734
+ const last = group.querySelector('.activity-items').lastElementChild;
1735
+ updateActivityGroup(group, last?.querySelector('.summary-text')?.textContent);
1736
+ }
1737
+
1738
+ // ---- Timeline of the viewed Agent. Items never change. Focusing the Agent makes the server send
1739
+ // a snapshot (focus_state), then append items in order on the same socket; anything that is not an
1740
+ // append makes it send timeline_reset, and the view takes a new snapshot. A tool's call and result
1741
+ // are two items shown as one card, and whether a call is running comes from the Agent's activity.
1742
+ // Large bodies load when a card is expanded; they never change, so they are cached for good.
1743
+ const TIMELINE_PAGE = 50;
1744
+ const CARD_KIND = {
1745
+ user: 'user',
1746
+ assistant: 'assistant',
1747
+ thinking: 'thinking',
1748
+ error: 'error',
1749
+ bash: 'tool'
1750
+ };
1751
+ const formatSize = chars => (chars < 1024 ? `${chars} 字符` : `${(chars / 1024).toFixed(1)}K 字符`);
1752
+ const RESULT_MARK = {ok: '✓', error: '✗', cancelled: '⊘'};
1753
+ function itemTitle(item) {
1754
+ const detail = item.summary ? ` · ${item.summary}` : '';
1755
+ switch (item.kind) {
1756
+ case 'user':
1757
+ case 'assistant':
1758
+ return null;
1759
+ case 'bash':
1760
+ return `${RESULT_MARK[item.status] || ''} $ ${item.title}${detail}`;
1761
+ case 'tool_result': // Its call is not on this page.
1762
+ return `${RESULT_MARK[item.status] || ''} ${item.title}`;
1763
+ case 'thinking':
1764
+ return item.body === undefined ? `Thinking${detail}` : 'Thinking';
1765
+ case 'error':
1766
+ return 'LLM Error';
1767
+ default:
1768
+ return `${item.title || item.kind}${detail}`;
1769
+ }
1770
+ }
1771
+ function itemCard(item) {
1772
+ const lazy = item.body === undefined,
1773
+ card = createCard(
1774
+ itemTitle(item),
1775
+ lazy ? `展开以加载(${formatSize(item.size)})` : item.body,
1776
+ CARD_KIND[item.kind] || (item.kind === 'tool_result' ? 'tool' : 'system'),
1777
+ item.kind === 'error',
1778
+ item.timestamp
1779
+ );
1780
+ card.item = item;
1781
+ card.details.dataset.itemId = item.id;
1782
+ card.details.classList.toggle('tool-error', item.status === 'error');
1783
+ if (item.durationMs !== undefined) setToolDuration(card, item.durationMs);
1784
+ if (lazy) card.details.addEventListener('toggle', () => void expandItem(card));
1785
+ return card;
1786
+ }
1787
+ async function expandItem(card) {
1788
+ if (!card.details.open || card.loaded) return;
1789
+ card.loaded = true;
1790
+ try {
1791
+ renderCardBody(card.body, await loadItemBody(card.item), card.item.kind === 'assistant');
1792
+ } catch (error) {
1793
+ card.loaded = false;
1794
+ renderCardBody(card.body, `加载失败:${error.message}`);
1795
+ }
1796
+ }
1797
+ async function loadItemBody(item) {
1798
+ const timeline = state.timeline,
1799
+ key = `${timeline.backendId}|${timeline.sessionId}|${item.id}`;
1800
+ if (state.itemBodies.has(key)) return state.itemBodies.get(key);
1801
+ const data = await requestAgent(
1802
+ timeline.agentId,
1803
+ `/sessions/${encodeURIComponent(timeline.sessionId)}/items/${encodeURIComponent(item.id)}`
1804
+ );
1805
+ state.itemBodies.set(key, data.body);
1806
+ // The browser's HTTP cache keeps everything; this only makes recent expansions instant.
1807
+ if (state.itemBodies.size > 200) state.itemBodies.delete(state.itemBodies.keys().next().value);
1808
+ return data.body;
1809
+ }
1810
+
1811
+ // A tool card: its call, its result once recorded, and while it runs and is expanded, its output.
1812
+ const TOOL_MARK = {ok: '✓', error: '✗', running: '⏳', waiting: '⋯', unfinished: '⊘'};
1813
+ const TOOL_NOTE = {running: ' · 执行中', waiting: ' · 等待执行', unfinished: ' · 未完成'};
1814
+ const entryOf = itemId => itemId.slice(0, itemId.lastIndexOf('.'));
1815
+ /** ok/error once recorded; otherwise running, waiting (in the newest calls of a run going on) or unfinished. */
1816
+ function toolState(card) {
1817
+ if (card.result) return card.result.status;
1818
+ const timeline = state.timeline,
1819
+ activity = state.activities.get(timeline.agentId),
1820
+ phase = activity?.phase;
1821
+ if (phase?.kind === 'running_tools' && phase.tools.some(tool => tool.toolCallId === card.call.toolCallId))
1822
+ return 'running';
1823
+ return activity?.run && entryOf(card.call.id) === timeline.lastCallEntry ? 'waiting' : 'unfinished';
1824
+ }
1825
+ function toolCard(call) {
1826
+ const card = createCard(call.title, '', 'tool', false, call.timestamp);
1827
+ card.call = card.item = call;
1828
+ card.details.dataset.itemId = call.id;
1829
+ card.details.addEventListener('toggle', () => void expandTool(card));
1830
+ return card;
1831
+ }
1832
+ function updateToolCard(card) {
1833
+ const status = toolState(card),
1834
+ {call, result} = card;
1835
+ card.summaryText.textContent = `${TOOL_MARK[status]} ${call.title}${call.summary ? ` · ${call.summary}` : ''}${TOOL_NOTE[status] || ''}`;
1836
+ card.details.classList.toggle('tool-error', status === 'error');
1837
+ if (result?.durationMs !== undefined) setToolDuration(card, result.durationMs);
1838
+ if (card.group) refreshActivityGroup(card.group);
1839
+ if (card.details.open) void expandTool(card);
1840
+ }
1841
+ /** Refresh the calls still without a result, whose state follows the Agent's activity. */
1842
+ function refreshToolCards(agentId) {
1843
+ if (state.timeline?.agentId !== agentId) return;
1844
+ for (const card of state.timeline.toolCards.values()) if (!card.result) updateToolCard(card);
1845
+ }
1846
+ function renderToolBody(card) {
1847
+ const head = card.args ?? '加载中…',
1848
+ tail = card.result
1849
+ ? `— 结果 —\n${card.resultText ?? '加载中…'}`
1850
+ : card.output !== undefined
1851
+ ? `— 输出 —\n${card.output}`
1852
+ : '';
1853
+ renderCardBody(card.body, [head, tail].filter(Boolean).join('\n\n'));
1854
+ }
1855
+ /** Expanding loads the call's and result's bodies; expanding a running call also watches its output. */
1856
+ async function expandTool(card) {
1857
+ const watch = card.details.open && toolState(card) === 'running';
1858
+ if (watch !== !!card.watching) {
1859
+ card.watching = watch;
1860
+ sendFocused({type: watch ? 'watch' : 'unwatch', toolCallId: card.call.toolCallId});
299
1861
  }
300
- if(target===$('messages'))$('messages').scrollTop=$('messages').scrollHeight;
1862
+ if (!card.details.open) return;
1863
+ renderToolBody(card);
1864
+ try {
1865
+ if (card.args === undefined) card.args = card.call.body ?? (await loadItemBody(card.call));
1866
+ if (card.result && card.resultText === undefined)
1867
+ card.resultText = card.result.body ?? (await loadItemBody(card.result));
1868
+ } catch (error) {
1869
+ renderCardBody(card.body, `加载失败:${error.message}`);
1870
+ return;
1871
+ }
1872
+ renderToolBody(card);
1873
+ }
1874
+ function applyToolOutput(message) {
1875
+ const card = state.timeline.toolCards.get(message.toolCallId);
1876
+ if (!card || card.result) return;
1877
+ card.output = message.output;
1878
+ if (card.details.open) renderToolBody(card);
1879
+ }
1880
+
1881
+ /** Add items in order: a result joins its call's card, everything else gets a card of its own. */
1882
+ function placeItems(items, target) {
1883
+ const timeline = state.timeline;
1884
+ for (const item of items) {
1885
+ const call = item.kind === 'tool_result' ? timeline.toolCards.get(item.toolCallId) : undefined;
1886
+ if (call) {
1887
+ call.result = item;
1888
+ timeline.cards.set(item.id, call);
1889
+ updateToolCard(call);
1890
+ continue;
1891
+ }
1892
+ const card = item.kind === 'tool_call' ? toolCard(item) : itemCard(item);
1893
+ timeline.cards.set(item.id, card);
1894
+ placeCard(card, target, item.timestamp);
1895
+ if (item.kind === 'tool_call') {
1896
+ timeline.toolCards.set(item.toolCallId, card);
1897
+ updateToolCard(card);
1898
+ }
1899
+ }
1900
+ }
1901
+ function renderTimelinePage(page, prepend) {
1902
+ const timeline = state.timeline,
1903
+ box = $('messages'),
1904
+ fragment = document.createDocumentFragment();
1905
+ if (!prepend) {
1906
+ timeline.cards.clear();
1907
+ timeline.toolCards.clear();
1908
+ const calls = page.items.filter(item => item.kind === 'tool_call');
1909
+ timeline.lastCallEntry = calls.length ? entryOf(calls[calls.length - 1].id) : null;
1910
+ }
1911
+ placeItems(page.items, fragment);
1912
+ if (prepend) {
1913
+ box.querySelector('#messageHistoryControls')?.remove();
1914
+ box.prepend(fragment);
1915
+ timeline.items = [...page.items, ...timeline.items];
1916
+ } else {
1917
+ clearLiveCards();
1918
+ state.optimistic = [];
1919
+ box.replaceChildren(fragment);
1920
+ timeline.items = [...page.items];
1921
+ if (!page.items.length) box.innerHTML = '<div class="empty">尚无消息</div>';
1922
+ }
1923
+ timeline.start = page.start;
1924
+ updateMessageHistoryControl();
1925
+ }
1926
+ function saveTimelineCache() {
1927
+ const timeline = state.timeline;
1928
+ state.timelineCache.delete(timeline.agentId);
1929
+ state.timelineCache.set(timeline.agentId, {
1930
+ sessionId: timeline.sessionId,
1931
+ items: timeline.items,
1932
+ start: timeline.start
1933
+ });
1934
+ if (state.timelineCache.size > 20) state.timelineCache.delete(state.timelineCache.keys().next().value);
1935
+ }
1936
+ /**
1937
+ * Show an Agent: its cached timeline at once, then the snapshot the server answers `focus` with.
1938
+ * Until that arrives, detail messages are older than it and are ignored.
1939
+ */
1940
+ function loadTimeline(agent) {
1941
+ const timeline = {
1942
+ agentId: agent.agentId,
1943
+ backendId: agent.backendId,
1944
+ sessionId: null,
1945
+ cards: new Map(),
1946
+ toolCards: new Map(),
1947
+ lastCallEntry: null,
1948
+ items: [],
1949
+ start: 0,
1950
+ ready: false
1951
+ };
1952
+ state.timeline = timeline;
1953
+ discardStreams();
1954
+ const box = $('messages'),
1955
+ cached = state.timelineCache.get(agent.agentId);
1956
+ if (cached) {
1957
+ timeline.sessionId = cached.sessionId;
1958
+ renderTimelinePage(cached, false);
1959
+ box.scrollTop = box.scrollHeight;
1960
+ } else box.innerHTML = '<div class="empty">正在加载…</div>';
1961
+ if (!sendFocus(true) && !cached) box.innerHTML = '<div class="empty">等待连接 backend…</div>';
1962
+ }
1963
+ /** Ask for a new snapshot, e.g. when the branch changed. */
1964
+ function refocus() {
1965
+ if (!state.timeline) return;
1966
+ state.timeline.ready = false;
1967
+ sendFocus(true);
1968
+ }
1969
+ function showSnapshot(message) {
1970
+ const timeline = state.timeline,
1971
+ box = $('messages');
1972
+ timeline.sessionId = message.sessionId;
1973
+ timeline.snapshots = (timeline.snapshots || 0) + 1;
1974
+ renderTimelinePage(message.page, false);
1975
+ timeline.ready = true;
1976
+ applyLive(message.live);
1977
+ restoreExtensionRequests(message.agentId, {dialogRequests: message.dialogRequests || []});
1978
+ saveTimelineCache();
1979
+ box.scrollTop = box.scrollHeight;
1980
+ }
1981
+ async function loadOlderItems(limit = TIMELINE_PAGE) {
1982
+ const timeline = state.timeline;
1983
+ if (!timeline?.ready || !timeline.items.length) return;
1984
+ const box = $('messages'),
1985
+ snapshot = timeline.snapshots;
1986
+ const page = await requestAgent(
1987
+ timeline.agentId,
1988
+ `/timeline?limit=${limit}&before=${encodeURIComponent(timeline.items[0].id)}`
1989
+ );
1990
+ // An older page belongs to the snapshot it was asked for.
1991
+ if (state.timeline !== timeline || timeline.snapshots !== snapshot) return;
1992
+ const oldHeight = box.scrollHeight,
1993
+ oldTop = box.scrollTop;
1994
+ if (page.sessionId !== timeline.sessionId) return refocus();
1995
+ renderTimelinePage(page, true);
1996
+ box.scrollTop = oldTop + (box.scrollHeight - oldHeight);
1997
+ saveTimelineCache();
301
1998
  }
302
1999
  function updateMessageHistoryControl() {
303
- $('messageHistoryControls')?.remove();if(state.messagePageStart<=0)return;
304
- const controls=document.createElement('div');controls.id='messageHistoryControls';controls.className='message-history-controls';
305
- const older=document.createElement('button');older.id='loadOlderMessages';older.type='button';older.textContent=`加载更早的对话(还有 ${state.messagePageStart} 条)`;older.onclick=()=>state.agent&&loadMessagePage(state.agent.agentId,state.messagePageStart,false).catch(error=>toast(error.message));
306
- const all=document.createElement('button');all.id='loadAllMessages';all.type='button';all.textContent='加载全部对话';all.onclick=()=>state.agent&&loadAllMessages(state.agent.agentId,all).catch(error=>toast(error.message));controls.append(older,all);$('messages').prepend(controls);
307
- }
308
- async function loadAllMessages(agentId,button) {
309
- button.disabled=true;button.textContent='正在加载全部对话…';
310
- try { const messages=await api(`/api/v1/agents/${agentId}/messages`);if(state.agent?.agentId!==agentId)return;const box=$('messages');box.replaceChildren();renderMessages(messages);state.messagePageStart=0;state.messageTotal=messages.length;updateMessageHistoryControl(); }
311
- finally { button.disabled=false;button.textContent='加载全部对话'; }
312
- }
313
- async function loadMessagePage(agentId,before,reset) {
314
- const query=new URLSearchParams({limit:String(state.messagePageSize)});if(before!==undefined)query.set('before',String(before));
315
- const page=await api(`/api/v1/agents/${agentId}/messages?${query}`);if(state.agent?.agentId!==agentId)return;
316
- const box=$('messages'),oldHeight=box.scrollHeight,oldTop=box.scrollTop,fragment=document.createDocumentFragment();renderMessages(page.items,fragment,false);
317
- if(reset){box.replaceChildren(fragment);state.messageTotal=page.total;}else{box.querySelector('#messageHistoryControls')?.remove();box.prepend(fragment);}
318
- state.messagePageStart=page.start;state.messageTotal=page.total;updateMessageHistoryControl();
319
- if(reset)box.scrollTop=box.scrollHeight;else box.scrollTop=oldTop+(box.scrollHeight-oldHeight);
320
- if(!page.total)box.innerHTML='<div class="empty">尚无消息</div>';
2000
+ $('messageHistoryControls')?.remove();
2001
+ const timeline = state.timeline;
2002
+ if (!timeline || timeline.start <= 0) return;
2003
+ const controls = document.createElement('div');
2004
+ controls.id = 'messageHistoryControls';
2005
+ controls.className = 'message-history-controls';
2006
+ const older = document.createElement('button');
2007
+ older.id = 'loadOlderMessages';
2008
+ older.type = 'button';
2009
+ older.textContent = `加载更早的对话(还有 ${timeline.start} 项)`;
2010
+ older.onclick = () => loadOlderItems().catch(error => toast(error.message));
2011
+ const all = document.createElement('button');
2012
+ all.id = 'loadAllMessages';
2013
+ all.type = 'button';
2014
+ all.textContent = '加载全部对话';
2015
+ all.onclick = async () => {
2016
+ all.disabled = older.disabled = true;
2017
+ all.textContent = '正在加载全部对话…';
2018
+ try {
2019
+ while (state.timeline === timeline && timeline.start > 0) await loadOlderItems(500);
2020
+ } catch (error) {
2021
+ toast(error.message);
2022
+ } finally {
2023
+ all.disabled = older.disabled = false;
2024
+ all.textContent = '加载全部对话';
2025
+ }
2026
+ };
2027
+ controls.append(older, all);
2028
+ $('messages').prepend(controls);
2029
+ }
2030
+ function applyItems(message) {
2031
+ const timeline = state.timeline,
2032
+ box = $('messages');
2033
+ if (message.clearLive) clearLiveCards();
2034
+ if (!message.items.length) return;
2035
+ box.querySelector('.empty')?.remove();
2036
+ for (const card of state.optimistic.splice(0)) removeCard(card);
2037
+ const calls = message.items.filter(item => item.kind === 'tool_call');
2038
+ if (calls.length) timeline.lastCallEntry = entryOf(calls[calls.length - 1].id);
2039
+ placeItems(message.items, box);
2040
+ timeline.items.push(...message.items);
2041
+ // Older calls without a result are no longer the newest: they did not finish.
2042
+ if (calls.length) refreshToolCards(timeline.agentId);
2043
+ saveTimelineCache();
2044
+ box.scrollTop = box.scrollHeight;
2045
+ }
2046
+ /** Messages of the focused Agent's detail stream, and its events that add to the transcript. */
2047
+ function handleDetail(message) {
2048
+ const timeline = state.timeline;
2049
+ if (!timeline || timeline.agentId !== message.agentId || state.selectedKind !== 'agent') return;
2050
+ if (message.type === 'focus_state') return showSnapshot(message);
2051
+ if (message.type === 'focus_error') {
2052
+ addCard('加载消息失败', message.error?.message || '', 'error', true);
2053
+ return;
2054
+ }
2055
+ if (!timeline.ready) return; // Older than the snapshot on its way, which includes it.
2056
+ if (message.type === 'agent_event') renderTranscriptEvent(message);
2057
+ else if (message.type === 'delta') appendDelta(message.kind, message.delta);
2058
+ else if (message.type === 'items') applyItems(message);
2059
+ else if (message.type === 'timeline_reset') refocus();
2060
+ else if (message.type === 'tool_output') applyToolOutput(message);
2061
+ }
2062
+
2063
+ // ---- Streamed text of the message being generated. Its cards stay until the server
2064
+ // reports the message recorded (`items` with clearLive) and its timeline items replace them.
2065
+ function applyLive(live) {
2066
+ clearLiveCards();
2067
+ if (live?.thinking) appendDelta('thinking', live.thinking);
2068
+ if (live?.text) appendDelta('text', live.text);
2069
+ }
2070
+ function appendDelta(kind, delta) {
2071
+ const stream =
2072
+ kind === 'text'
2073
+ ? ensureStream('assistant', null, 'assistant')
2074
+ : ensureStream('thinking', 'Thinking', 'thinking');
2075
+ stream.content += delta || '';
2076
+ scheduleStreamPaint(stream);
321
2077
  }
322
2078
  function ensureStream(key, title, kind) {
323
- if (!state.streams.has(key)) state.streams.set(key, addCard(title, '', kind, true));
2079
+ if (!state.streams.has(key)) {
2080
+ $('messages').querySelector('.empty')?.remove();
2081
+ const card = addCard(title, '', kind, true);
2082
+ card.renderFrame = null;
2083
+ state.streams.set(key, card);
2084
+ state.liveCards.push(card);
2085
+ }
324
2086
  return state.streams.get(key);
325
2087
  }
2088
+ // Parsing and sanitizing the complete accumulated Markdown for every tiny
2089
+ // provider delta is O(n²) and can leave the browser minutes behind the agent.
2090
+ // Paint plain text at most once per animation frame and parse Markdown once
2091
+ // when the message finishes.
2092
+ function paintStream(card) {
2093
+ card.renderFrame = null;
2094
+ card.body.textContent = card.content;
2095
+ card.summaryText.textContent = (card.content.trim().split('\n')[0] || card.kind || 'Assistant').slice(
2096
+ 0,
2097
+ 160
2098
+ );
2099
+ $('messages').scrollTop = $('messages').scrollHeight;
2100
+ }
2101
+ function scheduleStreamPaint(card) {
2102
+ if (card.renderFrame === null) card.renderFrame = requestAnimationFrame(() => paintStream(card));
2103
+ }
2104
+ /** The run ended: render the streamed Markdown once while waiting for the recorded items. */
2105
+ function finalizeAgentStreams() {
2106
+ for (const [key, card] of state.streams) {
2107
+ if (card.renderFrame !== null) cancelAnimationFrame(card.renderFrame);
2108
+ card.renderFrame = null;
2109
+ renderCardBody(card.body, card.content, key === 'assistant');
2110
+ card.summaryText.textContent = (card.content.trim().split('\n')[0] || key).slice(0, 160);
2111
+ }
2112
+ state.streams.clear();
2113
+ }
2114
+ function stopStreams() {
2115
+ for (const card of state.streams.values())
2116
+ if (card.renderFrame !== null && card.renderFrame !== undefined) cancelAnimationFrame(card.renderFrame);
2117
+ state.streams.clear();
2118
+ }
2119
+ function clearLiveCards() {
2120
+ stopStreams();
2121
+ for (const card of state.liveCards.splice(0)) card.details.remove();
2122
+ }
2123
+ /** Forget all live state; the caller replaces the transcript. */
2124
+ function discardStreams() {
2125
+ stopStreams();
2126
+ state.liveCards = [];
2127
+ state.bashCards.clear();
2128
+ }
326
2129
  function extensionRequest(agentId, ev, timestamp) {
327
- const card = addCard(ev.title || `Extension ${ev.kind}`, ev.message || ev.placeholder || ev.prefill || '', 'system', true, timestamp);
328
- const controls = document.createElement('div'); controls.className = 'dialog-actions';
329
- const send = async value => { try { await post(`/api/v1/agents/${agentId}/extension-response`, {requestId:ev.requestId, value}); controls.replaceChildren(document.createTextNode('已响应')); } catch (e) { toast(e.message); } };
330
- if (ev.kind === 'select') for (const option of ev.options || []) { const b = document.createElement('button'); b.textContent = option; b.onclick = () => send(option); controls.append(b); }
331
- else if (ev.kind === 'confirm') { for (const [label, value] of [['否',false],['是',true]]) { const b=document.createElement('button');b.textContent=label;b.onclick=()=>send(value);controls.append(b); } }
332
- else { const input = ev.kind === 'editor' ? document.createElement('textarea') : document.createElement('input'); input.value = ev.prefill || ''; input.placeholder = ev.placeholder || ''; const b = document.createElement('button'); b.textContent = '提交'; b.onclick = () => send(input.value); controls.append(input,b); }
2130
+ if ($(`extension-request-${ev.requestId}`)) return;
2131
+ $('messages').querySelector('.empty')?.remove();
2132
+ const card = addCard(
2133
+ ev.title || `Extension ${ev.kind}`,
2134
+ ev.message || ev.placeholder || ev.prefill || '',
2135
+ 'dialog',
2136
+ true,
2137
+ timestamp
2138
+ );
2139
+ const controls = document.createElement('div');
2140
+ controls.className = 'dialog-actions';
2141
+ controls.id = `extension-request-${ev.requestId}`;
2142
+ const send = async value => {
2143
+ try {
2144
+ await postAgent(agentId, '/extension-response', {requestId: ev.requestId, value});
2145
+ controls.replaceChildren(document.createTextNode('已响应'));
2146
+ } catch (e) {
2147
+ toast(e.message);
2148
+ }
2149
+ };
2150
+ if (ev.kind === 'select')
2151
+ for (const option of ev.options || []) {
2152
+ const b = document.createElement('button');
2153
+ b.textContent = option;
2154
+ b.onclick = () => send(option);
2155
+ controls.append(b);
2156
+ }
2157
+ else if (ev.kind === 'confirm') {
2158
+ for (const [label, value] of [
2159
+ ['否', false],
2160
+ ['是', true]
2161
+ ]) {
2162
+ const b = document.createElement('button');
2163
+ b.textContent = label;
2164
+ b.onclick = () => send(value);
2165
+ controls.append(b);
2166
+ }
2167
+ } else {
2168
+ const input =
2169
+ ev.kind === 'editor' ? document.createElement('textarea') : document.createElement('input');
2170
+ input.value = ev.prefill || '';
2171
+ input.placeholder = ev.placeholder || '';
2172
+ const b = document.createElement('button');
2173
+ b.textContent = '提交';
2174
+ b.onclick = () => send(input.value);
2175
+ controls.append(input, b);
2176
+ }
333
2177
  card.body.append(controls);
334
2178
  }
2179
+ /** Steering and follow-up messages Pi has queued for the running turn. */
2180
+ function setAgentQueue(agentId, queue) {
2181
+ const value = {steering: queue?.steering || [], followUp: queue?.followUp || []};
2182
+ if (value.steering.length || value.followUp.length) state.queues.set(agentId, value);
2183
+ else state.queues.delete(agentId);
2184
+ if (state.selectedKind === 'agent' && state.agent?.agentId === agentId) renderQueueBar();
2185
+ }
2186
+ function renderQueueBar() {
2187
+ const queue = state.agent ? state.queues.get(state.agent.agentId) : null,
2188
+ bar = $('queueBar');
2189
+ if (!queue) {
2190
+ bar.hidden = true;
2191
+ return;
2192
+ }
2193
+ const parts = [];
2194
+ if (queue.steering.length) parts.push(`Steer ${queue.steering.length}`);
2195
+ if (queue.followUp.length) parts.push(`Follow-up ${queue.followUp.length}`);
2196
+ const preview = [...queue.steering, ...queue.followUp][0] || '';
2197
+ $('queueText').textContent =
2198
+ `排队中:${parts.join(' · ')}${preview ? ` — ${preview.replace(/\s+/g, ' ').slice(0, 60)}` : ''}`;
2199
+ bar.hidden = false;
2200
+ }
2201
+ function restoreExtensionRequests(agentId, activity) {
2202
+ if (state.selectedKind !== 'agent' || state.agent?.agentId !== agentId || !activity?.dialogRequests)
2203
+ return;
2204
+ const requests = activity.dialogRequests,
2205
+ ids = new Set(requests.map(request => `extension-request-${request.requestId}`));
2206
+ for (const controls of $('messages').querySelectorAll('.dialog-actions')) {
2207
+ if (!ids.has(controls.id)) controls.replaceChildren(document.createTextNode('已结束'));
2208
+ }
2209
+ for (const request of requests) extensionRequest(agentId, request);
2210
+ }
335
2211
  function stopVoiceAudio() {
336
- state.voiceAudio.generation++;for(const source of state.voiceAudio.sources){try{source.stop();}catch{}}state.voiceAudio.sources.clear();state.voiceAudio.nextTime=0;state.voiceAudio.playbackId=null;
2212
+ state.voiceAudio.generation++;
2213
+ for (const source of state.voiceAudio.sources) {
2214
+ try {
2215
+ source.stop();
2216
+ } catch {}
2217
+ }
2218
+ state.voiceAudio.sources.clear();
2219
+ state.voiceAudio.nextTime = 0;
2220
+ state.voiceAudio.playbackId = null;
2221
+ state.voiceAudio.backendId = null;
337
2222
  }
338
2223
  async function ensureAudioContext() {
339
- const AudioContext=window.AudioContext||window.webkitAudioContext;if(!AudioContext)throw Error('当前浏览器不支持音频播放');
340
- state.voiceAudio.context??=new AudioContext();if(state.voiceAudio.context.state==='suspended')await state.voiceAudio.context.resume();return state.voiceAudio.context;
341
- }
342
- function playVoiceChunk(event) {
343
- if(!state.voicePlaybackEnabled||!event.audio)return;const generation=state.voiceAudio.generation;
344
- state.voiceAudio.decodeChain=state.voiceAudio.decodeChain.then(async()=>{
345
- const context=await ensureAudioContext();if(generation!==state.voiceAudio.generation)return;state.voiceAudio.playbackId=event.playbackId;
346
- const raw=atob(event.audio),bytes=Uint8Array.from(raw,c=>c.charCodeAt(0));let buffer;
347
- if(event.encoding==='mp3')buffer=await context.decodeAudioData(bytes.buffer.slice(0));
348
- else{const samples=new Float32Array(Math.floor(bytes.length/2));for(let i=0;i<samples.length;i++){let value=bytes[i*2]|(bytes[i*2+1]<<8);if(value&0x8000)value-=0x10000;samples[i]=value/32768;}const rate=Number(event.sampleRate||24000);buffer=context.createBuffer(1,samples.length,rate);buffer.copyToChannel(samples,0);}
349
- if(generation!==state.voiceAudio.generation||state.voiceAudio.playbackId!==event.playbackId)return;const source=context.createBufferSource();source.buffer=buffer;source.connect(context.destination);const start=Math.max(context.currentTime+0.03,state.voiceAudio.nextTime||0);source.start(start);state.voiceAudio.nextTime=start+buffer.duration;state.voiceAudio.sources.add(source);source.onended=()=>state.voiceAudio.sources.delete(source);
350
- }).catch(error=>toast(`语音播放失败:${error.message}`));
2224
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
2225
+ if (!AudioContext) throw Error('当前浏览器不支持音频播放');
2226
+ state.voiceAudio.context ??= new AudioContext();
2227
+ if (state.voiceAudio.context.state === 'suspended') await state.voiceAudio.context.resume();
2228
+ return state.voiceAudio.context;
2229
+ }
2230
+ function playVoiceChunk(event, backendId) {
2231
+ if (!state.voicePlaybackEnabled || !event.audio) return;
2232
+ const generation = state.voiceAudio.generation;
2233
+ state.voiceAudio.decodeChain = state.voiceAudio.decodeChain
2234
+ .then(async () => {
2235
+ const context = await ensureAudioContext();
2236
+ if (generation !== state.voiceAudio.generation) return;
2237
+ state.voiceAudio.playbackId = event.playbackId;
2238
+ state.voiceAudio.backendId = backendId;
2239
+ const raw = atob(event.audio),
2240
+ bytes = Uint8Array.from(raw, c => c.charCodeAt(0));
2241
+ let buffer;
2242
+ if (event.encoding === 'mp3') buffer = await context.decodeAudioData(bytes.buffer.slice(0));
2243
+ else {
2244
+ const samples = new Float32Array(Math.floor(bytes.length / 2));
2245
+ for (let i = 0; i < samples.length; i++) {
2246
+ let value = bytes[i * 2] | (bytes[i * 2 + 1] << 8);
2247
+ if (value & 0x8000) value -= 0x10000;
2248
+ samples[i] = value / 32768;
2249
+ }
2250
+ const rate = Number(event.sampleRate || 24000);
2251
+ buffer = context.createBuffer(1, samples.length, rate);
2252
+ buffer.copyToChannel(samples, 0);
2253
+ }
2254
+ if (generation !== state.voiceAudio.generation || state.voiceAudio.playbackId !== event.playbackId)
2255
+ return;
2256
+ const source = context.createBufferSource();
2257
+ source.buffer = buffer;
2258
+ source.connect(context.destination);
2259
+ const start = Math.max(context.currentTime + 0.03, state.voiceAudio.nextTime || 0);
2260
+ source.start(start);
2261
+ state.voiceAudio.nextTime = start + buffer.duration;
2262
+ state.voiceAudio.sources.add(source);
2263
+ source.onended = () => state.voiceAudio.sources.delete(source);
2264
+ })
2265
+ .catch(error => toast(`语音播放失败:${error.message}`));
351
2266
  }
352
2267
  function handleVoiceEvent(message) {
353
- const ev=message.event||{},selected=state.selectedKind==='agent'&&state.agent?.agentId===message.agentId,key=`voice-${ev.playbackId||message.agentId}`;
354
- if(ev.type==='voice_start'){if(state.voicePlaybackEnabled)void ensureAudioContext().catch(()=>{});if(selected){const c=ensureStream(key,'语音摘要','system');c.content='';renderCardBody(c.body,'正在生成语音摘要…',false);}}
355
- else if(ev.type==='voice_summary_delta'){if(selected){const c=ensureStream(key,'语音摘要','system');c.content+=ev.text||'';renderCardBody(c.body,c.content,false);c.summaryText.textContent='语音摘要';}}
356
- else if(ev.type==='voice_audio_chunk')playVoiceChunk(ev);
357
- else if(ev.type==='voice_cancelled'){stopVoiceAudio();}
358
- else if(ev.type==='voice_error'){if(selected)addCard('语音服务',ev.message||'语音生成失败','error',true);}
359
- else if(ev.type==='voice_end'){if(selected)state.streams.delete(key);}
360
- }
361
- function updateVoiceButton(){const button=$('voicePlayback');button.textContent=state.voicePlaybackEnabled?'语音已启用':'启用语音';button.title=state.voicePlaybackEnabled?'点击关闭 Agent 语音摘要':'点击启用 Agent 语音摘要播放';}
362
- async function toggleVoicePlayback(){state.voicePlaybackEnabled=!state.voicePlaybackEnabled;localStorage.rpVoicePlayback=String(state.voicePlaybackEnabled);updateVoiceButton();if(state.voicePlaybackEnabled){try{await ensureAudioContext();toast('Agent 完成后将播放语音摘要');}catch(error){state.voicePlaybackEnabled=false;localStorage.rpVoicePlayback='false';updateVoiceButton();toast(error.message);}}else stopVoiceAudio();}
363
- async function toggleVoiceInput(){
364
- if(!state.voiceSttEnabled)return toast('服务端未启用语音识别');if(!state.agent)return toast('请先选择 Agent');
365
- if(state.mediaRecorder&&state.mediaRecorder.state==='recording'){state.mediaRecorder.stop();return;}
366
- if(!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder)return toast('当前浏览器不支持麦克风录音');
367
- try{
368
- const stream=await navigator.mediaDevices.getUserMedia({audio:true}),preferred=['audio/webm;codecs=opus','audio/webm','audio/mp4'].find(type=>MediaRecorder.isTypeSupported(type));state.mediaStream=stream;state.mediaChunks=[];state.mediaRecorder=new MediaRecorder(stream,preferred?{mimeType:preferred}:undefined);
369
- state.mediaRecorder.ondataavailable=event=>{if(event.data.size)state.mediaChunks.push(event.data)};
370
- state.mediaAgentId=state.agent.agentId;state.mediaRecorder.onstop=()=>void transcribeVoice();state.mediaRecorder.start(250);$('voiceInput').textContent='■';$('voiceInput').title='停止并识别';$('voiceInput').classList.add('recording');state.mediaTimer=setTimeout(()=>state.mediaRecorder?.state==='recording'&&state.mediaRecorder.stop(),60000);
371
- }catch(error){toast(`无法录音:${error.message}`);}
372
- }
373
- async function transcribeVoice(){
374
- clearTimeout(state.mediaTimer);const recorder=state.mediaRecorder,mime=recorder?.mimeType||'audio/webm',blob=new Blob(state.mediaChunks,{type:mime}),agentId=state.mediaAgentId;state.mediaStream?.getTracks().forEach(track=>track.stop());state.mediaRecorder=null;state.mediaStream=null;state.mediaChunks=[];state.mediaAgentId=null;$('voiceInput').textContent='…';$('voiceInput').classList.remove('recording');
375
- if(!agentId){$('voiceInput').textContent='🎙';return;}try{const response=await fetch(state.base+`/api/v1/agents/${agentId}/transcribe`,{method:'POST',headers:{Authorization:`Bearer ${state.token}`,'Content-Type':mime,'X-Audio-Filename':mime.includes('mp4')?'recording.m4a':'recording.webm'},body:blob});const body=await response.json().catch(()=>({}));if(!response.ok)throw Error(body.error?.message||`HTTP ${response.status}`);const text=body.data?.text?.trim();if(text){const input=$('input'),prefix=input.value&& !/\s$/.test(input.value)?' ':'';input.value+=prefix+text;input.focus();toast('语音已转换为文字');}else toast('没有识别到语音');}catch(error){toast(`语音识别失败:${error.message}`);}finally{$('voiceInput').textContent='🎙';$('voiceInput').title='开始语音输入';}
2268
+ const ev = message.event || {},
2269
+ selected = state.selectedKind === 'agent' && state.agent?.agentId === message.agentId,
2270
+ key = `voice-${resourceKey(message.backendId, ev.playbackId || message.agentId)}`;
2271
+ if (ev.type === 'voice_start') {
2272
+ if (state.voicePlaybackEnabled) void ensureAudioContext().catch(() => {});
2273
+ if (selected) {
2274
+ const c = ensureStream(key, '语音摘要', 'voice');
2275
+ c.content = '';
2276
+ renderCardBody(c.body, '正在生成语音摘要…', false);
2277
+ }
2278
+ } else if (ev.type === 'voice_summary_delta') {
2279
+ if (selected) {
2280
+ const c = ensureStream(key, '语音摘要', 'voice');
2281
+ c.content += ev.text || '';
2282
+ renderCardBody(c.body, c.content, false);
2283
+ c.summaryText.textContent = '语音摘要';
2284
+ }
2285
+ } else if (ev.type === 'voice_audio_chunk')
2286
+ playVoiceChunk({...ev, playbackId: resourceKey(message.backendId, ev.playbackId)}, message.backendId);
2287
+ else if (ev.type === 'voice_cancelled') {
2288
+ stopVoiceAudio();
2289
+ } else if (ev.type === 'voice_error') {
2290
+ if (selected) addCard('语音服务', ev.message || '语音生成失败', 'error', true);
2291
+ } else if (ev.type === 'voice_end') {
2292
+ if (selected) state.streams.delete(key);
2293
+ }
2294
+ }
2295
+ function updateVoiceButton() {
2296
+ const button = $('voicePlayback');
2297
+ button.textContent = state.voicePlaybackEnabled ? '语音已启用' : '启用语音';
2298
+ button.title = state.voicePlaybackEnabled ? '点击关闭 Agent 语音摘要' : '点击启用 Agent 语音摘要播放';
2299
+ }
2300
+ async function toggleVoicePlayback() {
2301
+ state.voicePlaybackEnabled = !state.voicePlaybackEnabled;
2302
+ localStorage.rpVoicePlayback = String(state.voicePlaybackEnabled);
2303
+ updateVoiceButton();
2304
+ if (state.voicePlaybackEnabled) {
2305
+ try {
2306
+ await ensureAudioContext();
2307
+ toast('Agent 完成后将播放语音摘要');
2308
+ } catch (error) {
2309
+ state.voicePlaybackEnabled = false;
2310
+ localStorage.rpVoicePlayback = 'false';
2311
+ updateVoiceButton();
2312
+ toast(error.message);
2313
+ }
2314
+ } else stopVoiceAudio();
2315
+ }
2316
+ async function toggleVoiceInput() {
2317
+ if (!state.voiceSttEnabled) return toast('服务端未启用语音识别');
2318
+ if (!state.agent) return toast('请先选择 Agent');
2319
+ if (state.mediaRecorder && state.mediaRecorder.state === 'recording') {
2320
+ state.mediaRecorder.stop();
2321
+ return;
2322
+ }
2323
+ if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder)
2324
+ return toast('当前浏览器不支持麦克风录音');
2325
+ try {
2326
+ const stream = await navigator.mediaDevices.getUserMedia({audio: true}),
2327
+ preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(type =>
2328
+ MediaRecorder.isTypeSupported(type)
2329
+ );
2330
+ state.mediaStream = stream;
2331
+ state.mediaChunks = [];
2332
+ state.mediaRecorder = new MediaRecorder(stream, preferred ? {mimeType: preferred} : undefined);
2333
+ state.mediaRecorder.ondataavailable = event => {
2334
+ if (event.data.size) state.mediaChunks.push(event.data);
2335
+ };
2336
+ state.mediaAgentId = state.agent.agentId;
2337
+ state.mediaRecorder.onstop = () => void transcribeVoice();
2338
+ state.mediaRecorder.start(250);
2339
+ $('voiceInput').textContent = '■';
2340
+ $('voiceInput').title = '停止并识别';
2341
+ $('voiceInput').classList.add('recording');
2342
+ state.mediaTimer = setTimeout(
2343
+ () => state.mediaRecorder?.state === 'recording' && state.mediaRecorder.stop(),
2344
+ 60000
2345
+ );
2346
+ } catch (error) {
2347
+ toast(`无法录音:${error.message}`);
2348
+ }
2349
+ }
2350
+ async function transcribeVoice() {
2351
+ clearTimeout(state.mediaTimer);
2352
+ const recorder = state.mediaRecorder,
2353
+ mime = recorder?.mimeType || 'audio/webm',
2354
+ blob = new Blob(state.mediaChunks, {type: mime}),
2355
+ agentId = state.mediaAgentId;
2356
+ state.mediaStream?.getTracks().forEach(track => track.stop());
2357
+ state.mediaRecorder = null;
2358
+ state.mediaStream = null;
2359
+ state.mediaChunks = [];
2360
+ state.mediaAgentId = null;
2361
+ $('voiceInput').textContent = '…';
2362
+ $('voiceInput').classList.remove('recording');
2363
+ if (!agentId) {
2364
+ $('voiceInput').textContent = '🎙';
2365
+ return;
2366
+ }
2367
+ const {backendId, remoteId} = splitResourceKey(agentId),
2368
+ transcribeBackend = backendOf(backendId);
2369
+ if (!transcribeBackend) {
2370
+ $('voiceInput').textContent = '🎙';
2371
+ return toast('该 Session 的 backend 已断开');
2372
+ }
2373
+ 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
+ );
2386
+ const body = await response.json().catch(() => ({}));
2387
+ if (!response.ok) throw Error(body.error?.message || `HTTP ${response.status}`);
2388
+ const text = body.data?.text?.trim();
2389
+ if (text) {
2390
+ const input = $('input'),
2391
+ prefix = input.value && !/\s$/.test(input.value) ? ' ' : '';
2392
+ input.value += prefix + text;
2393
+ input.focus();
2394
+ toast('语音已转换为文字');
2395
+ } else toast('没有识别到语音');
2396
+ } catch (error) {
2397
+ toast(`语音识别失败:${error.message}`);
2398
+ } finally {
2399
+ $('voiceInput').textContent = '🎙';
2400
+ $('voiceInput').title = '开始语音输入';
2401
+ }
2402
+ }
2403
+ const handledDrafts = new Set();
2404
+ function handleDraftEvent(message) {
2405
+ const draft = message.draft,
2406
+ backend = backendOf(message.backendId);
2407
+ if (!draft || typeof draft.text !== 'string' || !backend) return;
2408
+ const ack = () => {
2409
+ handledDrafts.add(draft.id);
2410
+ if (handledDrafts.size > 200) handledDrafts.delete(handledDrafts.values().next().value);
2411
+ if (backend.ws?.readyState === WebSocket.OPEN)
2412
+ backend.ws.send(JSON.stringify({type: 'draft_ack', id: draft.id}));
2413
+ };
2414
+ if (handledDrafts.has(draft.id)) {
2415
+ ack();
2416
+ return;
2417
+ }
2418
+ try {
2419
+ // The voice client cannot select a session: only the input currently shown on this backend is changed.
2420
+ if (state.selectedKind !== 'agent' || !state.agent || state.agent.backendId !== backend.id) return;
2421
+ const input = $('input');
2422
+ if (draft.mode === 'replace') input.value = draft.text;
2423
+ else input.value += (input.value && !/\s$/.test(input.value) ? ' ' : '') + draft.text;
2424
+ input.focus();
2425
+ input.setSelectionRange(input.value.length, input.value.length);
2426
+ input.scrollTop = input.scrollHeight;
2427
+ toast('语音输入已写入输入框');
2428
+ ack();
2429
+ } catch (error) {
2430
+ toast(`语音输入失败:${error.message}`);
2431
+ }
376
2432
  }
377
2433
  function handleAgentEvent(message) {
378
- if(message.type==='voice_event'){handleVoiceEvent(message);return;}
379
- if (message.type === 'subscribed') {
380
- const key = `rpSeq:${message.agentId}`, saved = Number(localStorage[key] || 0), current = Number(message.currentSequence || 0);
381
- // Agent event sequences restart when the server restores an Agent. A cursor
382
- // from the previous process must not cause every new event to be discarded.
383
- if (saved > current) localStorage[key] = current;
2434
+ if (message.type === 'draft_event') {
2435
+ handleDraftEvent(message);
2436
+ return;
2437
+ }
2438
+ if (message.type === 'subscribed_drafts') return;
2439
+ if (message.type === 'voice_event') {
2440
+ handleVoiceEvent(message);
384
2441
  return;
385
2442
  }
386
- if (message.type === 'agent_snapshot') {
387
- localStorage[`rpSeq:${message.agentId}`] = message.lastSequence; if (state.agent?.agentId === message.agentId) { $('messages').replaceChildren(); renderMessages(message.messages);state.messagePageStart=message.messagePage?.start||0;state.messageTotal=message.messagePage?.total??message.messages?.length??0;updateMessageHistoryControl(); } return;
2443
+ if (message.type === 'subscribed_all') {
2444
+ const backend = backendOf(message.backendId);
2445
+ if (backend && Number.isFinite(message.serverTime))
2446
+ backend.clockOffset = message.serverTime - Date.now();
2447
+ return;
2448
+ }
2449
+ if (message.type === 'agent_state') {
2450
+ sessionKeyboard.updateAgent({...message.state, agentId: message.agentId});
2451
+ setAgentStatus(message.agentId, message.state.status);
2452
+ setAgentQueue(message.agentId, message.state.activity?.queue);
2453
+ setAgentActivity(message.agentId, message.state.activity);
2454
+ restoreExtensionRequests(message.agentId, message.state.activity);
2455
+ return;
2456
+ }
2457
+ // The focused Agent's detail stream; see handleDetail.
2458
+ if (
2459
+ ['focus_state', 'focus_error', 'delta', 'items', 'timeline_reset', 'tool_output'].includes(message.type)
2460
+ ) {
2461
+ handleDetail(message);
2462
+ return;
388
2463
  }
389
2464
  if (message.type !== 'agent_event') return;
390
- const key = `rpSeq:${message.agentId}`, last = Number(localStorage[key] || 0); if (message.sequence <= last) return; localStorage[key] = message.sequence;
391
- const ev = message.event || {}, selected = state.selectedKind==='agent'&&state.agent?.agentId === message.agentId;
392
- let eventAgent=state.agents.find(x=>x.agentId===message.agentId);
393
- if(eventAgent&&Number(message.timestamp)>0)eventAgent.lastActiveAt=new Date(Number(message.timestamp)*1000).toISOString();
394
- if (ev.type === 'agent_start' || ev.type === 'auto_retry_start') eventAgent=setAgentStatus(message.agentId,'streaming')||eventAgent;
395
- else if (ev.type === 'agent_end') {
396
- const final=!ev.willRetry;eventAgent=setAgentStatus(message.agentId,final?'idle':'streaming')||eventAgent;
397
- if(final){if(selected)state.streams.clear();notifyComplete(eventAgent||{agentId:message.agentId},message.timestamp);if(eventAgent)void loadAgentContexts([eventAgent]);}
398
- } else if (ev.type === 'agent_settled') { eventAgent=setAgentStatus(message.agentId,'idle')||eventAgent;if(eventAgent)void loadAgentContexts([eventAgent]); }
399
- else if (ev.type === 'session_info_changed') { if(eventAgent)eventAgent.sessionName=ev.name||undefined;if(selected)state.agent.sessionName=ev.name||undefined;renderAgentList();if(selected)updateAgentHeader(); }
400
- if (!selected) return;
401
- if (ev.type === 'agent_start') { $('messages').querySelector('.empty')?.remove(); }
402
- else if (ev.type === 'message_update') {
403
- const update = ev.assistantMessageEvent || {};
404
- if (update.type === 'text_delta') { const c=ensureStream('assistant',null,'assistant');c.content+=update.delta||'';renderCardBody(c.body,c.content,true);c.summaryText.textContent=c.content||'Assistant'; }
405
- else if (update.type === 'thinking_delta') {const c=ensureStream('thinking','Thinking','thinking');c.content+=update.delta||'';c.body.textContent=c.content;}
406
- } else if (ev.type === 'tool_execution_start') addCard(`🔧 ${ev.toolName || 'Tool'}`, JSON.stringify(ev.args || {}, null, 2), 'tool', false, message.timestamp);
407
- else if (ev.type === 'tool_execution_end') addCard(`${ev.isError ? '✗' : '✓'} ${ev.toolName || 'Tool'}`, textContent(ev.result) || (ev.isError ? '执行失败' : '执行完成'), ev.isError ? 'error' : 'tool', false, message.timestamp);
408
- else if (ev.type === 'auto_retry_start' || ev.type === 'auto_retry_end') addCard('Retry', ev.errorMessage || ev.type, 'system', false, message.timestamp);
2465
+ const ev = message.event || {},
2466
+ selected = state.selectedKind === 'agent' && state.agent?.agentId === message.agentId;
2467
+ let eventAgent = state.agents.find(x => x.agentId === message.agentId);
2468
+ // Viewing an Agent loads its backend; that is not activity and must not reorder the list.
2469
+ if (
2470
+ eventAgent &&
2471
+ Number(message.timestamp) > 0 &&
2472
+ ev.type !== 'agent_loaded' &&
2473
+ ev.type !== 'agent_unloaded'
2474
+ )
2475
+ eventAgent.lastActiveAt = new Date(Number(message.timestamp)).toISOString();
2476
+ // Every event carries the authoritative status, including dialog waits.
2477
+ if (message.status) eventAgent = setAgentStatus(message.agentId, message.status) || eventAgent;
2478
+ if (ev.type === 'queue_update') {
2479
+ setAgentQueue(message.agentId, ev);
2480
+ return;
2481
+ }
2482
+ if (ev.type === 'activity_changed') {
2483
+ setAgentActivity(message.agentId, ev.activity);
2484
+ return;
2485
+ }
2486
+ if (ev.type === 'agent_unloaded') {
2487
+ setAgentStatus(message.agentId, 'unloaded');
2488
+ setAgentQueue(message.agentId, null);
2489
+ setAgentActivity(message.agentId, null);
2490
+ restoreExtensionRequests(message.agentId, {dialogRequests: []});
2491
+ return;
2492
+ }
2493
+ if (ev.type === 'agent_end') {
2494
+ if (!ev.willRetry) {
2495
+ if (selected) finalizeAgentStreams();
2496
+ notifyComplete(
2497
+ eventAgent || {agentId: message.agentId},
2498
+ Math.floor(Number(message.timestamp) / 1000)
2499
+ );
2500
+ if (eventAgent) void loadAgentContexts([eventAgent]);
2501
+ }
2502
+ } else if (ev.type === 'agent_settled') {
2503
+ if (eventAgent) void loadAgentContexts([eventAgent]);
2504
+ } else if (ev.type === 'session_info_changed') {
2505
+ if (eventAgent) eventAgent.sessionName = ev.name || undefined;
2506
+ if (selected) state.agent.sessionName = ev.name || undefined;
2507
+ renderAgentList();
2508
+ if (selected) updateAgentHeader();
2509
+ }
2510
+ if (selected) handleDetail(message);
2511
+ }
2512
+ /** The part of an Agent event shown in the transcript of the Agent being viewed. */
2513
+ function renderTranscriptEvent(message) {
2514
+ const ev = message.event || {};
2515
+ if (ev.type === 'agent_start') $('messages').querySelector('.empty')?.remove();
2516
+ else if (
2517
+ ev.type === 'bash_execution_start' ||
2518
+ ev.type === 'bash_execution_update' ||
2519
+ ev.type === 'bash_execution_end'
2520
+ )
2521
+ updateBashCard(ev, message.timestamp);
2522
+ else if (ev.type === 'auto_retry_start' || ev.type === 'auto_retry_end')
2523
+ addCard(
2524
+ 'Retry',
2525
+ ev.errorMessage || ev.finalError || `${ev.type}${ev.attempt ? ` · attempt ${ev.attempt}` : ''}`,
2526
+ 'system',
2527
+ false,
2528
+ message.timestamp
2529
+ );
2530
+ else if (String(ev.type).startsWith('summarization_retry'))
2531
+ addCard(
2532
+ 'Summarization Retry',
2533
+ ev.errorMessage || `${ev.type}${ev.attempt ? ` · ${ev.attempt}/${ev.maxAttempts}` : ''}`,
2534
+ 'system',
2535
+ false,
2536
+ message.timestamp
2537
+ );
409
2538
  else if (ev.type === 'extension_ui_request') extensionRequest(message.agentId, ev, message.timestamp);
410
- else if (ev.type === 'extension_ui_notify') addCard(ev.notificationType || '通知', ev.message, 'system', false, message.timestamp);
411
- else if (ev.type === 'extension_ui_status') { ev.text ? state.extensionStatus.set(ev.key, ev.text) : state.extensionStatus.delete(ev.key); renderExtensionStatus(); }
412
- else if (ev.type === 'extension_ui_widget') { ev.content ? state.widgets.set(ev.key, ev.content) : state.widgets.delete(ev.key); renderWidgets(); }
413
- else if (ev.type === 'extension_ui_title') { state.agent.sessionName = ev.title; updateAgentHeader(); }
414
- else if (ev.type === 'extension_ui_working_message') { ev.message ? state.extensionStatus.set('working',ev.message) : state.extensionStatus.delete('working'); renderExtensionStatus(); }
415
- else if (!['message_start','message_end','agent_end','agent_settled','session_info_changed'].includes(ev.type)) addCard(ev.title || ev.type || 'Event', JSON.stringify(ev, null, 2), 'system', false, message.timestamp);
416
- $('messages').scrollTop = $('messages').scrollHeight;
2539
+ else if (ev.type === 'extension_ui_response')
2540
+ $(`extension-request-${ev.requestId}`)?.replaceChildren(document.createTextNode('已结束'));
2541
+ else if (ev.type === 'extension_ui_notify')
2542
+ addCard(ev.notificationType || '通知', ev.message, 'system', false, message.timestamp);
2543
+ else if (ev.type === 'extension_ui_status') {
2544
+ ev.text ? state.extensionStatus.set(ev.key, ev.text) : state.extensionStatus.delete(ev.key);
2545
+ renderExtensionStatus();
2546
+ } else if (ev.type === 'extension_ui_widget') {
2547
+ ev.content ? state.widgets.set(ev.key, ev.content) : state.widgets.delete(ev.key);
2548
+ renderWidgets();
2549
+ } else if (ev.type === 'extension_ui_title') {
2550
+ state.agent.sessionName = ev.title;
2551
+ updateAgentHeader();
2552
+ } else if (ev.type === 'extension_ui_working_message') {
2553
+ ev.message ? state.extensionStatus.set('working', ev.message) : state.extensionStatus.delete('working');
2554
+ renderExtensionStatus();
2555
+ }
2556
+ }
2557
+ function renderExtensionStatus() {
2558
+ let row = $('extensionStatus');
2559
+ if (!state.extensionStatus.size) {
2560
+ row?.remove();
2561
+ return;
2562
+ }
2563
+ if (!row) {
2564
+ row = document.createElement('div');
2565
+ row.id = 'extensionStatus';
2566
+ row.className = 'status-row';
2567
+ $('widgets').after(row);
2568
+ }
2569
+ row.textContent = [...state.extensionStatus.values()].join(' · ');
2570
+ }
2571
+ function renderWidgets() {
2572
+ $('widgets').replaceChildren(
2573
+ ...[...state.widgets.entries()].map(([key, value]) => {
2574
+ const el = document.createElement('div');
2575
+ el.className = 'widget';
2576
+ el.dataset.key = key;
2577
+ el.textContent = Array.isArray(value)
2578
+ ? value.map(x => (typeof x === 'string' ? x : x.text || '')).join('\n')
2579
+ : String(value);
2580
+ return el;
2581
+ })
2582
+ );
417
2583
  }
418
- function renderExtensionStatus() { let row=$('extensionStatus'); if (!state.extensionStatus.size) { row?.remove(); return; } if(!row){row=document.createElement('div');row.id='extensionStatus';row.className='status-row';$('widgets').after(row);} row.textContent=[...state.extensionStatus.values()].join(' · '); }
419
- function renderWidgets() { $('widgets').replaceChildren(...[...state.widgets.entries()].map(([key,value])=>{const el=document.createElement('div');el.className='widget';el.dataset.key=key;el.textContent=Array.isArray(value)?value.map(x=>typeof x==='string'?x:x.text||'').join('\n'):String(value);return el;})); }
420
2584
 
421
- function connectSocket() {
422
- if (!state.connected) return;
423
- clearTimeout(state.reconnectTimer); state.manuallyClosed = true; const previous=state.ws; state.ws=null; previous?.close(); state.manuallyClosed = false;
424
- const ws = new WebSocket(state.base.replace(/^http/, 'ws') + '/api/v1/ws', ['access-token.' + state.token]); state.ws = ws;
425
- ws.onopen = () => { state.reconnectAttempt = 0; $('status').textContent = '已配对 · 实时'; for (const agent of state.agents) { const key=`rpSeq:${agent.agentId}`, cursor=localStorage[key]; ws.send(JSON.stringify(cursor===undefined?{type:'subscribe',agentId:agent.agentId,fromNow:true,messageLimit:state.messagePageSize}:{type:'subscribe',agentId:agent.agentId,lastSequence:Number(cursor),messageLimit:state.messagePageSize})); } ws.send(JSON.stringify({type:'subscribe_all',fromNow:true})); };
426
- ws.onmessage = event => { try { handleAgentEvent(JSON.parse(event.data)); } catch (error) { console.error(error); } };
427
- ws.onclose = () => { if (!state.connected || state.manuallyClosed || ws !== state.ws) return; $('status').textContent = '正在重连…'; const delays=[1000,2000,4000,8000,15000,30000], delay=delays[Math.min(state.reconnectAttempt++,delays.length-1)]; state.reconnectTimer=setTimeout(connectSocket,delay); };
2585
+ /**
2586
+ * Tell each backend which of its Agents this page views, so only that one streams details.
2587
+ * `force` resends it for the viewed Agent, which answers with a fresh focus_state. Returns whether
2588
+ * the viewed Agent's backend was told.
2589
+ */
2590
+ function sendFocus(force = false) {
2591
+ const agent = state.selectedKind === 'agent' ? state.agent : null;
2592
+ let sent = false;
2593
+ for (const backend of connectedBackends()) {
2594
+ const target = agent?.backendId === backend.id ? agent.remoteId : null;
2595
+ if (backend.ws?.readyState !== WebSocket.OPEN) continue;
2596
+ if (backend.focused !== target || (force && target)) {
2597
+ backend.ws.send(JSON.stringify({type: 'focus', agentId: target}));
2598
+ backend.focused = target;
2599
+ }
2600
+ if (target) sent = true;
2601
+ }
2602
+ return sent;
2603
+ }
2604
+ function sendFocused(message) {
2605
+ const backend = backendOf(state.agent?.backendId);
2606
+ if (backend?.ws?.readyState === WebSocket.OPEN) backend.ws.send(JSON.stringify(message));
2607
+ }
2608
+ function connectSocket(backend) {
2609
+ if (!backend || backend.status !== 'connected') return;
2610
+ clearTimeout(backend.reconnectTimer);
2611
+ backend.manuallyClosed = true;
2612
+ const previous = backend.ws;
2613
+ backend.ws = null;
2614
+ previous?.close();
2615
+ backend.manuallyClosed = false;
2616
+ const ws = new WebSocket(backend.base.replace(/^http/, 'ws') + '/api/v1/ws', [
2617
+ 'access-token.' + backend.token
2618
+ ]);
2619
+ backend.ws = ws;
2620
+ ws.onopen = () => {
2621
+ if (ws !== backend.ws) return;
2622
+ backend.reconnectAttempt = 0;
2623
+ backend.reconnecting = false;
2624
+ updateHeaderStatus();
2625
+ ws.send(JSON.stringify({type: 'subscribe_all'}));
2626
+ ws.send(JSON.stringify({type: 'subscribe_drafts'}));
2627
+ // Nothing is replayed: refocus and reload what may have changed while disconnected.
2628
+ backend.focused = undefined;
2629
+ if (state.selectedKind === 'agent' && state.agent?.backendId === backend.id) loadTimeline(state.agent);
2630
+ else sendFocus();
2631
+ };
2632
+ ws.onmessage = event => {
2633
+ if (ws !== backend.ws) return;
2634
+ try {
2635
+ const message = JSON.parse(event.data);
2636
+ if (typeof message.agentId === 'string') message.agentId = resourceKey(backend.id, message.agentId);
2637
+ message.backendId = backend.id;
2638
+ handleAgentEvent(message);
2639
+ } catch (error) {
2640
+ console.error(error);
2641
+ }
2642
+ };
2643
+ ws.onclose = () => {
2644
+ if (backend.ws !== ws || backend.manuallyClosed) return;
2645
+ backend.reconnecting = true;
2646
+ updateHeaderStatus();
2647
+ const delays = [1000, 2000, 4000, 8000, 15000, 30000],
2648
+ delay = delays[Math.min(backend.reconnectAttempt++, delays.length - 1)];
2649
+ backend.reconnectTimer = setTimeout(() => connectSocket(backend), delay);
2650
+ };
428
2651
  ws.onerror = () => {};
429
2652
  }
430
2653
  function notificationUnavailableReason() {
431
2654
  if (!window.isSecureContext) return '浏览器通知需要 HTTPS;仅 localhost 可使用 HTTP';
432
- if (!('Notification' in window) || typeof Notification.requestPermission !== 'function') return '当前浏览器不支持系统通知';
2655
+ if (!('Notification' in window) || typeof Notification.requestPermission !== 'function')
2656
+ return '当前浏览器不支持系统通知';
433
2657
  return '';
434
2658
  }
435
2659
  function updateNotificationButton() {
436
- const button=$('notifications'),status=$('browserNotificationStatus'),unavailable=notificationUnavailableReason();
2660
+ const button = $('notifications'),
2661
+ status = $('browserNotificationStatus'),
2662
+ unavailable = notificationUnavailableReason();
437
2663
  // Keep the button clickable so unsupported/denied states can explain how to
438
2664
  // fix the problem instead of silently ignoring the user's click.
439
- button.disabled=false;
440
- if (unavailable) { button.textContent='通知不可用'; button.title=unavailable;status.textContent=unavailable; return; }
441
- button.textContent=Notification.permission==='granted'?'通知已启用':Notification.permission==='denied'?'通知已禁用':'启用通知';
442
- button.title=Notification.permission==='denied'?'通知权限已被浏览器阻止,点击查看处理方式':'启用所有 Agent 的完成通知';status.textContent=Notification.permission==='granted'?'Agent 完成后会显示浏览器系统通知。':Notification.permission==='denied'?'通知权限已被浏览器阻止。':'尚未授予浏览器通知权限。';
2665
+ button.disabled = false;
2666
+ if (unavailable) {
2667
+ button.textContent = '通知不可用';
2668
+ button.title = unavailable;
2669
+ status.textContent = unavailable;
2670
+ return;
2671
+ }
2672
+ button.textContent =
2673
+ Notification.permission === 'granted'
2674
+ ? '通知已启用'
2675
+ : Notification.permission === 'denied'
2676
+ ? '通知已禁用'
2677
+ : '启用通知';
2678
+ button.title =
2679
+ Notification.permission === 'denied'
2680
+ ? '通知权限已被浏览器阻止,点击查看处理方式'
2681
+ : '启用所有 Agent 的完成通知';
2682
+ status.textContent =
2683
+ Notification.permission === 'granted'
2684
+ ? 'Agent 完成后会显示浏览器系统通知。'
2685
+ : Notification.permission === 'denied'
2686
+ ? '通知权限已被浏览器阻止。'
2687
+ : '尚未授予浏览器通知权限。';
443
2688
  }
444
2689
  async function enableNotifications() {
445
- const unavailable=notificationUnavailableReason();
2690
+ const unavailable = notificationUnavailableReason();
446
2691
  if (unavailable) return toast(unavailable);
447
- if (Notification.permission==='denied') return toast('通知权限已被阻止,请在地址栏的网站权限中允许通知后刷新页面');
2692
+ if (Notification.permission === 'denied')
2693
+ return toast('通知权限已被阻止,请在地址栏的网站权限中允许通知后刷新页面');
448
2694
  try {
449
- const permission=await Notification.requestPermission(); updateNotificationButton();
450
- toast(permission==='granted'?'已启用所有 Agent 的完成通知':'未获得通知权限,请在浏览器的网站权限中开启');
2695
+ const permission = await Notification.requestPermission();
2696
+ updateNotificationButton();
2697
+ toast(
2698
+ permission === 'granted'
2699
+ ? '已启用所有 Agent 的完成通知'
2700
+ : '未获得通知权限,请在浏览器的网站权限中开启'
2701
+ );
451
2702
  } catch (error) {
452
- console.error('Unable to request notification permission',error);
453
- toast(`无法请求通知权限:${error?.message||'请检查浏览器的网站权限'}`);
2703
+ console.error('Unable to request notification permission', error);
2704
+ toast(`无法请求通知权限:${error?.message || '请检查浏览器的网站权限'}`);
454
2705
  }
455
2706
  }
456
2707
  function notifyComplete(agent, timestamp) {
457
2708
  if (!('Notification' in window) || Notification.permission !== 'granted') return;
458
- const occurredAt=Number(timestamp)||Math.floor(Date.now()/1000);
459
- try { new Notification('Agent 完成', {body:`${agentLabel(agent)} 已转为空闲\n本地时间:${new Date(occurredAt*1000).toLocaleString()}\nUnix 时间戳:${occurredAt}`, timestamp:occurredAt*1000, tag:`remote-pi-${agent.agentId}-${occurredAt}`}); } catch (error) { console.error('Unable to show notification',error); }
2709
+ const occurredAt = Number(timestamp) || Math.floor(Date.now() / 1000);
2710
+ try {
2711
+ new Notification('Agent 完成', {
2712
+ body: `${agentLabel(agent)} 已转为空闲\n本地时间:${new Date(occurredAt * 1000).toLocaleString()}\nUnix 时间戳:${occurredAt}`,
2713
+ timestamp: occurredAt * 1000,
2714
+ tag: `remote-pi-${agent.agentId}-${occurredAt}`
2715
+ });
2716
+ } catch (error) {
2717
+ console.error('Unable to show notification', error);
2718
+ }
460
2719
  }
461
- async function loadMailNotificationSettings(status) {
462
- if(status?.mailNotificationsConfigured===false){state.mailNotificationsAvailable=false;return}
463
- try{const result=await api('/api/v1/mail-notifications');state.mailNotificationsAvailable=!!result.available;if(result.settings)state.mailSettings={...state.mailSettings,...result.settings};}
464
- catch(error){state.mailNotificationsAvailable=false;console.error('Unable to load mail notification settings',error);}
2720
+ async function loadPrimaryMailSettings() {
2721
+ const requestNumber = ++state.mailRequest;
2722
+ const backend = primaryBackend();
2723
+ state.mailBackendId = null;
2724
+ state.mailNotificationsAvailable = false;
2725
+ updateMailControls();
2726
+ if (!backend) return;
2727
+
2728
+ if (backend.statusInfo?.mailNotificationsConfigured === false) {
2729
+ state.mailBackendId = backend.id;
2730
+ updateMailControls();
2731
+ return;
2732
+ }
2733
+
2734
+ try {
2735
+ const result = await requestBackend(backend.id, '/api/v1/mail-notifications');
2736
+ if (requestNumber !== state.mailRequest || primaryBackend()?.id !== backend.id) return;
2737
+ state.mailBackendId = backend.id;
2738
+ state.mailNotificationsAvailable = Boolean(result.available);
2739
+ if (result.settings) state.mailSettings = {...state.mailSettings, ...result.settings};
2740
+ } catch (error) {
2741
+ if (requestNumber !== state.mailRequest) return;
2742
+ console.error('Unable to load mail notification settings', error);
2743
+ }
2744
+ updateMailControls();
465
2745
  }
466
- function updateMailControls(){
467
- const available=state.connected&&state.mailNotificationsAvailable,settings=state.mailSettings;
468
- $('mailNotificationEnabled').checked=!!settings.enabled;$('mailAggregationDelay').value=String(settings.aggregationDelaySeconds??0);$('mailIncludeResponse').checked=!!settings.includeResponse;$('mailIncludeSessionDetails').checked=!!settings.includeSessionDetails;
469
- for(const id of ['mailNotificationEnabled','mailAggregationDelay','mailIncludeResponse','mailIncludeSessionDetails','configSave'])$(id).disabled=!available;
470
- $('mailNotificationAvailability').textContent=!state.connected?'连接服务后可以配置邮件通知。':available?'MailDispatch 已在服务端配置。设置对所有 Agent 和客户端生效。':'服务端启动时未配置 MailDispatch endpoint、API key 和通知邮箱。';
2746
+
2747
+ function updateMailControls() {
2748
+ const backend = backendOf(state.mailBackendId);
2749
+ const ownsForm = backend?.status === 'connected' && primaryBackend()?.id === backend.id;
2750
+ const available = ownsForm && state.mailNotificationsAvailable;
2751
+ const settings = state.mailSettings;
2752
+ $('mailNotificationEnabled').checked = Boolean(settings.enabled);
2753
+ $('mailAggregationDelay').value = String(settings.aggregationDelaySeconds ?? 0);
2754
+ $('mailIncludeResponse').checked = Boolean(settings.includeResponse);
2755
+ $('mailIncludeSessionDetails').checked = Boolean(settings.includeSessionDetails);
2756
+ $('mailCollabEscalations').checked = Boolean(settings.collabEscalations);
2757
+ for (const id of [
2758
+ 'mailNotificationEnabled',
2759
+ 'mailAggregationDelay',
2760
+ 'mailIncludeResponse',
2761
+ 'mailIncludeSessionDetails',
2762
+ 'mailCollabEscalations',
2763
+ 'configSave'
2764
+ ]) {
2765
+ $(id).disabled = !available;
2766
+ }
2767
+ $('mailNotificationAvailability').textContent = !hasConnection()
2768
+ ? '连接服务后可以配置邮件通知。'
2769
+ : !ownsForm
2770
+ ? '正在读取主 Backend 的邮件通知设置…'
2771
+ : available
2772
+ ? `MailDispatch 已在「${backend.name}」配置。设置对该 Backend 的所有 Agent 和客户端生效。`
2773
+ : `Backend「${backend.name}」未配置 MailDispatch endpoint、API key 和通知邮箱。`;
471
2774
  }
472
- function updateConfigUi(){
473
- $('configConnectionStatus').textContent=state.connected?`已连接 ${state.base}`:'当前未配对';updateNotificationButton();updateVoiceButton();updateMailControls();
2775
+
2776
+ function updateConfigUi() {
2777
+ const connected = connectedBackends().length;
2778
+ $('configConnectionStatus').textContent = connected ? `已连接 ${connected} 个 backend` : '当前未配对';
2779
+ $('disconnectAll').hidden = !connected;
2780
+ updateNotificationButton();
2781
+ updateVoiceButton();
2782
+ updateMailControls();
2783
+ renderBackendList();
2784
+ }
2785
+
2786
+ function openConfig() {
2787
+ updateConfigUi();
2788
+ renderKeyboardBindings();
2789
+ if (!$('configDialog').open) $('configDialog').showModal();
474
2790
  }
475
- function openConfig(){updateConfigUi();if(!$('configDialog').open)$('configDialog').showModal();}
476
- async function saveMailSettings(){
477
- if(!requireConnection())return;if(!state.mailNotificationsAvailable)return toast('服务端未配置 MailDispatch');
478
- const delay=Number($('mailAggregationDelay').value);if(!Number.isInteger(delay)||delay<0||delay>86400)return toast('聚合时间必须是 0 到 86400 之间的整数秒');
479
- $('configSave').disabled=true;
480
- try{const result=await post('/api/v1/mail-notifications',{enabled:$('mailNotificationEnabled').checked,aggregationDelaySeconds:delay,includeResponse:$('mailIncludeResponse').checked,includeSessionDetails:$('mailIncludeSessionDetails').checked});state.mailSettings=result.settings;updateMailControls();toast('邮件通知设置已保存');}
481
- catch(error){toast(error.message)}finally{$('configSave').disabled=!state.mailNotificationsAvailable;}
2791
+
2792
+ async function saveMailSettings() {
2793
+ if (!requireConnection()) return;
2794
+ const backend = backendOf(state.mailBackendId);
2795
+ if (!backend || primaryBackend()?.id !== backend.id)
2796
+ return toast('主 Backend 已变化,请等待邮件设置重新加载');
2797
+ if (!state.mailNotificationsAvailable) return toast('服务端未配置 MailDispatch');
2798
+ const delay = Number($('mailAggregationDelay').value);
2799
+ if (!Number.isInteger(delay) || delay < 0 || delay > 86400)
2800
+ return toast('聚合时间必须是 0 到 86400 之间的整数秒');
2801
+ $('configSave').disabled = true;
2802
+ try {
2803
+ const result = await postBackend(backend.id, '/api/v1/mail-notifications', {
2804
+ enabled: $('mailNotificationEnabled').checked,
2805
+ aggregationDelaySeconds: delay,
2806
+ includeResponse: $('mailIncludeResponse').checked,
2807
+ includeSessionDetails: $('mailIncludeSessionDetails').checked,
2808
+ collabEscalations: $('mailCollabEscalations').checked
2809
+ });
2810
+ if (primaryBackend()?.id !== backend.id || state.mailBackendId !== backend.id) return;
2811
+ state.mailSettings = result.settings;
2812
+ updateMailControls();
2813
+ toast('邮件通知设置已保存');
2814
+ } catch (error) {
2815
+ toast(error.message);
2816
+ } finally {
2817
+ updateMailControls();
2818
+ }
482
2819
  }
483
2820
 
484
2821
  function modal(title, build, okText = '确定') {
485
- const dialog=$('modal'), body=$('modalBody'); $('modalTitle').textContent=title; $('modalOk').textContent=okText; body.replaceChildren(); const value=build(body);
486
- return new Promise(resolve => { const done=()=>{dialog.removeEventListener('close',done);resolve(dialog.returnValue==='default'?(typeof value==='function'?value():value):null);};dialog.addEventListener('close',done);dialog.showModal(); });
2822
+ const dialog = $('modal'),
2823
+ body = $('modalBody');
2824
+ $('modalTitle').textContent = title;
2825
+ $('modalOk').textContent = okText;
2826
+ body.replaceChildren();
2827
+ const value = build(body);
2828
+ return new Promise(resolve => {
2829
+ const done = () => {
2830
+ dialog.removeEventListener('close', done);
2831
+ resolve(dialog.returnValue === 'default' ? (typeof value === 'function' ? value() : value) : null);
2832
+ };
2833
+ dialog.addEventListener('close', done);
2834
+ dialog.showModal();
2835
+ });
2836
+ }
2837
+ const field = (body, label, value = '', type = 'text') => {
2838
+ const l = document.createElement('label');
2839
+ l.textContent = label;
2840
+ const input = document.createElement('input');
2841
+ input.type = type;
2842
+ input.value = value;
2843
+ l.append(input);
2844
+ body.append(l);
2845
+ return input;
2846
+ };
2847
+ let agentContextTarget = null;
2848
+ function showItemContextMenu(event, kind, item) {
2849
+ event.preventDefault();
2850
+ event.stopPropagation();
2851
+ agentContextTarget = {kind, item};
2852
+ const archive = $('menuArchiveAgent'),
2853
+ active =
2854
+ kind === 'agent' && ['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(item.status),
2855
+ binding = kind === 'agent' && sessionKeyboard.getBinding(item.agentId);
2856
+ archive.hidden = kind !== 'agent';
2857
+ archive.disabled = active;
2858
+ archive.title = active ? 'Agent 执行期间不能 Archive' : '';
2859
+ $('menuBindKeyboard').hidden = kind !== 'agent' || !!binding;
2860
+ $('menuBindKeyboard').disabled = !binding && sessionKeyboard.getBindings().length >= 6;
2861
+ $('menuBindKeyboard').title = $('menuBindKeyboard').disabled ? '六个按键均已绑定' : '';
2862
+ $('menuKeyboardColor').hidden = kind !== 'agent' || !binding;
2863
+ $('menuUnbindKeyboard').hidden = kind !== 'agent' || !binding;
2864
+ $('menuCloseTerminal').hidden = kind !== 'terminal';
2865
+ const menu = $('agentContextMenu');
2866
+ menu.hidden = false;
2867
+ menu.style.left = `${Math.min(event.clientX, innerWidth - 210)}px`;
2868
+ menu.style.top = `${Math.min(event.clientY, innerHeight - 190)}px`;
2869
+ }
2870
+ async function bindKeyboard({agentId, slot} = {}) {
2871
+ const free = sessionKeyboard.getFreeSlots();
2872
+ const agents = state.agents.filter(agent => !sessionKeyboard.getBinding(agent.agentId));
2873
+ if (!free.length) return toast('六个键盘按键均已绑定');
2874
+ if (!agents.length) return toast('没有可绑定的 Session');
2875
+ const result = await modal(
2876
+ '绑定键盘按键',
2877
+ body => {
2878
+ const sessionLabel = document.createElement('label');
2879
+ sessionLabel.textContent = 'Session';
2880
+ const sessions = document.createElement('select');
2881
+ for (const agent of agents) {
2882
+ const option = document.createElement('option');
2883
+ option.value = agent.agentId;
2884
+ option.textContent =
2885
+ connectedBackends().length > 1
2886
+ ? `${agent.backendName} · ${agentLabel(agent)}`
2887
+ : agentLabel(agent);
2888
+ option.selected = agent.agentId === agentId;
2889
+ sessions.append(option);
2890
+ }
2891
+ sessionLabel.append(sessions);
2892
+ body.append(sessionLabel);
2893
+ const slotLabel = document.createElement('label');
2894
+ slotLabel.textContent = '按键';
2895
+ const keys = document.createElement('select');
2896
+ for (const key of free) {
2897
+ const option = document.createElement('option');
2898
+ option.value = String(key);
2899
+ option.textContent = `按键 ${key + 1} / AG0${key}`;
2900
+ option.selected = key === slot;
2901
+ keys.append(option);
2902
+ }
2903
+ slotLabel.append(keys);
2904
+ body.append(slotLabel);
2905
+ const color = field(body, '灯光颜色', sessionKeyboard.nextColor(), 'color');
2906
+ return () => ({agentId: sessions.value, slot: Number(keys.value), color: color.value});
2907
+ },
2908
+ '绑定'
2909
+ );
2910
+ if (!result) return;
2911
+ sessionKeyboard.bind(result.agentId, result.slot, result.color);
2912
+ toast(`已绑定到按键 ${result.slot + 1}`);
2913
+ }
2914
+ async function changeKeyboardColor(agent) {
2915
+ const binding = sessionKeyboard.getBinding(agent.agentId);
2916
+ if (!binding) return;
2917
+ const color = await modal(
2918
+ '更换按键颜色',
2919
+ body => {
2920
+ const input = field(body, `按键 ${binding.slot + 1} 的颜色`, binding.color, 'color');
2921
+ return () => input.value;
2922
+ },
2923
+ '应用'
2924
+ );
2925
+ if (color) {
2926
+ sessionKeyboard.setColor(agent.agentId, color);
2927
+ toast(`按键 ${binding.slot + 1} 颜色已更新`);
2928
+ }
487
2929
  }
488
- const field = (body, label, value='', type='text') => { const l=document.createElement('label');l.textContent=label;const input=document.createElement('input');input.type=type;input.value=value;l.append(input);body.append(l);return input; };
489
- let agentContextTarget=null;
490
- function showItemContextMenu(event,kind,item){event.preventDefault();event.stopPropagation();agentContextTarget={kind,item};const archive=$('menuArchiveAgent'),active=kind==='agent'&&['starting','streaming','waiting_for_user','stopping'].includes(item.status);archive.hidden=kind!=='agent';archive.disabled=active;archive.title=active?'Agent 执行期间不能 Archive':'';$('menuCloseTerminal').hidden=kind!=='terminal';const menu=$('agentContextMenu');menu.hidden=false;menu.style.left=`${Math.min(event.clientX,innerWidth-210)}px`;menu.style.top=`${Math.min(event.clientY,innerHeight-80)}px`;}
491
2930
  async function createAgent() {
492
- if (!state.workspace) return toast('请先选择 Workspace');
493
- const cwd=$('agentCwd').value.trim()||'.'; let sessions=[]; try { sessions=await api(`/api/v1/sessions?workspaceId=${state.workspace.id}&path=${encodeURIComponent(cwd)}`); } catch(e){return toast(e.message);}
494
- const result=await modal('新建 Agent',body=>{const c=field(body,'工作路径',cwd);const l=document.createElement('label');l.textContent='Session';const s=document.createElement('select');s.innerHTML='<option value="">新 Session</option>'+sessions.map(x=>`<option value="${esc(x.path)}">${esc(x.name||x.sessionName||x.id||x.path)} · ${esc(x.modified||'')}</option>`).join('');l.append(s);body.append(l);return()=>({cwd:c.value.trim()||'.',sessionFile:s.value||undefined});},'创建');
495
- if (!result) return; try { const agent=await post('/api/v1/agents',{workspaceId:state.workspace.id,relativeCwd:result.cwd,sessionFile:result.sessionFile});await refreshAgents();await selectAgent(agent);}catch(e){toast(e.message);}
2931
+ const workspace = state.workspace;
2932
+ if (!workspace) return toast('请先选择 Workspace');
2933
+ const cwd = $('agentCwd').value.trim() || '.';
2934
+ let sessions,
2935
+ templates = [];
2936
+ try {
2937
+ [sessions, templates] = await Promise.all([
2938
+ requestBackend(
2939
+ workspace.backendId,
2940
+ `/api/v1/sessions?workspaceId=${encodeURIComponent(workspace.id)}&path=${encodeURIComponent(cwd)}`
2941
+ ),
2942
+ // Older backends have no templates endpoint; they simply offer Pi's default prompt.
2943
+ requestBackend(workspace.backendId, '/api/v1/agent-templates')
2944
+ .then(data => data.templates || [])
2945
+ .catch(() => [])
2946
+ ]);
2947
+ } catch (error) {
2948
+ return toast(error.message);
2949
+ }
2950
+ const result = await modal(
2951
+ '新建 Agent',
2952
+ body => {
2953
+ 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);
2967
+ const t = templateSelect(body, templates);
2968
+ const preview = promptPreviewBox(body, () => ({
2969
+ backendId: workspace.backendId,
2970
+ workspaceId: workspace.id,
2971
+ relativeCwd: c.value.trim() || '.',
2972
+ templateId: t.value
2973
+ }));
2974
+ c.addEventListener('input', () => preview.reset());
2975
+ t.onchange = () => preview.reset();
2976
+ return () => ({
2977
+ cwd: c.value.trim() || '.',
2978
+ sessionFile: s.value || undefined,
2979
+ templateId: t.value || undefined
2980
+ });
2981
+ },
2982
+ '创建'
2983
+ );
2984
+ if (!result) return;
2985
+ try {
2986
+ const created = await postBackend(workspace.backendId, '/api/v1/agents', {
2987
+ workspaceId: workspace.id,
2988
+ relativeCwd: result.cwd,
2989
+ sessionFile: result.sessionFile,
2990
+ templateId: result.templateId
2991
+ });
2992
+ const agent = stampNewItem(workspace.backendId, created);
2993
+ const binding = result.sessionFile ? null : autoBindNewSession(agent);
2994
+ await refreshAgents();
2995
+ await selectAgent(agent);
2996
+ if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot + 1}`);
2997
+ } catch (error) {
2998
+ toast(error.message);
2999
+ }
3000
+ }
3001
+ const templateModeText = mode => (mode === 'replace' ? '替换 Pi 基础 Prompt' : '追加到 Pi Prompt 之后');
3002
+ /** Template picker for the create-Agent dialog. The choice is copied onto the Agent and cannot change later. */
3003
+ function templateSelect(body, templates) {
3004
+ const label = document.createElement('label');
3005
+ label.textContent = 'System Prompt 模板';
3006
+ const select = document.createElement('select');
3007
+ select.innerHTML =
3008
+ '<option value="">Pi 默认(SYSTEM.md / APPEND_SYSTEM.md 或 Pi 内置)</option>' +
3009
+ templates
3010
+ .map(
3011
+ x =>
3012
+ `<option value="${esc(x.id)}">${esc(x.name)} · ${esc(templateModeText(x.mode))}${x.description ? ` — ${esc(x.description)}` : ''}</option>`
3013
+ )
3014
+ .join('');
3015
+ const hint = document.createElement('small');
3016
+ hint.className = 'muted';
3017
+ hint.textContent =
3018
+ '创建后不可更改;Fork 出的 Agent 沿用同一模板。模板内容在创建时复制,之后修改模板文件不影响已有 Agent。';
3019
+ label.append(select, hint);
3020
+ body.append(label);
3021
+ return select;
3022
+ }
3023
+ function promptSourcesText(info) {
3024
+ const parts = [];
3025
+ if (info.template)
3026
+ parts.push(
3027
+ `模板:${info.template.name || info.template.id}(${templateModeText(info.template.mode)})`
3028
+ );
3029
+ if (info.template?.mode !== 'replace')
3030
+ parts.push(info.systemFile ? `基础 Prompt:${info.systemFile}` : '基础 Prompt:Pi 内置');
3031
+ if (info.appendFiles?.length) parts.push(`追加:${info.appendFiles.join(', ')}`);
3032
+ if (info.contextFiles?.length) parts.push(`上下文文件:${info.contextFiles.join(', ')}`);
3033
+ if (info.cwd) parts.push(`cwd:${info.cwd}`);
3034
+ if (typeof info.systemPrompt === 'string') parts.push(`${info.systemPrompt.length} 字符`);
3035
+ return parts.join(' · ');
3036
+ }
3037
+ /** Collapsible, on-demand preview of the system prompt an Agent would start with. */
3038
+ function promptPreviewBox(body, target, title = '预览 System Prompt') {
3039
+ const box = document.createElement('details');
3040
+ box.className = 'prompt-preview';
3041
+ const summary = document.createElement('summary');
3042
+ summary.textContent = title;
3043
+ const meta = document.createElement('small');
3044
+ meta.className = 'muted';
3045
+ const pre = document.createElement('pre');
3046
+ pre.className = 'prompt-text';
3047
+ box.append(summary, meta, pre);
3048
+ body.append(box);
3049
+ let loaded = false,
3050
+ request = 0;
3051
+ const load = async () => {
3052
+ const {backendId, workspaceId, relativeCwd, templateId, source} = target(),
3053
+ current = ++request;
3054
+ pre.textContent = '';
3055
+ if (!workspaceId) {
3056
+ meta.textContent = '需要先选择该 Backend 上的 Workspace 才能预览(Prompt 与工作目录有关)。';
3057
+ return;
3058
+ }
3059
+ meta.textContent = '正在生成…';
3060
+ try {
3061
+ const query = new URLSearchParams({workspaceId, relativeCwd: relativeCwd || '.'});
3062
+ if (templateId) query.set('templateId', templateId);
3063
+ if (source) query.set('source', source);
3064
+ const data = await requestBackend(backendId, `/api/v1/system-prompt/preview?${query}`);
3065
+ if (current !== request) return;
3066
+ meta.textContent = promptSourcesText(data);
3067
+ pre.textContent = data.systemPrompt;
3068
+ loaded = true;
3069
+ } catch (error) {
3070
+ if (current === request) meta.textContent = `预览失败:${error.message}`;
3071
+ }
3072
+ };
3073
+ box.addEventListener('toggle', () => {
3074
+ if (box.open && !loaded) void load();
3075
+ });
3076
+ return {
3077
+ reset() {
3078
+ ++request;
3079
+ loaded = false;
3080
+ if (box.open) void load();
3081
+ }
3082
+ };
496
3083
  }
497
- async function navigateAgent(entryId) {
498
- const agent=state.agent;if(!agent)return;
499
- const result=await post(`/api/v1/agents/${agent.agentId}/navigate`,{entryId});
3084
+ async function showAgentSystemPrompt() {
3085
+ if (!state.agent) return toast('请选择 Agent');
3086
+ const info = await requestAgent(state.agent.agentId, '/system-prompt');
3087
+ await modal(
3088
+ 'Agent System Prompt',
3089
+ body => {
3090
+ const head = document.createElement('p');
3091
+ head.textContent = info.template
3092
+ ? `模板:${info.template.name || info.template.id}(${templateModeText(info.template.mode)})。模板在创建 Agent 时选定并固定,不能更改。`
3093
+ : '未使用模板:Pi 默认 System Prompt(含 ~/.pi/agent 与受信任项目 .pi 下的 SYSTEM.md / APPEND_SYSTEM.md)。模板只能在创建 Agent 时选择。';
3094
+ const meta = document.createElement('small');
3095
+ meta.className = 'muted';
3096
+ meta.textContent = promptSourcesText(info);
3097
+ const pre = document.createElement('pre');
3098
+ pre.className = 'prompt-text';
3099
+ pre.textContent = info.systemPrompt;
3100
+ body.append(head, meta, pre);
3101
+ if (info.template) {
3102
+ const details = document.createElement('details');
3103
+ details.className = 'prompt-preview';
3104
+ const summary = document.createElement('summary');
3105
+ summary.textContent = '模板原文(创建时快照)';
3106
+ const raw = document.createElement('pre');
3107
+ raw.className = 'prompt-text';
3108
+ raw.textContent = info.template.systemPrompt;
3109
+ details.append(summary, raw);
3110
+ body.append(details);
3111
+ }
3112
+ return null;
3113
+ },
3114
+ '关闭'
3115
+ );
3116
+ }
3117
+ /** Manage Pi's global prompt files and the shareable Agent templates of one backend. */
3118
+ async function systemPromptManager() {
3119
+ if (!requireConnection()) return;
3120
+ const workspaceBackend =
3121
+ state.workspace && backendOf(state.workspace.backendId)?.status === 'connected'
3122
+ ? state.workspace.backendId
3123
+ : null;
3124
+ const backendId = workspaceBackend || primaryBackend()?.id;
3125
+ if (!backendId) return toast('没有已连接的 Backend');
3126
+ const backend = backendOf(backendId),
3127
+ workspace = state.workspace?.backendId === backendId ? state.workspace : null,
3128
+ relativeCwd = () => $('agentCwd').value.trim() || '.';
3129
+ const send = (url, method = 'GET', body) =>
3130
+ requestBackend(backendId, url, {method, ...(body ? {body: JSON.stringify(body)} : {})});
3131
+ let files, catalog;
3132
+ try {
3133
+ [files, catalog] = await Promise.all([send('/api/v1/system-prompt'), send('/api/v1/agent-templates')]);
3134
+ } catch (error) {
3135
+ return toast(`读取失败:${error.message}(Backend 可能版本过旧)`);
3136
+ }
3137
+ 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
+ const section = (title, note) =>
3144
+ el(
3145
+ 'section',
3146
+ {className: 'config-section'},
3147
+ el('h3', {textContent: title}),
3148
+ el('p', {className: 'muted', textContent: note})
3149
+ );
3150
+ await modal(
3151
+ `System Prompt · ${backend.name || backendId}`,
3152
+ body => {
3153
+ // Effective prompt preview ---------------------------------------------------------------
3154
+ const previewSection = section(
3155
+ '查看当前 System Prompt',
3156
+ workspace
3157
+ ? `基于 Workspace「${workspace.label}」的工作路径「${relativeCwd()}」生成,包含项目上下文、skills 与 cwd。`
3158
+ : '先在左侧选择该 Backend 上的 Workspace 才能预览(Prompt 与工作目录有关)。'
3159
+ );
3160
+ const sourceSelect = el('select');
3161
+ sourceSelect.innerHTML =
3162
+ '<option value="">当前生效(含 SYSTEM.md / APPEND_SYSTEM.md)</option><option value="pi-default">Pi 内置(忽略 SYSTEM.md / APPEND_SYSTEM.md)</option>';
3163
+ previewSection.append(el('label', {textContent: '来源'}, sourceSelect));
3164
+ const preview = promptPreviewBox(previewSection, () => ({
3165
+ backendId,
3166
+ workspaceId: workspace?.id,
3167
+ relativeCwd: relativeCwd(),
3168
+ source: sourceSelect.value
3169
+ }));
3170
+ sourceSelect.onchange = () => preview.reset();
3171
+ // Pi's global prompt files ----------------------------------------------------------------
3172
+ const fileSection = section(
3173
+ 'Pi 全局 System Prompt',
3174
+ `位于 ${files.agentDir},对所有 Pi 会话(包括 Pi CLI)生效;受信任项目的 .pi/SYSTEM.md、.pi/APPEND_SYSTEM.md 优先。留空保存即删除文件(恢复 Pi 内置)。对之后加载的 Agent 生效。`
3175
+ );
3176
+ const fileEditor = (kind, title, placeholder) => {
3177
+ const area = el('textarea', {className: 'prompt-editor', value: files[kind].content, placeholder});
3178
+ const status = el('small', {className: 'muted'});
3179
+ const showStatus = () =>
3180
+ (status.textContent = files[kind].exists ? files[kind].path : `${files[kind].path}(不存在)`);
3181
+ showStatus();
3182
+ const save = el('button', {type: 'button', textContent: '保存'});
3183
+ save.onclick = async () => {
3184
+ if (
3185
+ kind === 'system' &&
3186
+ area.value.trim() &&
3187
+ !confirm('SYSTEM.md 会替换 Pi 的整个基础 Prompt(包括工具说明、规则与文档段落),确定保存?')
3188
+ )
3189
+ return;
3190
+ save.disabled = true;
3191
+ try {
3192
+ files = await send('/api/v1/system-prompt', 'PUT', {kind, content: area.value});
3193
+ area.value = files[kind].content;
3194
+ showStatus();
3195
+ preview.reset();
3196
+ toast(files[kind].exists ? `${title} 已保存` : `${title} 已删除`);
3197
+ } catch (error) {
3198
+ toast(`保存失败:${error.message}`);
3199
+ } finally {
3200
+ save.disabled = false;
3201
+ }
3202
+ };
3203
+ fileSection.append(
3204
+ el('label', {textContent: title}, area),
3205
+ el('div', {className: 'config-actions'}, save, status)
3206
+ );
3207
+ };
3208
+ fileEditor('system', 'SYSTEM.md(替换 Pi 内置基础 Prompt)', '留空 = 使用 Pi 内置 Prompt');
3209
+ fileEditor('append', 'APPEND_SYSTEM.md(追加到基础 Prompt 之后)', '留空 = 不追加');
3210
+ // Shareable Agent templates ---------------------------------------------------------------
3211
+ const templateSection = section(
3212
+ 'Agent 模板',
3213
+ `每个模板是 ${catalog.dir} 下的一个 .md 文件(YAML front matter:name、description、mode),可直接复制分享,手动放入的文件会立即出现。新建 Agent 时选择,之后不可更改。`
3214
+ );
3215
+ const list = el('div', {className: 'template-list'}),
3216
+ errors = el('div'),
3217
+ editor = el('div', {className: 'template-editor', hidden: true});
3218
+ const idInput = el('input', {placeholder: '例如 reviewer(即文件名;字母、数字、. _ -)'}),
3219
+ nameInput = el('input', {placeholder: '显示名称'}),
3220
+ descInput = el('input', {placeholder: '一行说明(可选)'}),
3221
+ modeSelect = el('select'),
3222
+ promptArea = el('textarea', {className: 'prompt-editor', placeholder: 'System Prompt 内容'}),
3223
+ saveTemplate = el('button', {type: 'button', textContent: '保存模板'}),
3224
+ cancelTemplate = el('button', {type: 'button', textContent: '取消'});
3225
+ modeSelect.innerHTML =
3226
+ '<option value="append">append:追加到 Pi Prompt 之后(保留 Pi 的工具说明与规则)</option><option value="replace">replace:替换 Pi 基础 Prompt(等同 SYSTEM.md)</option>';
3227
+ editor.append(
3228
+ el('label', {textContent: 'ID'}, idInput),
3229
+ el('label', {textContent: '名称'}, nameInput),
3230
+ el('label', {textContent: '说明'}, descInput),
3231
+ el('label', {textContent: '模式'}, modeSelect),
3232
+ el('label', {textContent: 'System Prompt'}, promptArea),
3233
+ el('div', {className: 'config-actions'}, saveTemplate, cancelTemplate)
3234
+ );
3235
+ const templatePreview = promptPreviewBox(
3236
+ editor,
3237
+ () => ({
3238
+ backendId,
3239
+ workspaceId: workspace?.id,
3240
+ relativeCwd: relativeCwd(),
3241
+ templateId: idInput.value.trim()
3242
+ }),
3243
+ '预览已保存版本生成的 System Prompt'
3244
+ );
3245
+ const openEditor = (template = null) => {
3246
+ editor.hidden = false;
3247
+ idInput.value = template?.id || '';
3248
+ idInput.disabled = !!template;
3249
+ nameInput.value = template?.name || '';
3250
+ descInput.value = template?.description || '';
3251
+ modeSelect.value = template?.mode || 'append';
3252
+ promptArea.value = template?.systemPrompt || '';
3253
+ templatePreview.reset();
3254
+ (template ? promptArea : idInput).focus();
3255
+ };
3256
+ const reload = async () => {
3257
+ catalog = await send('/api/v1/agent-templates');
3258
+ renderList();
3259
+ };
3260
+ const renderList = () => {
3261
+ list.replaceChildren();
3262
+ if (!catalog.templates.length)
3263
+ list.append(el('p', {className: 'muted', textContent: '还没有模板。'}));
3264
+ for (const template of catalog.templates) {
3265
+ const edit = el('button', {type: 'button', textContent: '编辑'});
3266
+ edit.onclick = () => openEditor(template);
3267
+ const remove = el('button', {type: 'button', textContent: '删除', className: 'danger-action'});
3268
+ remove.onclick = async () => {
3269
+ if (!confirm(`删除模板「${template.name}」?已用它创建的 Agent 不受影响。`)) return;
3270
+ try {
3271
+ await send(`/api/v1/agent-templates/${encodeURIComponent(template.id)}`, 'DELETE');
3272
+ if (idInput.disabled && idInput.value === template.id) editor.hidden = true;
3273
+ await reload();
3274
+ toast('模板已删除');
3275
+ } catch (error) {
3276
+ toast(`删除失败:${error.message}`);
3277
+ }
3278
+ };
3279
+ const info = el(
3280
+ 'div',
3281
+ {className: 'template-info'},
3282
+ el('b', {textContent: template.name}),
3283
+ el('small', {
3284
+ className: 'muted',
3285
+ textContent: `${template.id}.md · ${templateModeText(template.mode)}${template.description ? ` · ${template.description}` : ''}`
3286
+ })
3287
+ );
3288
+ list.append(el('div', {className: 'template-row'}, info, edit, remove));
3289
+ }
3290
+ errors.replaceChildren(
3291
+ ...(catalog.errors || []).map(text => el('p', {className: 'mcp-error', textContent: text}))
3292
+ );
3293
+ };
3294
+ cancelTemplate.onclick = () => (editor.hidden = true);
3295
+ saveTemplate.onclick = async () => {
3296
+ const id = idInput.value.trim();
3297
+ if (!id) return toast('请填写模板 ID');
3298
+ if (!promptArea.value.trim()) return toast('System Prompt 不能为空');
3299
+ if (
3300
+ !idInput.disabled &&
3301
+ catalog.templates.some(x => x.id === id) &&
3302
+ !confirm(`模板 ${id} 已存在,覆盖?`)
3303
+ )
3304
+ return;
3305
+ saveTemplate.disabled = true;
3306
+ try {
3307
+ const saved = await send(`/api/v1/agent-templates/${encodeURIComponent(id)}`, 'PUT', {
3308
+ name: nameInput.value.trim() || id,
3309
+ description: descInput.value.trim(),
3310
+ mode: modeSelect.value,
3311
+ systemPrompt: promptArea.value
3312
+ });
3313
+ await reload();
3314
+ openEditor(saved);
3315
+ toast('模板已保存');
3316
+ } catch (error) {
3317
+ toast(`保存失败:${error.message}`);
3318
+ } finally {
3319
+ saveTemplate.disabled = false;
3320
+ }
3321
+ };
3322
+ const addTemplate = el('button', {type: 'button', textContent: '新建模板'});
3323
+ addTemplate.onclick = () => openEditor();
3324
+ templateSection.append(list, errors, el('div', {className: 'config-actions'}, addTemplate), editor);
3325
+ renderList();
3326
+ body.append(previewSection, fileSection, templateSection);
3327
+ return null;
3328
+ },
3329
+ '关闭'
3330
+ );
3331
+ }
3332
+ async function navigateAgent(entryId, agent = state.agent) {
3333
+ if (!agent) return;
3334
+ const result = await postAgent(agent.agentId, '/navigate', {entryId});
500
3335
  await selectAgent(agent);
501
- if(result.editorText!==undefined)$('input').value=result.editorText;
502
- toast(result.editorText!==undefined?'已回退到该用户输入,可编辑后重新发送':'已回退到所选输出');
3336
+ if (result.editorText !== undefined) $('input').value = result.editorText;
3337
+ toast(result.editorText !== undefined ? '已回退到该用户输入,可编辑后重新发送' : '已回退到所选输出');
503
3338
  }
504
3339
  async function showSessionTree() {
505
- if (!state.agent) return toast('请选择 Agent'); const session=await api(`/api/v1/agents/${state.agent.agentId}/session`); const entries=session.entries||[];
506
- const choice=await modal('Session Tree',body=>{const actionLabel=document.createElement('label');actionLabel.textContent='操作';const action=document.createElement('select');action.innerHTML='<option value="navigate">Navigate 到条目</option><option value="fork">从用户消息 Fork 新 Agent</option>';actionLabel.append(action);body.append(actionLabel);const list=document.createElement('div');list.className='modal-list';let selected='';for(const e of entries){const b=document.createElement('button');b.type='button';b.className='choice';const text=textContent(e.message?.content||e.content)||e.type;b.innerHTML=`${e.id===session.leafId?'● ':'○ '}${esc(text.slice(0,100))}<small>${esc(e.id)}</small>`;b.onclick=()=>{selected=e.id;list.querySelectorAll('button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');};list.append(b);}body.append(list);return()=>selected?{entryId:selected,action:action.value}:null;},'执行');
507
- if(choice){if(choice.action==='fork'){const result=await post(`/api/v1/agents/${state.agent.agentId}/fork`,{entryId:choice.entryId});await refreshAgents();await selectAgent(result.agent);}else await navigateAgent(choice.entryId);}
3340
+ const sourceAgent = state.agent;
3341
+ if (!sourceAgent) return toast('请选择 Agent');
3342
+ const session = await requestAgent(sourceAgent.agentId, '/session');
3343
+ const entries = session.entries || [];
3344
+ const choice = await modal(
3345
+ 'Session Tree',
3346
+ body => {
3347
+ const actionLabel = document.createElement('label');
3348
+ actionLabel.textContent = '操作';
3349
+ const action = document.createElement('select');
3350
+ action.innerHTML =
3351
+ '<option value="navigate">Navigate 到条目</option><option value="fork">从用户消息 Fork 新 Agent</option>';
3352
+ actionLabel.append(action);
3353
+ body.append(actionLabel);
3354
+ const list = document.createElement('div');
3355
+ list.className = 'modal-list';
3356
+ let selected = '';
3357
+ for (const e of entries) {
3358
+ const b = document.createElement('button');
3359
+ b.type = 'button';
3360
+ b.className = 'choice';
3361
+ const text = textContent(e.message?.content || e.content) || e.type;
3362
+ b.innerHTML = `${e.id === session.leafId ? '● ' : '○ '}${esc(text.slice(0, 100))}<small>${esc(e.id)}</small>`;
3363
+ b.onclick = () => {
3364
+ selected = e.id;
3365
+ list.querySelectorAll('button').forEach(x => x.classList.remove('selected'));
3366
+ b.classList.add('selected');
3367
+ };
3368
+ list.append(b);
3369
+ }
3370
+ body.append(list);
3371
+ return () => (selected ? {entryId: selected, action: action.value} : null);
3372
+ },
3373
+ '执行'
3374
+ );
3375
+ if (!choice) return;
3376
+ if (choice.action === 'fork') {
3377
+ const result = await postAgent(sourceAgent.agentId, '/fork', {entryId: choice.entryId});
3378
+ const forkedAgent = stampNewItem(sourceAgent.backendId, result.agent);
3379
+ const binding = autoBindNewSession(forkedAgent);
3380
+ await refreshAgents();
3381
+ await selectAgent(forkedAgent);
3382
+ if (binding) toast(`Fork Session 已自动绑定到按键 ${binding.slot + 1}`);
3383
+ } else {
3384
+ await navigateAgent(choice.entryId, sourceAgent);
3385
+ }
508
3386
  }
509
3387
  async function revert() {
510
- if(!state.agent)return toast('请选择 Agent');const session=await api(`/api/v1/agents/${state.agent.agentId}/session`);
511
- const messages=(session.entries||[]).filter(e=>e.type==='message'&&['user','assistant'].includes(e.message?.role));
512
- if(!messages.length)return toast('当前 Session 没有可回退的消息');
513
- const selected=await modal('Undo(Session Tree)',body=>{const note=document.createElement('p');note.className='muted';note.textContent='选择用户输入会回到输入前并恢复文字;选择 Agent 输出会保留该输出、移除后续上下文。文件改动不会撤销。';body.append(note);const list=document.createElement('div');list.className='modal-list';let value=null;for(const e of [...messages].reverse()){const role=e.message.role==='user'?'You':'Agent',text=textContent(e.message.content)||'(空消息)';const b=document.createElement('button');b.type='button';b.className='choice';b.innerHTML=`<b>${role}</b> ${esc(text.slice(0,160))}<small>${esc(e.id)}${e.id===session.leafId?' · 当前':''}</small>`;b.onclick=()=>{value=e.id;list.querySelectorAll('button').forEach(x=>x.classList.remove('selected'));b.classList.add('selected');};list.append(b);}body.append(list);return()=>value;},'回退');
514
- if(selected)await navigateAgent(selected);
3388
+ if (!state.agent) return toast('请选择 Agent');
3389
+ const session = await requestAgent(state.agent.agentId, '/session');
3390
+ const messages = (session.entries || []).filter(
3391
+ e => e.type === 'message' && ['user', 'assistant'].includes(e.message?.role)
3392
+ );
3393
+ if (!messages.length) return toast('当前 Session 没有可回退的消息');
3394
+ const selected = await modal(
3395
+ 'Undo(Session Tree)',
3396
+ body => {
3397
+ const note = document.createElement('p');
3398
+ note.className = 'muted';
3399
+ note.textContent =
3400
+ '选择用户输入会回到输入前并恢复文字;选择 Agent 输出会保留该输出、移除后续上下文。文件改动不会撤销。';
3401
+ body.append(note);
3402
+ const list = document.createElement('div');
3403
+ list.className = 'modal-list';
3404
+ let value = null;
3405
+ for (const e of [...messages].reverse()) {
3406
+ const role = e.message.role === 'user' ? 'You' : 'Agent',
3407
+ text = textContent(e.message.content) || '(空消息)';
3408
+ const b = document.createElement('button');
3409
+ b.type = 'button';
3410
+ b.className = 'choice';
3411
+ b.innerHTML = `<b>${role}</b> ${esc(text.slice(0, 160))}<small>${esc(e.id)}${e.id === session.leafId ? ' · 当前' : ''}</small>`;
3412
+ b.onclick = () => {
3413
+ value = e.id;
3414
+ list.querySelectorAll('button').forEach(x => x.classList.remove('selected'));
3415
+ b.classList.add('selected');
3416
+ };
3417
+ list.append(b);
3418
+ }
3419
+ body.append(list);
3420
+ return () => value;
3421
+ },
3422
+ '回退'
3423
+ );
3424
+ if (selected) await navigateAgent(selected);
515
3425
  }
516
3426
  async function modelControls() {
517
- if(!state.agent)return toast('请选择 Agent');const cap=await api(`/api/v1/agents/${state.agent.agentId}/capabilities`);
518
- const result=await modal('模型与 Thinking',body=>{const ml=document.createElement('label');ml.textContent='模型';const m=document.createElement('select');for(const x of cap.models||[]){const o=document.createElement('option');o.value=JSON.stringify([x.provider,x.id]);o.textContent=`${x.provider} / ${x.name||x.id}`;o.selected=x.provider===cap.model?.provider&&x.id===cap.model?.id;m.append(o);}ml.append(m);body.append(ml);const tl=document.createElement('label');tl.textContent='Thinking level';const t=document.createElement('select');for(const x of cap.thinkingLevels||[]){const o=document.createElement('option');o.value=x;o.textContent=x;o.selected=x===cap.thinkingLevel;t.append(o);}tl.append(t);body.append(tl);return()=>({model:m.value?JSON.parse(m.value):null,thinking:t.value});});
519
- if(result){if(result.model)await post(`/api/v1/agents/${state.agent.agentId}/model`,{provider:result.model[0],modelId:result.model[1]});if(result.thinking)await post(`/api/v1/agents/${state.agent.agentId}/thinking`,{level:result.thinking});toast('设置已更新');}
520
- }
521
-
522
- $('pairCancel').onclick=()=>$('pairDialog').close();
523
- $('modalCancel').onclick=()=>$('modal').close('cancel');
524
- $('pairForm').onsubmit=async event=>{event.preventDefault();$('pairSubmit').disabled=true;try{await connect($('pairBase').value,$('pairToken').value);}catch(e){$('status').className='bad';$('status').textContent='连接失败';toast(e.message);}finally{$('pairSubmit').disabled=false;}};
525
- $('openConfig').onclick=openConfig;$('configClose').onclick=()=>$('configDialog').close();$('configSave').onclick=saveMailSettings;$('connect').onclick=openPair;$('notifications').onclick=enableNotifications;$('voicePlayback').onclick=toggleVoicePlayback;$('voiceInput').onclick=toggleVoiceInput;$('api').onchange=()=>{state.base=$('api').value.replace(/\/$/,'');localStorage.rpBase=state.base;openPair();};
526
- $('refreshWs').onclick=()=>refreshWs().catch(e=>toast(e.message));$('addWs').onclick=async()=>{if(!requireConnection())return;const result=await modal('添加 Workspace',body=>{const n=field(body,'名称');const p=field(body,'主机绝对路径');return()=>({label:n.value.trim(),rootPath:p.value.trim()});},'添加');if(result?.label&&result.rootPath){await post('/api/v1/workspaces',result);await refreshWs();}};
527
- $('workspaces').onchange=selectWorkspace;$('agentPageSize').value=String(state.agentPageSize);$('agentPageSize').onchange=()=>{state.agentPageSize=Number($('agentPageSize').value)||10;state.agentVisibleCount=state.agentPageSize;localStorage.rpAgentPageSize=String(state.agentPageSize);renderAgentList();};$('loadMoreAgents').onclick=()=>{state.agentVisibleCount+=state.agentPageSize;renderAgentList();};$('treeRoot').onclick=()=>openDirectory('.');$('treeUp').onclick=()=>openDirectory(parentPath(state.treePath));$('mentionCurrent').onclick=()=>insertMention(state.treePath);$('terminalCurrent').onclick=()=>openTerminal(state.treePath);
528
- $('treePath').oncontextmenu=e=>showContextMenu(e,{relativePath:state.treePath,type:'directory'});enableLongPressMenu($('treePath'),e=>showContextMenu(e,{relativePath:state.treePath,type:'directory'}));$('filePrev').onclick=()=>openFile(state.filePath,Math.max(0,state.fileOffset-state.fileLimit),false);$('fileNext').onclick=()=>openFile(state.filePath,state.fileOffset+state.fileLimit,false);
529
- $('menuStartAgent').onclick=()=>startAgent(state.contextTarget.relativePath);$('menuOpenTerminal').onclick=()=>openTerminal(state.contextTarget.relativePath);$('menuSetCwd').onclick=()=>{$('agentCwd').value=state.contextTarget.relativePath;$('contextMenu').hidden=true;};$('menuMention').onclick=()=>{insertMention(state.contextTarget.relativePath);$('contextMenu').hidden=true;};$('menuCloseTerminal').onclick=()=>{const terminal=agentContextTarget?.kind==='terminal'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(terminal&&confirm('关闭这个 Terminal?正在运行的进程会被终止。'))void closeTerminal(terminal);};$('menuArchiveAgent').onclick=async()=>{const agent=agentContextTarget?.kind==='agent'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(!agent||!confirm('Archive 这个 Agent?Pi Session 文件不会被修改。'))return;try{await post(`/api/v1/agents/${agent.agentId}/archive`);if(state.agent?.agentId===agent.agentId){state.agent=null;localStorage.removeItem('rpAgentId');$('agentTitle').textContent='请选择 Agent';$('agentStatus').textContent='';$('agentStateBadge').textContent='未选择';$('agentStateBadge').className='state-badge state-none';$('contextUsage').hidden=true;$('messages').innerHTML='<div class="empty">选择或创建 Agent 开始对话</div>'}await refreshAgents();toast('Agent 已 Archive')}catch(e){toast(e.message)}};document.addEventListener('click',e=>{if(!$('contextMenu').contains(e.target))$('contextMenu').hidden=true;if(!$('agentContextMenu').contains(e.target))$('agentContextMenu').hidden=true;});
530
- $('newAgent').onclick=createAgent;$('newTerminal').onclick=()=>openTerminal($('agentCwd').value.trim()||'.');$('abort').onclick=()=>state.agent&&post(`/api/v1/agents/${state.agent.agentId}/abort`).catch(e=>toast(e.message));$('stop').onclick=async()=>{if(state.agent&&confirm('停止这个 Agent?之后再次使用时会按需启动。')){await api(`/api/v1/agents/${state.agent.agentId}`,{method:'DELETE'});setAgentStatus(state.agent.agentId,'unloaded');}};
531
- $('sessionName').onclick=async()=>{if(!state.agent)return;const result=await modal('Session 名称',body=>{const n=field(body,'名称',state.agent.sessionName||'');return()=>n.value.trim();});if(result){const s=await post(`/api/v1/agents/${state.agent.agentId}/session-name`,{name:result});state.agent.sessionName=s.sessionName||result;updateAgentHeader();await refreshAgents();}};
532
- $('sessionTree').onclick=()=>showSessionTree().catch(e=>toast(e.message));$('undo').onclick=()=>revert().catch(e=>toast(e.message));$('controls').onclick=()=>modelControls().catch(e=>toast(e.message));$('compact').onclick=async()=>{if(!state.agent)return;const instructions=await modal('Compact',body=>{const n=field(body,'可选指令');return()=>n.value;},'开始');if(instructions!==null){await post(`/api/v1/agents/${state.agent.agentId}/compact`,{instructions:instructions||undefined});toast('Compact 完成');}};
533
- $('prompt').onsubmit=async event=>{event.preventDefault();if(!state.agent)return toast('请先创建或选择 Agent');const text=$('input').value.trim();if(!text)return;const mode=$('sendMode').value,agentId=state.agent.agentId,sequenceBefore=localStorage[`rpSeq:${agentId}`];$('messages').querySelector('.empty')?.remove();addCard(null,text,'user');$('input').value='';try{await post(`/api/v1/agents/${agentId}/${mode}`,{message:text});if(mode==='prompt'&&state.agent?.agentId===agentId&&localStorage[`rpSeq:${agentId}`]===sequenceBefore){await loadMessagePage(agentId,undefined,true);state.streams.clear();}}catch(e){addCard('发送失败',e.message,'error',true);}};
534
- $('input').onkeydown=event=>{if(!$('mentionPicker').hidden){if(event.key==='ArrowDown'||event.key==='ArrowUp'){event.preventDefault();setMentionSelection(state.mentionIndex+(event.key==='ArrowDown'?1:-1));return;}if(event.key==='Tab'||event.key==='Enter'){event.preventDefault();chooseMention(state.mentionFiltered[state.mentionIndex],event.key==='Enter');return;}if(event.key==='Escape'){event.preventDefault();closeMention();return;}}if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){$('prompt').requestSubmit();}};$('input').oninput=()=>{const context=mentionContext();if(!context){if(!$('mentionPicker').hidden)closeMention();return;}const fresh=$('mentionPicker').hidden||state.mentionStart!==context.start;state.mentionStart=context.start;state.mentionEnd=context.replacementEnd;if(fresh){state.mentionPrefix='';void openMention(state.treePath,context.query);return;}const filter=state.mentionPrefix&&context.query.startsWith(state.mentionPrefix)?context.query.slice(state.mentionPrefix.length):context.query;renderMentionItems(filter);};
535
- $('mentionUp').onclick=()=>browseMention(parentPath(state.mentionPath));$('mentionClose').onclick=closeMention;
536
- $('workspaceBack').onclick=()=>mobileBack('home');$('fileBack').onclick=()=>mobileBack('workspace');$('agentBack').onclick=()=>mobileBack('home');$('terminalBack').onclick=()=>mobileBack('home');$('terminalClear').onclick=()=>state.terminalEmulator?.clear();$('terminalClose').onclick=()=>{if(state.terminal&&confirm('关闭这个 Terminal?正在运行的进程会被终止。'))void closeTerminal();};
537
- $('mobileAgentActions').onclick=()=>$('mobileAgentActions').closest('.toolbar').classList.toggle('actions-open');
538
- document.querySelectorAll('.agent-action').forEach(button=>button.addEventListener('click',()=>button.closest('.toolbar').classList.remove('actions-open')));
539
- window.addEventListener('popstate',event=>setMobileView(event.state?.rpView||'home'));
540
- mobileMedia.addEventListener?.('change',event=>{if(event.matches)setMobileView('home',{replace:true});});
541
- window.addEventListener('beforeunload',()=>{state.manuallyClosed=true;state.ws?.close();state.terminalWs?.close();state.mediaRecorder?.state==='recording'&&state.mediaRecorder.stop();stopVoiceAudio();});
542
- window.addEventListener('focus',updateNotificationButton);
543
- setMobileView('home', {replace:true}); updateConfigUi(); openPair();
3427
+ if (!state.agent) return toast('请选择 Agent');
3428
+ const cap = await requestAgent(state.agent.agentId, '/capabilities');
3429
+ const result = await modal('模型与 Thinking', body => {
3430
+ const ml = document.createElement('label');
3431
+ ml.textContent = '模型';
3432
+ const m = document.createElement('select');
3433
+ for (const x of cap.models || []) {
3434
+ const o = document.createElement('option');
3435
+ o.value = JSON.stringify([x.provider, x.id]);
3436
+ o.textContent = `${x.provider} / ${x.name || x.id}`;
3437
+ o.selected = x.provider === cap.model?.provider && x.id === cap.model?.id;
3438
+ m.append(o);
3439
+ }
3440
+ ml.append(m);
3441
+ body.append(ml);
3442
+ const tl = document.createElement('label');
3443
+ tl.textContent = 'Thinking level';
3444
+ const t = document.createElement('select');
3445
+ for (const x of cap.thinkingLevels || []) {
3446
+ const o = document.createElement('option');
3447
+ o.value = x;
3448
+ o.textContent = x;
3449
+ o.selected = x === cap.thinkingLevel;
3450
+ t.append(o);
3451
+ }
3452
+ tl.append(t);
3453
+ body.append(tl);
3454
+ return () => ({model: m.value ? JSON.parse(m.value) : null, thinking: t.value});
3455
+ });
3456
+ if (result) {
3457
+ if (result.model)
3458
+ await postAgent(state.agent.agentId, '/model', {provider: result.model[0], modelId: result.model[1]});
3459
+ if (result.thinking) await postAgent(state.agent.agentId, '/thinking', {level: result.thinking});
3460
+ toast('设置已更新');
3461
+ }
3462
+ }
3463
+
3464
+ /** MCP servers Pi reads from mcp.json. Toggling writes the config; a session picks it up when it loads. */
3465
+ async function mcpControls() {
3466
+ if (!state.agent) return toast('请选择 Agent');
3467
+ const agentId = state.agent.agentId;
3468
+ const render = (overview, list) => {
3469
+ list.replaceChildren();
3470
+ if (!overview.servers.length) {
3471
+ const empty = document.createElement('p');
3472
+ empty.className = 'muted';
3473
+ empty.textContent = '没有配置 MCP 服务器。在 mcp.json 中添加后会显示在这里。';
3474
+ list.append(empty);
3475
+ }
3476
+ for (const server of overview.servers) {
3477
+ const row = document.createElement('div');
3478
+ row.className = `mcp-row${server.enabled ? '' : ' mcp-off'}`;
3479
+ const info = document.createElement('div');
3480
+ info.className = 'mcp-info';
3481
+ const name = document.createElement('b');
3482
+ name.textContent = server.name;
3483
+ const detail = document.createElement('small');
3484
+ detail.className = 'muted';
3485
+ detail.textContent = `${server.transport} · ${server.exposure} · ${server.scope === 'project' ? '项目' : '全局'}${server.override ? '(项目覆盖)' : ''}\n${server.description || server.target}`;
3486
+ info.append(name, detail);
3487
+ const toggle = document.createElement('button');
3488
+ toggle.type = 'button';
3489
+ toggle.textContent = server.enabled ? '停用' : '启用';
3490
+ toggle.onclick = async () => {
3491
+ toggle.disabled = true;
3492
+ try {
3493
+ render(await postAgent(agentId, '/mcp', {name: server.name, enabled: !server.enabled}), list);
3494
+ toast('已保存,重新加载 Agent 后生效');
3495
+ } catch (error) {
3496
+ toast(`修改失败:${error.message}`);
3497
+ toggle.disabled = false;
3498
+ }
3499
+ };
3500
+ row.append(info, toggle);
3501
+ list.append(row);
3502
+ }
3503
+ for (const error of overview.errors || []) {
3504
+ const line = document.createElement('p');
3505
+ line.className = 'mcp-error';
3506
+ line.textContent = error;
3507
+ list.append(line);
3508
+ }
3509
+ if (!overview.projectTrusted) {
3510
+ const note = document.createElement('p');
3511
+ note.className = 'muted';
3512
+ note.textContent = '项目未被信任,因此忽略 .pi/mcp.json,修改会写入全局配置。';
3513
+ list.append(note);
3514
+ }
3515
+ };
3516
+ const overview = await requestAgent(agentId, '/mcp');
3517
+ await modal(
3518
+ 'MCP 服务器',
3519
+ body => {
3520
+ const list = document.createElement('div');
3521
+ list.className = 'mcp-list';
3522
+ render(overview, list);
3523
+ body.append(list);
3524
+ return null;
3525
+ },
3526
+ '关闭'
3527
+ );
3528
+ }
3529
+
3530
+ $('pairCancel').onclick = () => $('pairDialog').close();
3531
+ $('modalCancel').onclick = () => $('modal').close('cancel');
3532
+ $('pairForm').onsubmit = async event => {
3533
+ event.preventDefault();
3534
+ $('pairSubmit').disabled = true;
3535
+ try {
3536
+ const base = $('pairBase').value.trim().replace(/\/$/, '');
3537
+ const token = $('pairToken').value;
3538
+ let name = $('pairName').value.trim();
3539
+ if (!name) {
3540
+ try {
3541
+ name = new URL(base).hostname;
3542
+ } catch {}
3543
+ }
3544
+ const editingId = $('pairDialog').dataset.backendId;
3545
+ const existing = backendOf(editingId) || backendRegistry.findByBase(base);
3546
+ const entry = {
3547
+ id: existing?.id || createBackendId(),
3548
+ name,
3549
+ base,
3550
+ token,
3551
+ saved: existing?.saved || false
3552
+ };
3553
+ await connectBackend(entry, {confirmSave: true});
3554
+ $('pairDialog').close();
3555
+ } catch (error) {
3556
+ updateHeaderStatus();
3557
+ toast(error.message);
3558
+ } finally {
3559
+ $('pairSubmit').disabled = false;
3560
+ }
3561
+ };
3562
+ $('openConfig').onclick = openConfig;
3563
+ $('configClose').onclick = () => $('configDialog').close();
3564
+ $('configSave').onclick = saveMailSettings;
3565
+ $('connect').onclick = () => openPair();
3566
+ $('keyboardConnect').onclick = async () => {
3567
+ try {
3568
+ $('keyboardConnect').disabled = true;
3569
+ await sessionKeyboard.connect();
3570
+ } catch (error) {
3571
+ toast(`键盘连接失败:${error.message}`);
3572
+ } finally {
3573
+ $('keyboardConnect').disabled = false;
3574
+ }
3575
+ };
3576
+ $('keyboardDisconnect').onclick = () =>
3577
+ sessionKeyboard.disconnect().catch(error => toast(`键盘断开失败:${error.message}`));
3578
+ $('notifications').onclick = enableNotifications;
3579
+ $('voicePlayback').onclick = toggleVoicePlayback;
3580
+ $('voiceInput').onclick = toggleVoiceInput;
3581
+ $('disconnectAll').onclick = () => void disconnectAll();
3582
+ $('backendFilter').onchange = () => {
3583
+ state.backendFilter = $('backendFilter').value;
3584
+ if (state.backendFilter) localStorage.rpBackendFilter = state.backendFilter;
3585
+ else localStorage.removeItem('rpBackendFilter');
3586
+ renderWorkspaces().catch(() => {});
3587
+ renderAgentList();
3588
+ };
3589
+ $('refreshWs').onclick = () => refreshWs().catch(e => toast(e.message));
3590
+ $('addWs').onclick = async () => {
3591
+ if (!requireConnection()) return;
3592
+ const result = await modal(
3593
+ '添加 Workspace',
3594
+ body => {
3595
+ const n = field(body, '名称');
3596
+ const p = field(body, '主机绝对路径');
3597
+ return () => ({label: n.value.trim(), rootPath: p.value.trim()});
3598
+ },
3599
+ '添加'
3600
+ );
3601
+ if (result?.label && result.rootPath) {
3602
+ const target = state.backendFilter || state.workspace?.backendId || primaryBackend()?.id;
3603
+ if (!target) return toast('没有可用的 Backend');
3604
+ await postBackend(target, '/api/v1/workspaces', result);
3605
+ await refreshWs();
3606
+ }
3607
+ };
3608
+ $('workspaces').onchange = () => void selectWorkspace();
3609
+ $('agentPageSize').value = String(state.agentPageSize);
3610
+ $('agentPageSize').onchange = () => {
3611
+ state.agentPageSize = Number($('agentPageSize').value) || 10;
3612
+ state.agentVisibleCount = state.agentPageSize;
3613
+ localStorage.rpAgentPageSize = String(state.agentPageSize);
3614
+ renderAgentList();
3615
+ };
3616
+ $('loadMoreAgents').onclick = () => {
3617
+ state.agentVisibleCount += state.agentPageSize;
3618
+ renderAgentList();
3619
+ };
3620
+ $('treeRoot').onclick = () => openDirectory('.');
3621
+ $('treeUp').onclick = () => openDirectory(parentPath(state.treePath));
3622
+ $('mentionCurrent').onclick = () => insertMention(state.treePath);
3623
+ $('terminalCurrent').onclick = () => openTerminal(state.treePath);
3624
+ $('treePath').oncontextmenu = e => showContextMenu(e, {relativePath: state.treePath, type: 'directory'});
3625
+ enableLongPressMenu($('treePath'), e =>
3626
+ showContextMenu(e, {relativePath: state.treePath, type: 'directory'})
3627
+ );
3628
+ $('filePrev').onclick = () =>
3629
+ openFile(state.filePath, Math.max(0, state.fileOffset - state.fileLimit), false);
3630
+ $('fileNext').onclick = () => openFile(state.filePath, state.fileOffset + state.fileLimit, false);
3631
+ $('menuStartAgent').onclick = () => startAgent(state.contextTarget.relativePath);
3632
+ $('menuOpenTerminal').onclick = () => openTerminal(state.contextTarget.relativePath);
3633
+ $('menuSetCwd').onclick = () => {
3634
+ $('agentCwd').value = state.contextTarget.relativePath;
3635
+ $('contextMenu').hidden = true;
3636
+ };
3637
+ $('menuMention').onclick = () => {
3638
+ insertMention(state.contextTarget.relativePath);
3639
+ $('contextMenu').hidden = true;
3640
+ };
3641
+ $('menuBindKeyboard').onclick = () => {
3642
+ const agent = agentContextTarget?.kind === 'agent' ? agentContextTarget.item : null;
3643
+ $('agentContextMenu').hidden = true;
3644
+ if (agent) void bindKeyboard({agentId: agent.agentId}).catch(error => toast(error.message));
3645
+ };
3646
+ $('menuKeyboardColor').onclick = () => {
3647
+ const agent = agentContextTarget?.kind === 'agent' ? agentContextTarget.item : null;
3648
+ $('agentContextMenu').hidden = true;
3649
+ if (agent) void changeKeyboardColor(agent).catch(error => toast(error.message));
3650
+ };
3651
+ $('menuUnbindKeyboard').onclick = () => {
3652
+ const agent = agentContextTarget?.kind === 'agent' ? agentContextTarget.item : null;
3653
+ $('agentContextMenu').hidden = true;
3654
+ if (agent && sessionKeyboard.unbind(agent.agentId)) toast('已取消键盘按键绑定');
3655
+ };
3656
+ $('menuCloseTerminal').onclick = () => {
3657
+ const terminal = agentContextTarget?.kind === 'terminal' ? agentContextTarget.item : null;
3658
+ $('agentContextMenu').hidden = true;
3659
+ if (terminal && confirm('关闭这个 Terminal?正在运行的进程会被终止。')) void closeTerminal(terminal);
3660
+ };
3661
+ $('menuArchiveAgent').onclick = async () => {
3662
+ const agent = agentContextTarget?.kind === 'agent' ? agentContextTarget.item : null;
3663
+ $('agentContextMenu').hidden = true;
3664
+ if (!agent || !confirm('Archive 这个 Agent?Pi Session 文件不会被修改。')) return;
3665
+ try {
3666
+ await postAgent(agent.agentId, '/archive');
3667
+ 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
+ }
3678
+ await refreshAgents();
3679
+ toast('Agent 已 Archive');
3680
+ } catch (e) {
3681
+ toast(e.message);
3682
+ }
3683
+ };
3684
+ document.addEventListener('click', e => {
3685
+ if (!$('contextMenu').contains(e.target)) $('contextMenu').hidden = true;
3686
+ if (!$('agentContextMenu').contains(e.target)) $('agentContextMenu').hidden = true;
3687
+ });
3688
+ $('newAgent').onclick = createAgent;
3689
+ $('newTerminal').onclick = () => openTerminal($('agentCwd').value.trim() || '.');
3690
+ $('abort').onclick = () => {
3691
+ if (!state.agent) return;
3692
+ const id = state.agent.agentId;
3693
+ Promise.all([postAgent(id, '/abort'), postAgent(id, '/bash-abort')]).catch(e => toast(e.message));
3694
+ };
3695
+ $('stop').onclick = async () => {
3696
+ if (state.agent && confirm('停止这个 Agent?之后再次使用时会按需启动。')) {
3697
+ await requestAgent(state.agent.agentId, '', {method: 'DELETE'});
3698
+ setAgentStatus(state.agent.agentId, 'unloaded');
3699
+ }
3700
+ };
3701
+ $('sessionName').onclick = async () => {
3702
+ if (!state.agent) return;
3703
+ const result = await modal('Session 名称', body => {
3704
+ const n = field(body, '名称', state.agent.sessionName || '');
3705
+ return () => n.value.trim();
3706
+ });
3707
+ if (result) {
3708
+ const s = await postAgent(state.agent.agentId, '/session-name', {name: result});
3709
+ state.agent.sessionName = s.sessionName || result;
3710
+ updateAgentHeader();
3711
+ await refreshAgents();
3712
+ }
3713
+ };
3714
+ $('mcp').onclick = () => mcpControls().catch(e => toast(e.message));
3715
+ $('systemPrompt').onclick = () => showAgentSystemPrompt().catch(e => toast(e.message));
3716
+ $('openSystemPrompts').onclick = () => systemPromptManager().catch(e => toast(e.message));
3717
+ $('sessionTree').onclick = () => showSessionTree().catch(e => toast(e.message));
3718
+ $('undo').onclick = () => revert().catch(e => toast(e.message));
3719
+ $('controls').onclick = () => modelControls().catch(e => toast(e.message));
3720
+ $('compact').onclick = async () => {
3721
+ if (!state.agent) return;
3722
+ const instructions = await modal(
3723
+ 'Compact',
3724
+ body => {
3725
+ const n = field(body, '可选指令');
3726
+ return () => n.value;
3727
+ },
3728
+ '开始'
3729
+ );
3730
+ if (instructions !== null) {
3731
+ await postAgent(state.agent.agentId, '/compact', {instructions: instructions || undefined});
3732
+ toast('Compact 完成');
3733
+ }
3734
+ };
3735
+ $('prompt').onsubmit = async event => {
3736
+ event.preventDefault();
3737
+ if (!state.agent) return toast('请先创建或选择 Agent');
3738
+ const text = $('input').value.trim();
3739
+ if (!text) return;
3740
+ closeCommandPicker();
3741
+ const mode = $('sendMode').value,
3742
+ agentId = state.agent.agentId;
3743
+ $('messages').querySelector('.empty')?.remove();
3744
+ // `!cmd` runs inside the Agent session (output is recorded in the Session); `!!cmd` keeps the output out of the model context.
3745
+ if (text.startsWith('!')) {
3746
+ const excludeFromContext = text.startsWith('!!'),
3747
+ command = text.slice(excludeFromContext ? 2 : 1).trim();
3748
+ if (!command) return toast('请输入要执行的命令');
3749
+ state.optimistic.push(addCard(null, text, 'user'));
3750
+ $('input').value = '';
3751
+ try {
3752
+ await postAgent(agentId, '/bash', {command, excludeFromContext});
3753
+ } catch (e) {
3754
+ toast(`命令失败:${e.message}`);
3755
+ }
3756
+ return;
3757
+ }
3758
+ // A prompt shows at once; steering and follow-ups appear in the queue bar until the run takes them.
3759
+ if (mode === 'prompt') state.optimistic.push(addCard(null, text, 'user'));
3760
+ $('input').value = '';
3761
+ try {
3762
+ const sent = await postAgent(agentId, `/${mode}`, {message: text});
3763
+ if (mode !== 'prompt' && sent?.disposition)
3764
+ toast(sent.disposition === 'queued' ? '已排队,等待当前回合' : '已送达当前回合');
3765
+ } catch (e) {
3766
+ addCard('发送失败', e.message, 'error', true);
3767
+ }
3768
+ };
3769
+ $('input').onkeydown = event => {
3770
+ if (!$('commandPicker').hidden) {
3771
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3772
+ event.preventDefault();
3773
+ setCommandSelection(state.commandIndex + (event.key === 'ArrowDown' ? 1 : -1));
3774
+ return;
3775
+ }
3776
+ if (event.key === 'Tab' || (event.key === 'Enter' && !event.ctrlKey && !event.metaKey)) {
3777
+ event.preventDefault();
3778
+ chooseCommand(state.commandFiltered[state.commandIndex]);
3779
+ return;
3780
+ }
3781
+ if (event.key === 'Escape') {
3782
+ event.preventDefault();
3783
+ closeCommandPicker();
3784
+ return;
3785
+ }
3786
+ }
3787
+ if (!$('mentionPicker').hidden) {
3788
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
3789
+ event.preventDefault();
3790
+ setMentionSelection(state.mentionIndex + (event.key === 'ArrowDown' ? 1 : -1));
3791
+ return;
3792
+ }
3793
+ if (event.key === 'Tab' || event.key === 'Enter') {
3794
+ event.preventDefault();
3795
+ chooseMention(state.mentionFiltered[state.mentionIndex], event.key === 'Enter');
3796
+ return;
3797
+ }
3798
+ if (event.key === 'Escape') {
3799
+ event.preventDefault();
3800
+ closeMention();
3801
+ return;
3802
+ }
3803
+ }
3804
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
3805
+ $('prompt').requestSubmit();
3806
+ }
3807
+ };
3808
+ $('input').oninput = () => {
3809
+ const command = commandContext();
3810
+ if (command) {
3811
+ if (!$('mentionPicker').hidden) closeMention();
3812
+ void openCommandPicker(command.query);
3813
+ return;
3814
+ }
3815
+ if (!$('commandPicker').hidden) closeCommandPicker();
3816
+ const context = mentionContext();
3817
+ if (!context) {
3818
+ if (!$('mentionPicker').hidden) closeMention();
3819
+ return;
3820
+ }
3821
+ const fresh = $('mentionPicker').hidden || state.mentionStart !== context.start;
3822
+ state.mentionStart = context.start;
3823
+ state.mentionEnd = context.replacementEnd;
3824
+ if (fresh) {
3825
+ state.mentionPrefix = '';
3826
+ void openMention(state.treePath, context.query);
3827
+ return;
3828
+ }
3829
+ const filter =
3830
+ state.mentionPrefix && context.query.startsWith(state.mentionPrefix)
3831
+ ? context.query.slice(state.mentionPrefix.length)
3832
+ : context.query;
3833
+ renderMentionItems(filter);
3834
+ };
3835
+ // Clearing hands the queued messages back, so nothing typed is lost when a run is redirected.
3836
+ $('queueClear').onclick = async () => {
3837
+ if (!state.agent) return;
3838
+ const agentId = state.agent.agentId;
3839
+ try {
3840
+ // The server's queue_update event clears the banner; applying an empty queue here would hide a
3841
+ // message queued in the meantime.
3842
+ const cleared = await requestAgent(agentId, '/queue', {method: 'DELETE'});
3843
+ const restored = [...(cleared.steering || []), ...(cleared.followUp || [])].join('\n');
3844
+ // The composer is shared, so restoring into another Agent's input would send the text there.
3845
+ if (restored && state.agent?.agentId !== agentId)
3846
+ return toast('队列已清空,但已切换 Agent,内容未放回输入框');
3847
+ if (restored) {
3848
+ const input = $('input');
3849
+ input.value = input.value.trim() ? `${restored}\n${input.value}` : restored;
3850
+ input.focus();
3851
+ }
3852
+ toast(restored ? '队列已清空,内容已放回输入框' : '队列已清空');
3853
+ } catch (error) {
3854
+ toast(`清空队列失败:${error.message}`);
3855
+ }
3856
+ };
3857
+ $('commandClose').onclick = closeCommandPicker;
3858
+ $('mentionUp').onclick = () => browseMention(parentPath(state.mentionPath));
3859
+ $('mentionClose').onclick = closeMention;
3860
+ $('workspaceBack').onclick = () => mobileBack('home');
3861
+ $('fileBack').onclick = () => mobileBack('workspace');
3862
+ $('agentBack').onclick = () => mobileBack('home');
3863
+ $('terminalBack').onclick = () => mobileBack('home');
3864
+ $('terminalClear').onclick = () => state.terminalEmulator?.clear();
3865
+ $('terminalClose').onclick = () => {
3866
+ if (state.terminal && confirm('关闭这个 Terminal?正在运行的进程会被终止。')) void closeTerminal();
3867
+ };
3868
+ $('mobileAgentActions').onclick = () =>
3869
+ $('mobileAgentActions').closest('.toolbar').classList.toggle('actions-open');
3870
+ document
3871
+ .querySelectorAll('.agent-action')
3872
+ .forEach(button =>
3873
+ button.addEventListener('click', () => button.closest('.toolbar').classList.remove('actions-open'))
3874
+ );
3875
+ window.addEventListener('popstate', event => setMobileView(event.state?.rpView || 'home'));
3876
+ mobileMedia.addEventListener?.('change', event => {
3877
+ if (event.matches) setMobileView('home', {replace: true});
3878
+ });
3879
+ window.addEventListener('beforeunload', () => {
3880
+ for (const backend of state.backends) closeBackendSocket(backend);
3881
+ state.terminalWs?.close();
3882
+ cancelVoiceRecording();
3883
+ stopVoiceAudio();
3884
+ });
3885
+ window.addEventListener('focus', updateNotificationButton);
3886
+ setMobileView('home', {replace: true});
3887
+ updateConfigUi();
3888
+ updateKeyboardUi();
3889
+ renderBackendFilter();
3890
+ updateHeaderStatus();
3891
+ void sessionKeyboard.restoreAuthorized();
3892
+ // Backends whose pairing code was explicitly saved reconnect on their own, and so do codeless
3893
+ // backends because a --no-auth server needs none; the pairing dialog is only for first-time
3894
+ // or failed connections.
3895
+ void (async () => {
3896
+ const candidates = state.backends.filter(backend => backend.base && (backend.saved || !backend.token));
3897
+ if (!candidates.length) {
3898
+ openPair(state.backends[0] || null);
3899
+ return;
3900
+ }
3901
+ const results = await Promise.allSettled(
3902
+ candidates.map(backend => connectBackend(backend, {confirmSave: false, pair: false}))
3903
+ );
3904
+ if (!hasConnection()) {
3905
+ const error = results.find(result => result.status === 'rejected')?.reason;
3906
+ if (error) toast(`自动连接失败:${error.message}`);
3907
+ openPair(state.backends[0] || null);
3908
+ }
3909
+ })();
544
3910
  })();