pi2web 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -17
- package/dist/agents.js +126 -25
- package/dist/auth.js +83 -20
- package/dist/collab/dispatcher.js +186 -0
- package/dist/collab/hub.js +1880 -0
- package/dist/collab/pi-extension.js +237 -0
- package/dist/collab/review-flow.js +310 -0
- package/dist/collab/routes.js +214 -0
- package/dist/collab/schemas.js +225 -0
- package/dist/collab/scoring-flow.js +230 -0
- package/dist/collab/store.js +563 -0
- package/dist/collab/types.js +53 -0
- package/dist/collab/validate.js +148 -0
- package/dist/drafts.js +30 -0
- package/dist/mail-notifier.js +36 -8
- package/dist/main.js +83 -4
- package/dist/mcp.js +113 -0
- package/dist/metadata-store.js +12 -3
- package/dist/sdk-backend.js +82 -19
- package/dist/server.js +434 -58
- package/dist/session-namer.js +114 -0
- package/dist/voice.js +47 -9
- package/package.json +8 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +2 -0
- package/packages/protocol/websocket.schema.json +47 -6
- package/web/app.js +1187 -176
- package/web/backend-state.js +237 -0
- package/web/collab.html +95 -0
- package/web/collab.js +667 -0
- package/web/hid-keyboard.js +420 -0
- package/web/index.html +11 -9
- package/web/styles.css +59 -0
- package/ANDROID_APP_FEATURES.md +0 -256
package/web/app.js
CHANGED
|
@@ -1,27 +1,45 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
'use strict';
|
|
3
3
|
const $ = id => document.getElementById(id);
|
|
4
|
+
const {BackendRegistry, createBackendId, resourceKey, splitResourceKey, normalizePath, isInsidePath, matchWorkspace} = window.RemotePiBackendState;
|
|
5
|
+
const backendRegistry = new BackendRegistry(localStorage, location.origin);
|
|
6
|
+
// The registry owns backend connections and their resource caches. This object
|
|
7
|
+
// only keeps cross-backend UI selections and transient view state.
|
|
4
8
|
const state = {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
backends: backendRegistry.items,
|
|
10
|
+
backendFilter: localStorage.rpBackendFilter || '',
|
|
11
|
+
workspace: null,
|
|
12
|
+
treePath: '.',
|
|
13
|
+
filePath: null, fileOffset: 0, fileSize: 0, fileLimit: 64 * 1024,
|
|
14
|
+
agent: null, agents: [], terminals: [], terminal: null, selectedKind: 'agent',
|
|
15
|
+
terminalWs: null, terminalEmulator: null, terminalAssetsPromise: null, terminalConnectAttempt: 0, fitAddon: null, resizeObserver: null,
|
|
16
|
+
streams: new Map(), contextTarget: null,
|
|
17
|
+
mentionPath: '.', mentionStart: null, mentionEnd: null, mentionPrefix: '', mentionOptions: [], mentionFiltered: [], mentionIndex: 0, mentionRequest: 0,
|
|
18
|
+
extensionStatus: new Map(), widgets: new Map(), contexts: new Map(), mobileView: 'home',
|
|
19
|
+
queues: new Map(), toolCards: new Map(), commands: [], commandsAgentId: null, commandFiltered: [], commandIndex: 0, commandRequest: 0,
|
|
9
20
|
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
|
-
|
|
21
|
+
voiceEnabled: false, voiceSttEnabled: false, voicePlaybackEnabled: localStorage.rpVoicePlayback === 'true', voiceAudio: {context:null,nextTime:0,playbackId:null,backendId:null,sources:new Set(),decodeChain:Promise.resolve(),generation:0}, mediaRecorder:null, mediaChunks:[], mediaStream:null, mediaTimer:null, mediaAgentId:null,
|
|
22
|
+
mailBackendId: null, mailRequest: 0, mailNotificationsAvailable:false,
|
|
23
|
+
mailSettings:{enabled:false,aggregationDelaySeconds:0,includeResponse:true,includeSessionDetails:true,collabEscalations:true},
|
|
12
24
|
};
|
|
13
|
-
$('
|
|
14
|
-
$('
|
|
25
|
+
$('pairBase').value = state.backends[0]?.base || location.origin;
|
|
26
|
+
$('pairName').value = state.backends[0]?.name || '';
|
|
15
27
|
|
|
16
|
-
const clean =
|
|
28
|
+
const clean = normalizePath;
|
|
17
29
|
const joinPath = (base, name) => base === '.' ? name : `${base}/${name}`;
|
|
18
30
|
const parentPath = value => { const p = value.split('/'); p.pop(); return p.join('/') || '.'; };
|
|
19
31
|
const absolutePath = relative => clean(`${state.workspace.rootPath}/${relative === '.' ? '' : relative}`);
|
|
20
|
-
const isInside =
|
|
32
|
+
const isInside = isInsidePath;
|
|
33
|
+
/** A cwd only belongs to a Workspace of the same Backend: the same path on two machines is two directories. */
|
|
34
|
+
const ownsPath = (workspace, item) => !!workspace && workspace.backendId === item.backendId && isInside(clean(item.cwd), clean(workspace.rootPath));
|
|
21
35
|
const relativeTo = (file, dir) => file === dir ? '.' : file.slice(dir.length + 1);
|
|
22
36
|
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
23
37
|
const toast = text => { $('toast').textContent = text; $('toast').hidden = false; clearTimeout(toast.timer); toast.timer = setTimeout(() => $('toast').hidden = true, 3000); };
|
|
24
|
-
const
|
|
38
|
+
const backendOf = id => backendRegistry.find(id);
|
|
39
|
+
const connectedBackends = () => backendRegistry.connected();
|
|
40
|
+
const primaryBackend = () => backendRegistry.primary();
|
|
41
|
+
const hasConnection = () => connectedBackends().length > 0;
|
|
42
|
+
const requireConnection = () => { if (!hasConnection()) { openPair(state.backends[0] || null); return false; } return true; };
|
|
25
43
|
const mobileMedia = matchMedia('(max-width: 700px)');
|
|
26
44
|
const isMobile = () => mobileMedia.matches;
|
|
27
45
|
function setMobileView(view, {push = false, replace = false} = {}) {
|
|
@@ -38,82 +56,402 @@
|
|
|
38
56
|
const navigateMobile = view => { if (isMobile()) setMobileView(view, {push:true}); };
|
|
39
57
|
const mobileBack = fallback => { if (isMobile() && history.state?.rpView === state.mobileView && state.mobileView !== 'home') history.back(); else setMobileView(fallback, {replace:true}); };
|
|
40
58
|
|
|
41
|
-
async function request(base, token, url, options = {},
|
|
42
|
-
|
|
43
|
-
const headers = {Authorization: `Bearer ${token}`, ...(options.body ? {'Content-Type':'application/json'} : {}), ...options.headers};
|
|
59
|
+
async function request(base, token, url, options = {}, onUnauthorized) {
|
|
60
|
+
const headers = {...(token ? {Authorization: `Bearer ${token}`} : {}), ...(options.body ? {'Content-Type':'application/json'} : {}), ...options.headers};
|
|
44
61
|
const response = await fetch(base + url, {...options, headers});
|
|
45
62
|
const body = await response.json().catch(() => ({}));
|
|
46
63
|
if (!response.ok) {
|
|
47
|
-
if (
|
|
64
|
+
if (response.status === 401) onUnauthorized?.();
|
|
48
65
|
throw Error(body.error?.message || `HTTP ${response.status}`);
|
|
49
66
|
}
|
|
50
67
|
return body.data;
|
|
51
68
|
}
|
|
52
|
-
async function
|
|
53
|
-
|
|
69
|
+
async function backendRequiresAuth(base) {
|
|
70
|
+
try { return (await request(base, '', '/health')).authRequired !== false; } catch { return true; }
|
|
71
|
+
}
|
|
72
|
+
async function requestBackend(backendId, url, options = {}) {
|
|
73
|
+
const backend = backendOf(backendId);
|
|
74
|
+
if (!backend) throw Error('Backend 不存在');
|
|
75
|
+
if (backend.status !== 'connected') throw Error(`Backend「${backend.name || backendId}」未连接`);
|
|
76
|
+
return request(backend.base, backend.token, url, options, () => {
|
|
77
|
+
if (backend.status === 'connected') void deactivateBackend(backend, {status:'error', error:'配对码无效或已失效'});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const postBackend = (backendId, url, body = {}) => requestBackend(backendId, url, {method:'POST', body:JSON.stringify(body)});
|
|
81
|
+
const requestAgent = (agentId, suffix = '', options = {}) => {
|
|
82
|
+
const {backendId, remoteId} = splitResourceKey(agentId);
|
|
83
|
+
return requestBackend(backendId, `/api/v1/agents/${encodeURIComponent(remoteId)}${suffix}`, options);
|
|
84
|
+
};
|
|
85
|
+
const postAgent = (agentId, suffix = '', body = {}) => requestAgent(agentId, suffix, {method:'POST', body:JSON.stringify(body)});
|
|
54
86
|
|
|
55
|
-
function openPair() {
|
|
56
|
-
if($('
|
|
57
|
-
$('
|
|
58
|
-
$('
|
|
87
|
+
function openPair(backend = null) {
|
|
88
|
+
if ($('pairDialog').open) return;
|
|
89
|
+
if ($('configDialog').open) $('configDialog').close();
|
|
90
|
+
$('pairDialog').dataset.backendId = backend?.id || '';
|
|
91
|
+
$('pairName').value = backend?.name || '';
|
|
92
|
+
$('pairBase').value = backend?.base || location.origin;
|
|
93
|
+
$('pairToken').value = backend?.token || '';
|
|
59
94
|
if (!$('pairDialog').open) $('pairDialog').showModal();
|
|
60
95
|
}
|
|
61
|
-
|
|
62
|
-
|
|
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();
|
|
96
|
+
function persistConnections() {
|
|
97
|
+
backendRegistry.persist();
|
|
71
98
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
|
|
100
|
+
function updateHeaderStatus() {
|
|
101
|
+
const connected = connectedBackends();
|
|
102
|
+
const reconnecting = connected.some(backend => backend.reconnecting);
|
|
103
|
+
if (!connected.length) {
|
|
104
|
+
$('status').className = 'bad';
|
|
105
|
+
$('status').textContent = '未配对';
|
|
106
|
+
$('serverInfo').textContent = '';
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
$('status').className = 'ok';
|
|
110
|
+
$('status').textContent = state.backends.length > 1 ? `已连接 ${connected.length}/${state.backends.length}` : '已配对';
|
|
111
|
+
if (reconnecting) $('status').textContent += ' · 重连中';
|
|
112
|
+
$('serverInfo').textContent = connected.map(backend => backend.name).join(' · ');
|
|
76
113
|
}
|
|
77
114
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
115
|
+
function closeBackendSocket(backend) {
|
|
116
|
+
clearTimeout(backend.reconnectTimer);
|
|
117
|
+
backend.manuallyClosed = true;
|
|
118
|
+
backend.reconnecting = false;
|
|
119
|
+
backend.ws?.close();
|
|
120
|
+
backend.ws = null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function clearWorkspaceSelection(backendId) {
|
|
124
|
+
if (backendId && state.workspace?.backendId !== backendId) return;
|
|
125
|
+
state.workspace = null;
|
|
126
|
+
localStorage.removeItem('rpWorkspaceId');
|
|
127
|
+
$('filePanel').hidden = true;
|
|
128
|
+
$('tree').innerHTML = '<div class="muted">请添加 Workspace</div>';
|
|
129
|
+
$('workspaceHeading').textContent = 'Workspace';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cancelVoiceRecording(backendId) {
|
|
133
|
+
if (!state.mediaAgentId) return;
|
|
134
|
+
if (backendId && splitResourceKey(state.mediaAgentId).backendId !== backendId) return;
|
|
135
|
+
clearTimeout(state.mediaTimer);
|
|
136
|
+
if (state.mediaRecorder) {
|
|
137
|
+
state.mediaRecorder.onstop = null;
|
|
138
|
+
if (state.mediaRecorder.state === 'recording') state.mediaRecorder.stop();
|
|
139
|
+
}
|
|
140
|
+
state.mediaStream?.getTracks().forEach(track => track.stop());
|
|
141
|
+
state.mediaRecorder = null;
|
|
142
|
+
state.mediaStream = null;
|
|
143
|
+
state.mediaChunks = [];
|
|
144
|
+
state.mediaAgentId = null;
|
|
145
|
+
$('voiceInput').textContent = '🎙';
|
|
146
|
+
$('voiceInput').classList.remove('recording');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clearAgentSelection(backendId) {
|
|
150
|
+
cancelVoiceRecording(backendId);
|
|
151
|
+
if (backendId && state.agent?.backendId !== backendId) return;
|
|
152
|
+
state.agent = null;
|
|
153
|
+
localStorage.removeItem('rpAgentId');
|
|
154
|
+
$('agentTitle').textContent = '请选择 Agent';
|
|
155
|
+
$('agentStatus').textContent = '';
|
|
156
|
+
$('agentStateBadge').textContent = '未选择';
|
|
157
|
+
$('agentStateBadge').className = 'state-badge state-none';
|
|
158
|
+
$('contextUsage').hidden = true;
|
|
159
|
+
$('messages').innerHTML = '<div class="empty">选择或创建 Agent 开始对话</div>';
|
|
160
|
+
state.extensionStatus.clear();
|
|
161
|
+
state.widgets.clear();
|
|
162
|
+
renderExtensionStatus();
|
|
163
|
+
renderWidgets();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function clearTerminalSelection(backendId) {
|
|
167
|
+
if (backendId && state.terminal?.backendId !== backendId) return;
|
|
168
|
+
state.terminalConnectAttempt++;
|
|
169
|
+
state.terminalWs?.close();
|
|
170
|
+
state.terminalWs = null;
|
|
171
|
+
state.terminal = null;
|
|
172
|
+
localStorage.removeItem('rpTerminalId');
|
|
173
|
+
if (state.selectedKind === 'terminal') {
|
|
174
|
+
state.selectedKind = 'agent';
|
|
175
|
+
localStorage.rpSelectedKind = 'agent';
|
|
176
|
+
showAgentView();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function deactivateBackendState(backend, status, error = '') {
|
|
181
|
+
closeBackendSocket(backend);
|
|
182
|
+
if (state.voiceAudio.backendId === backend.id) stopVoiceAudio();
|
|
183
|
+
backendRegistry.deactivate(backend, status, error);
|
|
184
|
+
clearWorkspaceSelection(backend.id);
|
|
185
|
+
clearAgentSelection(backend.id);
|
|
186
|
+
clearTerminalSelection(backend.id);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function deactivateBackend(backend, {status = 'off', error = ''} = {}) {
|
|
190
|
+
deactivateBackendState(backend, status, error);
|
|
191
|
+
syncSessionLists();
|
|
192
|
+
updateVoiceAvailability();
|
|
193
|
+
updateHeaderStatus();
|
|
194
|
+
renderBackendList();
|
|
195
|
+
renderBackendFilter();
|
|
196
|
+
await renderWorkspaces();
|
|
197
|
+
await loadPrimaryMailSettings();
|
|
198
|
+
updateConfigUi();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function disconnectAll(reason = '已断开全部连接') {
|
|
202
|
+
for (const backend of state.backends) deactivateBackendState(backend, 'off');
|
|
203
|
+
clearWorkspaceSelection();
|
|
204
|
+
clearAgentSelection();
|
|
205
|
+
clearTerminalSelection();
|
|
206
|
+
syncSessionLists();
|
|
207
|
+
updateVoiceAvailability();
|
|
208
|
+
updateHeaderStatus();
|
|
209
|
+
renderBackendList();
|
|
210
|
+
renderBackendFilter();
|
|
211
|
+
await renderWorkspaces();
|
|
212
|
+
await loadPrimaryMailSettings();
|
|
213
|
+
updateConfigUi();
|
|
214
|
+
if (reason) {
|
|
215
|
+
$('status').className = 'bad';
|
|
216
|
+
$('status').textContent = reason;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function updateVoiceAvailability() {
|
|
221
|
+
const backends = connectedBackends();
|
|
222
|
+
state.voiceEnabled = backends.some(backend => backend.voiceEnabled);
|
|
223
|
+
state.voiceSttEnabled = backends.some(backend => backend.voiceSttEnabled);
|
|
224
|
+
$('voicePlayback').hidden = !state.voiceEnabled;
|
|
225
|
+
$('voiceInput').hidden = !state.voiceSttEnabled;
|
|
226
|
+
if (!state.voiceEnabled) stopVoiceAudio();
|
|
227
|
+
updateVoiceButton();
|
|
228
|
+
}
|
|
229
|
+
async function connectBackend(entry, {confirmSave = true, pair = true} = {}) {
|
|
230
|
+
const base = String(entry.base || '').trim().replace(/\/$/, '') || location.origin;
|
|
231
|
+
let token = String(entry.token || '').trim();
|
|
232
|
+
if (!token && await backendRequiresAuth(base)) {
|
|
233
|
+
if (pair) openPair(entry);
|
|
234
|
+
throw Error('请先输入配对码');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const statusInfo = await request(base, token, '/api/v1/system/status');
|
|
238
|
+
if (statusInfo.protocolVersion !== 1) {
|
|
239
|
+
throw Error(`不支持的协议版本 ${statusInfo.protocolVersion}(需要 1)`);
|
|
240
|
+
}
|
|
241
|
+
const authRequired = statusInfo.authRequired !== false;
|
|
242
|
+
if (!authRequired) token = '';
|
|
243
|
+
|
|
244
|
+
let backend = backendOf(entry.id);
|
|
245
|
+
if (!backend) {
|
|
246
|
+
backend = backendRegistry.add({
|
|
247
|
+
id: entry.id || createBackendId(),
|
|
248
|
+
name: '', base: '', token: '', saved: false,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
closeBackendSocket(backend);
|
|
253
|
+
Object.assign(backend, {
|
|
254
|
+
name: String(entry.name || '').trim() || base,
|
|
255
|
+
base,
|
|
256
|
+
token,
|
|
257
|
+
status: 'connected',
|
|
258
|
+
error: '',
|
|
259
|
+
statusInfo,
|
|
260
|
+
authRequired,
|
|
261
|
+
serverInfo: `v${statusInfo.version} · Pi ${statusInfo.piVersion}`,
|
|
262
|
+
reconnectAttempt: 0,
|
|
263
|
+
voiceEnabled: Boolean(statusInfo.voiceCapabilities?.tts ?? statusInfo.voiceEnabled),
|
|
264
|
+
voiceSttEnabled: Boolean(statusInfo.voiceCapabilities?.stt ?? statusInfo.voiceEnabled),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
await refreshBackendData(backend);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (backend.status !== 'error') await deactivateBackend(backend, {status:'error', error:error.message});
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (entry.saved) backend.saved = true;
|
|
275
|
+
if (confirmSave && authRequired && !backend.saved && confirm(`是否将「${backend.name}」的配对码保存到浏览器本地存储?\n\n请仅在可信设备上保存。`)) {
|
|
276
|
+
backend.saved = true;
|
|
277
|
+
}
|
|
278
|
+
persistConnections();
|
|
279
|
+
connectSocket(backend);
|
|
280
|
+
updateVoiceAvailability();
|
|
281
|
+
updateHeaderStatus();
|
|
282
|
+
renderBackendList();
|
|
283
|
+
renderBackendFilter();
|
|
284
|
+
await renderWorkspaces();
|
|
285
|
+
await restorePreviousSelection();
|
|
286
|
+
await loadPrimaryMailSettings();
|
|
287
|
+
updateConfigUi();
|
|
288
|
+
return backend;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function refreshBackendData(backend) {
|
|
292
|
+
const [workspaces, agents, terminals] = await Promise.all([
|
|
293
|
+
requestBackend(backend.id, '/api/v1/workspaces'),
|
|
294
|
+
requestBackend(backend.id, '/api/v1/agents'),
|
|
295
|
+
requestBackend(backend.id, '/api/v1/terminals'),
|
|
296
|
+
]);
|
|
297
|
+
if (backend.status !== 'connected') return;
|
|
298
|
+
backend.workspaces = workspaces.map(workspace => ({
|
|
299
|
+
...workspace,
|
|
300
|
+
backendId: backend.id,
|
|
301
|
+
backendName: backend.name,
|
|
302
|
+
}));
|
|
303
|
+
backend.agents = agents.map(agent => backendRegistry.stamp(backend, agent, 'agentId'));
|
|
304
|
+
backend.terminals = terminals.map(terminal => backendRegistry.stamp(backend, terminal, 'terminalId'));
|
|
305
|
+
syncSessionLists();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function refreshBackendWorkspaces(backend) {
|
|
309
|
+
const workspaces = await requestBackend(backend.id, '/api/v1/workspaces');
|
|
310
|
+
if (backend.status !== 'connected') return;
|
|
311
|
+
backend.workspaces = workspaces.map(workspace => ({
|
|
312
|
+
...workspace,
|
|
313
|
+
backendId: backend.id,
|
|
314
|
+
backendName: backend.name,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function restorePreviousSelection() {
|
|
319
|
+
if (state.agent || state.terminal) return;
|
|
320
|
+
const terminal = state.terminals.find(item => item.terminalId === localStorage.rpTerminalId);
|
|
321
|
+
const agent = state.agents.find(item => item.agentId === localStorage.rpAgentId);
|
|
322
|
+
if (localStorage.rpSelectedKind === 'terminal' && terminal) await selectTerminal(terminal, false);
|
|
323
|
+
else if (agent) await selectAgent(agent, false);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const visibleWorkspaces = () => backendRegistry.resources('workspaces', state.backendFilter);
|
|
327
|
+
const workspaceKey = workspace => resourceKey(workspace.backendId, workspace.id);
|
|
328
|
+
const workspaceLabel = workspace => connectedBackends().length > 1 ? `${workspace.backendName} · ${workspace.label}` : workspace.label;
|
|
329
|
+
async function renderWorkspaces() {
|
|
330
|
+
const workspaces = visibleWorkspaces();
|
|
331
|
+
$('workspaces').replaceChildren(...workspaces.map(workspace => {
|
|
332
|
+
const option = document.createElement('option');
|
|
333
|
+
option.value = workspaceKey(workspace);
|
|
334
|
+
option.textContent = `${workspaceLabel(workspace)} (${workspace.rootPath})`;
|
|
335
|
+
return option;
|
|
84
336
|
}));
|
|
85
337
|
renderMobileWorkspaces();
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await selectWorkspace();
|
|
338
|
+
if (workspaces.length) {
|
|
339
|
+
const wanted = state.workspace ? workspaceKey(state.workspace) : localStorage.rpWorkspaceId;
|
|
340
|
+
const values = workspaces.map(workspaceKey);
|
|
341
|
+
if (values.includes(wanted)) { $('workspaces').value = wanted; if (!state.workspace) await selectWorkspace(); }
|
|
342
|
+
else { $('workspaces').value = values[0]; await selectWorkspace(); }
|
|
90
343
|
} else {
|
|
91
|
-
|
|
344
|
+
clearWorkspaceSelection();
|
|
92
345
|
}
|
|
93
346
|
}
|
|
347
|
+
function renderBackendFilter() {
|
|
348
|
+
const select = $('backendFilter');
|
|
349
|
+
if (!select) return;
|
|
350
|
+
// Every saved Backend is listed, not only the connected ones: this also runs before the automatic
|
|
351
|
+
// connections start, where dropping the filter of a still disconnected Backend would discard the
|
|
352
|
+
// stored choice on every reload. Removing a Backend clears the filter where it is removed.
|
|
353
|
+
select.replaceChildren(...state.backends.map(backend => {
|
|
354
|
+
const option = document.createElement('option'); option.value = backend.id; option.textContent = backend.name || backend.base; return option;
|
|
355
|
+
}));
|
|
356
|
+
const all = document.createElement('option'); all.value = ''; all.textContent = '全部 Backend';
|
|
357
|
+
select.prepend(all);
|
|
358
|
+
select.value = state.backendFilter;
|
|
359
|
+
select.hidden = state.backends.length < 2;
|
|
360
|
+
}
|
|
361
|
+
function renderBackendList() {
|
|
362
|
+
const list = $('backendList');
|
|
363
|
+
if (!list) return;
|
|
364
|
+
list.replaceChildren(...state.backends.map(backend => {
|
|
365
|
+
const row = document.createElement('div'); row.className = 'backend-row';
|
|
366
|
+
const info = document.createElement('div'); info.className = 'backend-row-info';
|
|
367
|
+
const name = document.createElement('b'); name.textContent = backend.name || backend.base;
|
|
368
|
+
const detail = document.createElement('small');
|
|
369
|
+
const statusText = backend.status === 'connected' ? (backend.reconnecting ? '已连接 · 重连中' : `已连接 · ${backend.authRequired === false ? '无需认证 · ' : ''}${backend.serverInfo}`) : backend.status === 'error' ? `连接失败 · ${backend.error || ''}` : '未连接';
|
|
370
|
+
detail.textContent = `${backend.base} · ${statusText}`;
|
|
371
|
+
info.append(name, detail);
|
|
372
|
+
const actions = document.createElement('div'); actions.className = 'backend-row-actions';
|
|
373
|
+
const toggle = document.createElement('button'); toggle.type = 'button'; toggle.textContent = backend.status === 'connected' ? '断开' : '连接';
|
|
374
|
+
toggle.onclick = async () => {
|
|
375
|
+
toggle.disabled = true;
|
|
376
|
+
try {
|
|
377
|
+
if (backend.status === 'connected') {
|
|
378
|
+
await deactivateBackend(backend);
|
|
379
|
+
} else {
|
|
380
|
+
await connectBackend(backend, {confirmSave: false});
|
|
381
|
+
toast(`已连接 ${backend.name}`);
|
|
382
|
+
}
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (backend.status !== 'error') await deactivateBackend(backend, {status:'error', error:error.message});
|
|
385
|
+
toast(error.message);
|
|
386
|
+
} finally {
|
|
387
|
+
toggle.disabled = false;
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const edit = document.createElement('button'); edit.type = 'button'; edit.textContent = '修改';
|
|
391
|
+
edit.onclick = () => openPair(backend);
|
|
392
|
+
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'danger-action'; remove.textContent = '删除';
|
|
393
|
+
remove.onclick = async () => {
|
|
394
|
+
if (!confirm(`删除 backend「${backend.name}」?只影响这个页面,不会停止该机器上的服务。`)) return;
|
|
395
|
+
const agentIds = backend.agents.map(agent => agent.agentId);
|
|
396
|
+
deactivateBackendState(backend, 'off');
|
|
397
|
+
backendRegistry.remove(backend);
|
|
398
|
+
persistConnections();
|
|
399
|
+
for (const agentId of agentIds) sessionKeyboard.unbind(agentId);
|
|
400
|
+
if (state.backendFilter === backend.id) {
|
|
401
|
+
state.backendFilter = '';
|
|
402
|
+
localStorage.removeItem('rpBackendFilter');
|
|
403
|
+
}
|
|
404
|
+
syncSessionLists();
|
|
405
|
+
updateVoiceAvailability();
|
|
406
|
+
updateHeaderStatus();
|
|
407
|
+
renderBackendFilter();
|
|
408
|
+
await renderWorkspaces();
|
|
409
|
+
await loadPrimaryMailSettings();
|
|
410
|
+
updateConfigUi();
|
|
411
|
+
toast('已删除 backend');
|
|
412
|
+
};
|
|
413
|
+
actions.append(toggle, edit, remove); row.append(info, actions);
|
|
414
|
+
return row;
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function refreshWs() {
|
|
419
|
+
if (!requireConnection()) return;
|
|
420
|
+
await Promise.allSettled(connectedBackends().map(refreshBackendWorkspaces));
|
|
421
|
+
await renderWorkspaces();
|
|
422
|
+
}
|
|
94
423
|
function renderMobileWorkspaces() {
|
|
95
|
-
$('mobileWorkspaces').replaceChildren(...
|
|
424
|
+
$('mobileWorkspaces').replaceChildren(...visibleWorkspaces().map(workspace => {
|
|
96
425
|
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
|
|
98
|
-
button.onclick=async()=>{try{$('workspaces').value=workspace
|
|
426
|
+
const name=document.createElement('b'),path=document.createElement('small');name.textContent=workspaceLabel(workspace);path.textContent=workspace.rootPath;button.append(name,path);
|
|
427
|
+
button.onclick=async()=>{try{$('workspaces').value=workspaceKey(workspace);await selectWorkspace();navigateMobile('workspace');}catch(error){toast(error.message);}};return button;
|
|
99
428
|
}));
|
|
100
429
|
}
|
|
101
|
-
async function selectWorkspace() {
|
|
102
|
-
|
|
430
|
+
async function selectWorkspace(selectedKey = $('workspaces').value) {
|
|
431
|
+
const {backendId, remoteId} = splitResourceKey(selectedKey);
|
|
432
|
+
state.workspace = backendOf(backendId)?.workspaces.find(workspace => workspace.id === remoteId) || null;
|
|
103
433
|
if (!state.workspace) return;
|
|
104
|
-
|
|
105
|
-
|
|
434
|
+
// A Backend filter can hide the owning Workspace from the picker; the selection still has to follow it.
|
|
435
|
+
if ([...$('workspaces').options].some(option => option.value === selectedKey)) $('workspaces').value = selectedKey;
|
|
436
|
+
localStorage.rpWorkspaceId = selectedKey;
|
|
437
|
+
state.treePath = '.';
|
|
438
|
+
$('agentCwd').value = '.';
|
|
439
|
+
$('filePanel').hidden = true;
|
|
440
|
+
$('workspaceHeading').textContent = isMobile() && state.mobileView !== 'home' ? workspaceLabel(state.workspace) : 'Workspace';
|
|
106
441
|
await loadTree();
|
|
107
442
|
}
|
|
108
443
|
async function loadTree() {
|
|
109
|
-
|
|
110
|
-
|
|
444
|
+
const workspace = state.workspace;
|
|
445
|
+
const treePath = state.treePath;
|
|
446
|
+
if (!workspace) return;
|
|
447
|
+
$('treePath').textContent = absolutePath(treePath);
|
|
111
448
|
try {
|
|
112
|
-
const items = await
|
|
449
|
+
const items = await requestBackend(workspace.backendId, `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/tree?path=${encodeURIComponent(treePath)}`);
|
|
450
|
+
if (!state.workspace || workspaceKey(state.workspace) !== workspaceKey(workspace) || state.treePath !== treePath) return;
|
|
113
451
|
$('tree').replaceChildren(...items.map(item => {
|
|
114
452
|
const el = document.createElement('div'); el.className = `entry ${item.type === 'directory' ? 'dir' : 'file-entry'}`;
|
|
115
453
|
el.textContent = `${item.type === 'directory' ? '📁' : '📄'} ${item.name}`;
|
|
116
|
-
const relativePath = joinPath(
|
|
454
|
+
const relativePath = joinPath(treePath, item.name);
|
|
117
455
|
el.onclick = () => item.type === 'directory' ? openDirectory(relativePath) : openFile(relativePath, 0);
|
|
118
456
|
const contextTarget={relativePath,type:item.type};el.oncontextmenu=event=>showContextMenu(event,contextTarget);enableLongPressMenu(el,event=>showContextMenu(event,contextTarget));return el;
|
|
119
457
|
}));
|
|
@@ -121,8 +459,11 @@
|
|
|
121
459
|
}
|
|
122
460
|
async function openDirectory(relativePath) { state.treePath = relativePath; $('filePanel').hidden = true; await loadTree(); }
|
|
123
461
|
async function openFile(relativePath, offset, showView = true) {
|
|
462
|
+
const workspace = state.workspace;
|
|
463
|
+
if (!workspace) return;
|
|
124
464
|
try {
|
|
125
|
-
const file = await
|
|
465
|
+
const file = await requestBackend(workspace.backendId, `/api/v1/workspaces/${encodeURIComponent(workspace.id)}/file?path=${encodeURIComponent(relativePath)}&offset=${offset}&limit=${state.fileLimit}`);
|
|
466
|
+
if (!state.workspace || workspaceKey(state.workspace) !== workspaceKey(workspace)) return;
|
|
126
467
|
state.filePath = relativePath; state.fileOffset = file.offset; state.fileSize = file.size;
|
|
127
468
|
$('filePanel').hidden = false; $('fileName').textContent = relativePath;
|
|
128
469
|
$('file').textContent = file.binary ? `[二进制文件,${file.size} bytes,无法预览]` : file.content;
|
|
@@ -181,15 +522,106 @@
|
|
|
181
522
|
async function openMention(path=state.treePath,filter='') {
|
|
182
523
|
if(!state.workspace)return;const request=++state.mentionRequest;state.mentionPath=path;$('mentionPicker').hidden=false;$('mentionPath').textContent=absolutePath(path);
|
|
183
524
|
try {
|
|
184
|
-
const items=await
|
|
525
|
+
const items=await requestBackend(state.workspace.backendId, `/api/v1/workspaces/${encodeURIComponent(state.workspace.id)}/tree?path=${encodeURIComponent(path)}`);if(request!==state.mentionRequest)return;
|
|
185
526
|
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
527
|
} catch(error){if(request===state.mentionRequest)$('mentionItems').textContent=error.message;}
|
|
187
528
|
}
|
|
188
529
|
function closeMention(){state.mentionRequest++;state.mentionStart=null;state.mentionEnd=null;state.mentionPrefix='';$('mentionPicker').hidden=true;}
|
|
189
530
|
|
|
531
|
+
// Slash commands: prompt templates and skills are expanded by the Pi session itself
|
|
532
|
+
// (AgentSession.prompt() expands them by default), so the composer only has to help discover them.
|
|
533
|
+
function commandContext() {
|
|
534
|
+
const input=$('input'),end=input.selectionStart,before=input.value.slice(0,end);
|
|
535
|
+
const match=before.match(/^\/([^\s]*)$/);return match?{end,query:match[1]}:null;
|
|
536
|
+
}
|
|
537
|
+
async function loadCommands(agentId) {
|
|
538
|
+
if(state.commandsAgentId===agentId)return state.commands;
|
|
539
|
+
const commands=await requestAgent(agentId,'/commands');return Array.isArray(commands)?commands:[];
|
|
540
|
+
}
|
|
541
|
+
function setCommandSelection(index) {
|
|
542
|
+
const count=state.commandFiltered.length;if(!count){state.commandIndex=0;return;}state.commandIndex=(index+count)%count;
|
|
543
|
+
$('commandItems').querySelectorAll('.picker-item').forEach((element,i)=>element.classList.toggle('selected',i===state.commandIndex));
|
|
544
|
+
$('commandItems').querySelector('.picker-item.selected')?.scrollIntoView({block:'nearest'});
|
|
545
|
+
}
|
|
546
|
+
function chooseCommand(option) {
|
|
547
|
+
if(!option)return;const input=$('input'),context=commandContext();
|
|
548
|
+
input.setRangeText(`/${option.name} `,0,context?context.end:input.value.length,'end');closeCommandPicker();input.focus();
|
|
549
|
+
}
|
|
550
|
+
function renderCommandItems(query='') {
|
|
551
|
+
const filter=query.toLocaleLowerCase(),options=state.commands.filter(command=>!filter||command.name.toLocaleLowerCase().includes(filter));
|
|
552
|
+
state.commandFiltered=options;state.commandIndex=0;
|
|
553
|
+
if(!options.length){const empty=document.createElement('div');empty.className='picker-empty muted';empty.textContent=state.commands.length?'没有匹配的命令':'当前目录没有 prompt template 或 skill';$('commandItems').replaceChildren(empty);return;}
|
|
554
|
+
$('commandItems').replaceChildren(...options.map((option,index)=>{
|
|
555
|
+
const el=document.createElement('div');el.className=`picker-item${index===0?' selected':''}`;
|
|
556
|
+
const name=document.createElement('b');name.textContent=`/${option.name}${option.argumentHint?` ${option.argumentHint}`:''}`;
|
|
557
|
+
const description=document.createElement('small');description.className='muted';description.textContent=`${option.source==='skill'?'skill':'prompt'}${option.description?` · ${option.description}`:''}`;
|
|
558
|
+
el.append(name,description);el.onmouseenter=()=>setCommandSelection(index);el.onclick=()=>chooseCommand(option);return el;
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
async function openCommandPicker(query='') {
|
|
562
|
+
if(!state.agent)return;const agentId=state.agent.agentId,request=++state.commandRequest;$('commandPicker').hidden=false;
|
|
563
|
+
if(state.commandsAgentId!==agentId){state.commands=[];$('commandItems').textContent='正在加载命令…';}
|
|
564
|
+
try{const commands=await loadCommands(agentId);if(request!==state.commandRequest||state.agent?.agentId!==agentId||$('commandPicker').hidden)return;state.commands=commands;state.commandsAgentId=agentId;renderCommandItems(query);}
|
|
565
|
+
catch(error){if(request===state.commandRequest&&state.agent?.agentId===agentId){state.commands=[];$('commandItems').textContent='无法加载命令';console.error('Unable to load slash commands',error);}}
|
|
566
|
+
}
|
|
567
|
+
function closeCommandPicker(){state.commandRequest++;$('commandPicker').hidden=true;state.commandFiltered=[];state.commandIndex=0;}
|
|
568
|
+
|
|
190
569
|
function agentName(agent) { return agent.sessionName || agent.name; }
|
|
191
570
|
function agentLabel(agent) { return agentName(agent) || agent.cwd || agent.agentId; }
|
|
192
571
|
function agentSubtitle(agent) { return agentName(agent) ? agent.cwd : agent.agentId; }
|
|
572
|
+
function updateKeyboardUi(connection) {
|
|
573
|
+
const connected=connection?.connected??sessionKeyboard.connected,name=connection?.name||sessionKeyboard.device?.productName||'Codex Micro';
|
|
574
|
+
$('keyboardConnect').hidden=connected;$('keyboardDisconnect').hidden=!connected;
|
|
575
|
+
const unavailable=!window.isSecureContext?'需要 HTTPS 或 localhost':!navigator.hid?'当前浏览器不支持 WebHID':'';
|
|
576
|
+
$('keyboardConnect').disabled=Boolean(unavailable);$('keyboardConnect').title=unavailable;
|
|
577
|
+
$('keyboardStatus').textContent=connected?`已连接 ${name} · LLM 慢速呼吸,Tool 中速呼吸,Retry 快速呼吸,等待输入浅呼吸,空闲常亮`:unavailable||'未连接 · 绑定保存在当前浏览器';
|
|
578
|
+
renderKeyboardBindings();
|
|
579
|
+
}
|
|
580
|
+
function renderKeyboardBindings() {
|
|
581
|
+
const slots = $('keyboardSlots');
|
|
582
|
+
if (!slots) return;
|
|
583
|
+
const focusedId = slots.contains(document.activeElement) ? document.activeElement.id : '';
|
|
584
|
+
const agents = new Map(state.agents.map(agent => [agent.agentId, agent]));
|
|
585
|
+
const canBind = hasConnection() && state.agents.some(agent => !sessionKeyboard.getBinding(agent.agentId));
|
|
586
|
+
slots.replaceChildren(...Array.from({length:6}, (_, slot) => {
|
|
587
|
+
const binding = sessionKeyboard.getSlot(slot), agent = binding && agents.get(binding.agentId);
|
|
588
|
+
const el = document.createElement('div'); el.className = 'keyboard-slot';
|
|
589
|
+
el.style.setProperty('--key-color', binding?.color || '#586174');
|
|
590
|
+
const title = document.createElement('b'); title.textContent = `键 ${slot+1}${binding ? ' · '+binding.color : ''}`;
|
|
591
|
+
const detail = document.createElement('small'); detail.textContent = binding ? (agent ? agentLabel(agent) : 'Session 当前不可用') : '未绑定';
|
|
592
|
+
const actions = document.createElement('div'); actions.className = 'keyboard-slot-actions';
|
|
593
|
+
if (binding) {
|
|
594
|
+
const color = document.createElement('button'); color.type = 'button'; color.textContent = '颜色…';
|
|
595
|
+
color.id = `keyboardSlot${slot}Color`; color.setAttribute('aria-label', `更换按键 ${slot+1} 的颜色`);
|
|
596
|
+
color.onclick = () => changeKeyboardColor(binding).catch(error => toast(error.message));
|
|
597
|
+
actions.append(color);
|
|
598
|
+
}
|
|
599
|
+
const action = document.createElement('button'); action.type = 'button';
|
|
600
|
+
action.id = `keyboardSlot${slot}Binding`; action.textContent = binding ? '取消绑定' : '绑定 Session…';
|
|
601
|
+
action.setAttribute('aria-label', `${binding ? '取消' : '设置'}按键 ${slot+1} 的绑定`);
|
|
602
|
+
action.disabled = !binding && !canBind;
|
|
603
|
+
action.onclick = async () => {
|
|
604
|
+
try {
|
|
605
|
+
if (binding) { sessionKeyboard.unbind(binding.agentId); toast('已取消键盘按键绑定'); }
|
|
606
|
+
else await bindKeyboard({slot});
|
|
607
|
+
} catch (error) { toast(error.message); }
|
|
608
|
+
};
|
|
609
|
+
actions.append(action); el.append(title, detail, actions);
|
|
610
|
+
return el;
|
|
611
|
+
}));
|
|
612
|
+
if (focusedId) $(focusedId)?.focus();
|
|
613
|
+
}
|
|
614
|
+
const sessionKeyboard = new window.SessionKeyboardController({
|
|
615
|
+
onKey:binding=>{
|
|
616
|
+
const agent=state.agents.find(item=>item.agentId===binding.agentId);
|
|
617
|
+
if(!agent)return toast(`按键 ${binding.slot+1} 绑定的 Session 当前不可用`);
|
|
618
|
+
if(state.selectedKind==='agent'&&state.agent?.agentId===agent.agentId)return navigateMobile('agent');
|
|
619
|
+
selectAgent(agent).catch(error=>toast(error.message));
|
|
620
|
+
},
|
|
621
|
+
onStateChange:updateKeyboardUi,
|
|
622
|
+
onBindingsChange:()=>{renderAgentList();renderKeyboardBindings();},
|
|
623
|
+
onError:error=>{console.error('Session keyboard error',error);toast(`键盘:${error.message}`);}
|
|
624
|
+
});
|
|
193
625
|
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
626
|
const usageLevel = usage => Number(usage?.percent)>90?'usage-danger':Number(usage?.percent)>70?'usage-warning':'';
|
|
195
627
|
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)}`; };
|
|
@@ -197,31 +629,89 @@
|
|
|
197
629
|
const sessionUsage = session => ({...(session.contextUsage||{}),cost:session.stats?.cost});
|
|
198
630
|
function renderAgentList() {
|
|
199
631
|
const recent=(value,fallback)=>{const time=Date.parse(value||fallback||'');return Number.isFinite(time)?time:0;};
|
|
200
|
-
|
|
632
|
+
let 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));
|
|
633
|
+
if(state.backendFilter)allItems=allItems.filter(entry=>entry.item.backendId===state.backendFilter);
|
|
201
634
|
const items=allItems.slice(0,state.agentVisibleCount);
|
|
202
635
|
$('agents').replaceChildren(...items.map(({kind,item}) => {
|
|
203
636
|
const isAgent=kind==='agent',id=isAgent?item.agentId:item.terminalId,selected=state.selectedKind===kind&&(isAgent?state.agent?.agentId:state.terminal?.terminalId)===id;
|
|
204
637
|
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>`;}
|
|
638
|
+
if(isAgent){const usage=state.contexts.get(item.agentId),binding=sessionKeyboard.getBinding(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>`;if(binding){const badge=document.createElement('span');badge.className='key-binding';badge.style.setProperty('--key-color',binding.color);badge.textContent=`K${binding.slot+1}`;badge.title=`键盘按键 ${binding.slot+1} · ${binding.color}`;el.querySelector('.agent-top').append(badge);}}
|
|
206
639
|
else el.innerHTML=`<div class="agent-top"><span class="terminal-kind">>_</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>`;
|
|
640
|
+
if(connectedBackends().length>1){const tag=document.createElement('span');tag.className='backend-tag';tag.textContent=item.backendName;tag.title=`来自 backend ${item.backendName}`;el.querySelector('.agent-top').append(tag);}
|
|
207
641
|
el.onclick=()=>isAgent?selectAgent(item):selectTerminal(item);el.oncontextmenu=event=>showItemContextMenu(event,kind,item);return el;
|
|
208
642
|
}));
|
|
209
643
|
$('agentListCount').textContent=`已显示 ${items.length} / ${allItems.length}`;$('loadMoreAgents').hidden=items.length>=allItems.length;$('loadMoreAgents').disabled=items.length>=allItems.length;
|
|
210
644
|
}
|
|
211
|
-
async function loadAgentContexts(agents=state.agents) {
|
|
212
|
-
await Promise.allSettled(agents.map(async agent=>
|
|
213
|
-
|
|
645
|
+
async function loadAgentContexts(agents = state.agents) {
|
|
646
|
+
await Promise.allSettled(agents.map(async agent => {
|
|
647
|
+
const session = await requestAgent(agent.agentId, '/session?summary=true');
|
|
648
|
+
agent.sessionName = session.sessionName || undefined;
|
|
649
|
+
let lastMessageAt = 0;
|
|
650
|
+
for (const entry of session.entries || []) {
|
|
651
|
+
if (entry.type !== 'message') continue;
|
|
652
|
+
const time = Date.parse(entry.timestamp || '');
|
|
653
|
+
if (Number.isFinite(time)) lastMessageAt = Math.max(lastMessageAt, time);
|
|
654
|
+
}
|
|
655
|
+
if (lastMessageAt) agent.lastActiveAt = new Date(lastMessageAt).toISOString();
|
|
656
|
+
state.contexts.set(agent.agentId, sessionUsage(session));
|
|
657
|
+
}));
|
|
658
|
+
renderAgentList();
|
|
659
|
+
if (state.agent) updateAgentHeader();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function stampNewItem(backendId, item) {
|
|
663
|
+
const backend = backendOf(backendId);
|
|
664
|
+
const idProperty = item.terminalId ? 'terminalId' : 'agentId';
|
|
665
|
+
return backendRegistry.stamp(backend, item, idProperty);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function refreshBackendSessions(backend) {
|
|
669
|
+
const [agents, terminals] = await Promise.all([
|
|
670
|
+
requestBackend(backend.id, '/api/v1/agents'),
|
|
671
|
+
requestBackend(backend.id, '/api/v1/terminals'),
|
|
672
|
+
]);
|
|
673
|
+
if (backend.status !== 'connected') return;
|
|
674
|
+
backend.agents = agents.map(agent => backendRegistry.stamp(backend, agent, 'agentId'));
|
|
675
|
+
backend.terminals = terminals.map(terminal => backendRegistry.stamp(backend, terminal, 'terminalId'));
|
|
214
676
|
}
|
|
677
|
+
|
|
678
|
+
function syncSessionLists() {
|
|
679
|
+
state.agents = backendRegistry.resources('agents');
|
|
680
|
+
state.terminals = backendRegistry.resources('terminals');
|
|
681
|
+
if (state.agent) {
|
|
682
|
+
const current = state.agents.find(agent => agent.agentId === state.agent.agentId);
|
|
683
|
+
if (current) Object.assign(state.agent, current);
|
|
684
|
+
else clearAgentSelection(state.agent.backendId);
|
|
685
|
+
}
|
|
686
|
+
if (state.terminal) {
|
|
687
|
+
const current = state.terminals.find(terminal => terminal.terminalId === state.terminal.terminalId);
|
|
688
|
+
if (current) Object.assign(state.terminal, current);
|
|
689
|
+
else clearTerminalSelection(state.terminal.backendId);
|
|
690
|
+
}
|
|
691
|
+
sessionKeyboard.setAgents(state.agents);
|
|
692
|
+
renderAgentList();
|
|
693
|
+
renderKeyboardBindings();
|
|
694
|
+
}
|
|
695
|
+
|
|
215
696
|
async function refreshAgents(selectPrevious = false) {
|
|
216
|
-
if (!
|
|
217
|
-
|
|
218
|
-
|
|
697
|
+
if (!hasConnection()) return;
|
|
698
|
+
const previousAgentId = state.agent?.agentId || localStorage.rpAgentId;
|
|
699
|
+
const previousTerminalId = state.terminal?.terminalId || localStorage.rpTerminalId;
|
|
700
|
+
await Promise.allSettled(connectedBackends().map(refreshBackendSessions));
|
|
701
|
+
syncSessionLists();
|
|
702
|
+
|
|
219
703
|
renderAgentList();
|
|
220
|
-
if (selectPrevious)
|
|
221
|
-
|
|
704
|
+
if (!selectPrevious) return;
|
|
705
|
+
const terminal = state.terminals.find(item => item.terminalId === previousTerminalId);
|
|
706
|
+
const agent = state.agents.find(item => item.agentId === previousAgentId);
|
|
707
|
+
if (localStorage.rpSelectedKind === 'terminal' && terminal) await selectTerminal(terminal, false);
|
|
708
|
+
else if (agent) await selectAgent(agent, false);
|
|
222
709
|
}
|
|
223
710
|
function setAgentStatus(agentId,status) {
|
|
224
|
-
const agent=state.agents.find(x=>x.agentId===agentId);if(!agent){void refreshAgents();return null;}
|
|
711
|
+
const agent=state.agents.find(x=>x.agentId===agentId);if(!agent){void refreshAgents();return null;}
|
|
712
|
+
// Every event reports the status, so an unchanged one must not rebuild the list for each delta.
|
|
713
|
+
if(agent.status===status)return agent;
|
|
714
|
+
agent.status=status;if(state.agent?.agentId===agentId)state.agent.status=status;sessionKeyboard.updateAgent(agent);renderAgentList();updateAgentHeader();return agent;
|
|
225
715
|
}
|
|
226
716
|
function updateAgentHeader(agent = state.agent) {
|
|
227
717
|
if (!agent) return;
|
|
@@ -232,39 +722,122 @@
|
|
|
232
722
|
function showAgentView(){
|
|
233
723
|
$('agentToolbar').hidden=false;$('terminalToolbar').hidden=true;$('terminalView').hidden=true;$('widgets').hidden=false;$('messages').hidden=false;$('prompt').hidden=false;
|
|
234
724
|
}
|
|
235
|
-
|
|
236
|
-
if(
|
|
237
|
-
const
|
|
725
|
+
const loadTerminalStyle=()=>new Promise((resolve,reject)=>{
|
|
726
|
+
const id='xterm-styles',loaded=document.getElementById(id);if(loaded?.dataset.loaded==='true')return resolve();
|
|
727
|
+
const link=loaded||document.createElement('link');link.id=id;link.rel='stylesheet';link.href='/vendor/xterm.css';link.onload=()=>{link.dataset.loaded='true';resolve();};link.onerror=()=>{link.remove();reject(Error('无法加载 /vendor/xterm.css'));};if(!loaded)document.head.append(link);
|
|
728
|
+
});
|
|
729
|
+
const loadTerminalScript=(id,src)=>new Promise((resolve,reject)=>{
|
|
730
|
+
const loaded=document.getElementById(id);if(loaded?.dataset.loaded==='true')return resolve();
|
|
731
|
+
const script=loaded||document.createElement('script');script.id=id;script.src=src;script.async=true;script.onload=()=>{script.dataset.loaded='true';resolve();};script.onerror=()=>{script.remove();reject(Error(`无法加载 ${src}`));};if(!loaded)document.head.append(script);
|
|
732
|
+
});
|
|
733
|
+
function loadTerminalAssets(){
|
|
734
|
+
if(window.Terminal&&window.FitAddon?.FitAddon)return Promise.resolve();
|
|
735
|
+
if(!state.terminalAssetsPromise)state.terminalAssetsPromise=(async()=>{
|
|
736
|
+
await Promise.all([loadTerminalStyle(),loadTerminalScript('xterm-script','/vendor/xterm.js')]);
|
|
737
|
+
await loadTerminalScript('xterm-fit-script','/vendor/addon-fit.js');
|
|
738
|
+
if(typeof window.Terminal!=='function'||typeof window.FitAddon?.FitAddon!=='function')throw Error('xterm.js 初始化失败');
|
|
739
|
+
})().catch(error=>{state.terminalAssetsPromise=null;throw error;});
|
|
740
|
+
return state.terminalAssetsPromise;
|
|
741
|
+
}
|
|
742
|
+
async function ensureTerminalEmulator(){
|
|
743
|
+
if(state.terminalEmulator)return state.terminalEmulator;
|
|
744
|
+
await loadTerminalAssets();if(state.terminalEmulator)return state.terminalEmulator;
|
|
745
|
+
const term=new window.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 window.FitAddon.FitAddon();
|
|
238
746
|
term.loadAddon(fit);term.open($('terminal'));term.onData(data=>{if(state.terminalWs?.readyState===WebSocket.OPEN)state.terminalWs.send(JSON.stringify({type:'input',data}));});
|
|
239
747
|
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;
|
|
748
|
+
state.resizeObserver=new ResizeObserver(()=>{if(!$('terminalView').hidden)try{fit.fit();}catch{}});state.resizeObserver.observe($('terminalView'));state.terminalEmulator=term;state.fitAddon=fit;return term;
|
|
241
749
|
}
|
|
242
|
-
function connectTerminalSocket(terminal){
|
|
243
|
-
state.terminalWs?.close();
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
750
|
+
async function connectTerminalSocket(terminal){
|
|
751
|
+
state.terminalWs?.close();state.terminalWs=null;const terminalId=terminal.terminalId,attempt=++state.terminalConnectAttempt;$('terminalStatus').textContent=`${terminal.cwd} · 正在加载 Terminal…`;
|
|
752
|
+
try{
|
|
753
|
+
const term=await ensureTerminalEmulator();if(attempt!==state.terminalConnectAttempt||state.selectedKind!=='terminal'||state.terminal?.terminalId!==terminalId)return;term.reset();term.clear();$('terminalStatus').textContent=`${terminal.cwd} · 正在连接…`;
|
|
754
|
+
const terminalBackend=backendOf(terminal.backendId);if(!terminalBackend||terminalBackend.status!=='connected')throw Error(`Backend「${terminal.backendName||terminal.backendId}」未连接`);
|
|
755
|
+
const ws=new WebSocket(terminalBackend.base.replace(/^http/,'ws')+`/api/v1/terminals/${encodeURIComponent(terminal.remoteId)}/ws`,['access-token.'+terminalBackend.token]);state.terminalWs=ws;
|
|
756
|
+
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{}});};
|
|
757
|
+
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);}};
|
|
758
|
+
ws.onerror=()=>{};ws.onclose=()=>{if(ws===state.terminalWs&&terminal.status==='running')$('terminalStatus').textContent=`${terminal.cwd} · 连接已断开`;};
|
|
759
|
+
}catch(error){if(attempt===state.terminalConnectAttempt&&state.selectedKind==='terminal'&&state.terminal?.terminalId===terminalId){$('terminalStatus').textContent=`${terminal.cwd} · Terminal 加载失败`;toast(`Terminal 加载失败:${error.message}`);}}
|
|
248
760
|
}
|
|
249
761
|
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}`;}
|
|
762
|
+
/**
|
|
763
|
+
* Mentions, cwd defaults and "start agent here" all read the selected Workspace, so selecting a
|
|
764
|
+
* session on another Backend must move the file browser with it. Without this the browser keeps
|
|
765
|
+
* pointing at the previous machine and inserts its paths into prompts sent elsewhere.
|
|
766
|
+
*
|
|
767
|
+
* Never await this from a selection: the Workspace is committed synchronously and only the tree
|
|
768
|
+
* load is asynchronous, so waiting for it would let a slow tree restore the older selection.
|
|
769
|
+
*/
|
|
770
|
+
async function syncWorkspaceForSelection(item) {
|
|
771
|
+
const backend = backendOf(item.backendId);
|
|
772
|
+
if (!backend || backend.status !== 'connected') return;
|
|
773
|
+
if (ownsPath(state.workspace, item)) return;
|
|
774
|
+
const match = matchWorkspace(backend.workspaces, item.cwd)
|
|
775
|
+
|| (state.workspace?.backendId === item.backendId ? state.workspace : backend.workspaces[0]);
|
|
776
|
+
if (!match) { clearWorkspaceSelection(); return; }
|
|
777
|
+
if (state.workspace && workspaceKey(state.workspace) === workspaceKey(match)) return;
|
|
778
|
+
await selectWorkspace(workspaceKey(match));
|
|
779
|
+
}
|
|
250
780
|
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);
|
|
781
|
+
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');void syncWorkspaceForSelection(terminal);void connectTerminalSocket(terminal);
|
|
782
|
+
}
|
|
783
|
+
const autoBindNewSession=agent=>sessionKeyboard.autoBind(agent.agentId);
|
|
784
|
+
async function startAgent(relativePath) {
|
|
785
|
+
if (!state.workspace) return toast('请先选择 Workspace');
|
|
786
|
+
$('contextMenu').hidden = true;
|
|
787
|
+
try {
|
|
788
|
+
const backendId = state.workspace.backendId;
|
|
789
|
+
const created = await postBackend(backendId, '/api/v1/agents', {workspaceId:state.workspace.id, relativeCwd:relativePath});
|
|
790
|
+
const agent = stampNewItem(backendId, created);
|
|
791
|
+
const binding = autoBindNewSession(agent);
|
|
792
|
+
$('agentCwd').value = relativePath;
|
|
793
|
+
await refreshAgents();
|
|
794
|
+
await selectAgent(agent);
|
|
795
|
+
if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot+1}`);
|
|
796
|
+
} catch (error) {
|
|
797
|
+
toast(error.message);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
async function openTerminal(relativePath) {
|
|
801
|
+
if (!state.workspace) return toast('请先选择 Workspace');
|
|
802
|
+
try {
|
|
803
|
+
const backend = backendOf(state.workspace.backendId);
|
|
804
|
+
const created = await postBackend(backend.id, '/api/v1/terminals', {workspaceId:state.workspace.id, relativeCwd:relativePath});
|
|
805
|
+
const terminal = backendRegistry.stamp(backend, created, 'terminalId');
|
|
806
|
+
backend.terminals.unshift(terminal);
|
|
807
|
+
syncSessionLists();
|
|
808
|
+
$('contextMenu').hidden = true;
|
|
809
|
+
await selectTerminal(terminal);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
toast(error.message);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async function closeTerminal(terminal = state.terminal) {
|
|
815
|
+
if (!terminal) return;
|
|
816
|
+
try {
|
|
817
|
+
await requestBackend(terminal.backendId, `/api/v1/terminals/${encodeURIComponent(terminal.remoteId)}`, {method:'DELETE'});
|
|
818
|
+
const backend = backendOf(terminal.backendId);
|
|
819
|
+
if (backend) backend.terminals = backend.terminals.filter(item => item.terminalId !== terminal.terminalId);
|
|
820
|
+
if (state.terminal?.terminalId === terminal.terminalId) clearTerminalSelection(terminal.backendId);
|
|
821
|
+
syncSessionLists();
|
|
822
|
+
toast('Terminal 已关闭');
|
|
823
|
+
} catch (error) {
|
|
824
|
+
toast(error.message);
|
|
825
|
+
}
|
|
252
826
|
}
|
|
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);}}
|
|
256
827
|
async function selectAgent(agent, openView = true) {
|
|
257
828
|
state.selectedKind='agent';state.terminalWs?.close();state.terminalWs=null;state.agent = agent; localStorage.rpAgentId = agent.agentId;localStorage.rpSelectedKind='agent';showAgentView();updateAgentHeader();
|
|
258
829
|
if (openView) navigateMobile('agent');
|
|
259
|
-
|
|
260
|
-
$('
|
|
830
|
+
void syncWorkspaceForSelection(agent);
|
|
831
|
+
if (ownsPath(state.workspace, agent)) $('agentCwd').value = relativeTo(clean(agent.cwd), clean(state.workspace.rootPath));
|
|
832
|
+
$('messages').replaceChildren(); discardStreams();state.messagePageStart=0;state.messageTotal=0;closeCommandPicker();state.commands=[];state.commandsAgentId=null;renderQueueBar();
|
|
261
833
|
try { await loadMessagePage(agent.agentId,undefined,true); await loadSessionIdentity(); } catch (error) { addCard('加载消息失败', error.message, 'error', true); }
|
|
262
834
|
renderAgentList();
|
|
263
|
-
|
|
835
|
+
const backend=backendOf(agent.backendId);
|
|
836
|
+
if(backend){subscribeAgent(backend,agent);if(backend.ws?.readyState===WebSocket.OPEN)backend.ws.send(JSON.stringify({type:'subscribe_drafts'}));}
|
|
264
837
|
}
|
|
265
838
|
async function loadSessionIdentity() {
|
|
266
839
|
if (!state.agent) return;
|
|
267
|
-
const session = await
|
|
840
|
+
const session = await requestAgent(state.agent.agentId,'/session?summary=true');
|
|
268
841
|
state.agent.sessionName=session.sessionName||undefined;state.contexts.set(state.agent.agentId,sessionUsage(session));renderAgentList();updateAgentHeader();
|
|
269
842
|
}
|
|
270
843
|
|
|
@@ -273,29 +846,150 @@
|
|
|
273
846
|
if (!Array.isArray(content)) return '';
|
|
274
847
|
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');
|
|
275
848
|
}
|
|
849
|
+
// Pi persists failed provider responses as empty assistant messages with
|
|
850
|
+
// stopReason="error" and errorMessage. They have no content to render, so
|
|
851
|
+
// handle the error metadata explicitly instead of silently dropping them.
|
|
852
|
+
function assistantError(message) {
|
|
853
|
+
if(message?.role!=='assistant'||(message.stopReason!=='error'&&!message.errorMessage))return '';
|
|
854
|
+
const detail=String(message.errorMessage||'LLM request failed').trim();
|
|
855
|
+
const source=[message.provider,message.model].filter(Boolean).join(' · ');
|
|
856
|
+
return source?`${detail}\n\n${source}`:detail;
|
|
857
|
+
}
|
|
276
858
|
function renderCardBody(body, content, markdown = false) {
|
|
277
859
|
const value=String(content??'');body.classList.toggle('markdown',markdown);
|
|
278
860
|
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
861
|
else body.textContent=value;
|
|
280
862
|
}
|
|
281
|
-
|
|
863
|
+
// The transcript only shows what a human reads linearly: prompts, answers, thinking, dialogs and errors.
|
|
864
|
+
// Everything else (tool calls, bash output, retries, extension chatter) is folded into one collapsed
|
|
865
|
+
// activity group per stretch of activity, whose summary tracks the most recent item while it streams.
|
|
866
|
+
const TRANSCRIPT_KINDS = new Set(['user','assistant','thinking','dialog','voice','error']);
|
|
867
|
+
function activityGroup(target) {
|
|
868
|
+
const last=target.lastElementChild;
|
|
869
|
+
if (last?.classList?.contains('activity-group')) return last;
|
|
870
|
+
const group=document.createElement('details'); group.className='msg activity-group';
|
|
871
|
+
const summary=document.createElement('summary'),text=document.createElement('span'),count=document.createElement('span'),time=document.createElement('span');
|
|
872
|
+
text.className='summary-text';text.textContent='活动';count.className='activity-count';time.className='event-time';
|
|
873
|
+
summary.append(text,count,time);
|
|
874
|
+
const items=document.createElement('div');items.className='activity-items';
|
|
875
|
+
group.append(summary,items);target.append(group);return group;
|
|
876
|
+
}
|
|
877
|
+
const eventTime = timestamp => {const value=Number(timestamp);return Number.isFinite(value)&&value>0?new Date(value>1e12?value:value*1000).toLocaleTimeString():'';};
|
|
878
|
+
function updateActivityGroup(group, title, timestamp) {
|
|
879
|
+
const count=group.querySelector('.activity-items').childElementCount;
|
|
880
|
+
group.querySelector('.summary-text').textContent=title||'活动';
|
|
881
|
+
group.querySelector('.activity-count').textContent=`${count} 项`;
|
|
882
|
+
const time=eventTime(timestamp);if(time)group.querySelector('.event-time').textContent=time;
|
|
883
|
+
}
|
|
884
|
+
// `grouped` is false for cards that already live inside another card, such as nested tool calls.
|
|
885
|
+
function addCard(title, content, kind = 'system', open = false, timestamp, target = $('messages'), autoScroll = true, grouped = true) {
|
|
282
886
|
const derivedTitle=!title,details=document.createElement('details'); details.className = `msg ${kind}${derivedTitle?' derived-title':''}`; details.open = open;
|
|
283
887
|
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
|
-
|
|
888
|
+
const time=document.createElement('span'); time.className='event-time'; time.textContent=eventTime(timestamp); summary.append(time);
|
|
285
889
|
const body = document.createElement('div'); body.className = 'msg-content';renderCardBody(body,content,kind==='assistant');
|
|
286
|
-
details.append(summary, body);
|
|
890
|
+
details.append(summary, body);
|
|
891
|
+
const group=!grouped||TRANSCRIPT_KINDS.has(kind)?null:activityGroup(target);
|
|
892
|
+
(group?group.querySelector('.activity-items'):target).append(details);
|
|
893
|
+
if(group)updateActivityGroup(group,title||kind,timestamp);
|
|
894
|
+
if(autoScroll&&target===$('messages'))$('messages').scrollTop = $('messages').scrollHeight;
|
|
895
|
+
return {details, summary, summaryText, summaryTime:time, body, group, content:String(content??'')};
|
|
896
|
+
}
|
|
897
|
+
/** One card per tool call: `tool_execution_start` opens it, updates stream into it and `tool_execution_end` closes it. */
|
|
898
|
+
function upsertToolCard(ev, timestamp) {
|
|
899
|
+
const key=`tool:${ev.toolCallId||ev.toolName||'tool'}`,existing=state.toolCards.get(key);
|
|
900
|
+
const args=ev.args&&Object.keys(ev.args).length?JSON.stringify(ev.args,null,2):'';
|
|
901
|
+
// Pi 0.99+ runs tools from other tools (codemode scripts, MCP batches). Such calls carry
|
|
902
|
+
// `parentToolCallId`; show them inside the caller instead of as separate activity entries.
|
|
903
|
+
const parent=ev.parentToolCallId?state.toolCards.get(`tool:${ev.parentToolCallId}`):null;
|
|
904
|
+
const newCard=()=>parent?addCard(`🔧 ${ev.toolName||'Tool'}`,args,'tool',false,timestamp,nestedContainer(parent),false,false)
|
|
905
|
+
:addCard(`🔧 ${ev.toolName||'Tool'}`,args,'tool',false,timestamp);
|
|
906
|
+
if (ev.type==='tool_execution_start') {
|
|
907
|
+
// A replayed start can follow a restored pending card for the same call; keep that card so
|
|
908
|
+
// the end event finishes it rather than a duplicate.
|
|
909
|
+
if(existing){existing.summaryTime.textContent=eventTime(timestamp);return existing;}
|
|
910
|
+
const card=newCard();card.toolName=ev.toolName||'Tool';card.args=args;state.toolCards.set(key,card);return card;
|
|
911
|
+
}
|
|
912
|
+
const card=existing||newCard();
|
|
913
|
+
if(!existing){card.toolName=ev.toolName||'Tool';card.args=args;state.toolCards.set(key,card);}
|
|
914
|
+
if (ev.type==='tool_execution_update') {
|
|
915
|
+
const partial=textContent(ev.partialResult?.content??ev.partialResult)||(ev.partialResult?JSON.stringify(ev.partialResult):'');
|
|
916
|
+
if(partial)renderCardBody(card.body,`${card.args}\n\n— 输出 —\n${partial}`);
|
|
917
|
+
card.summaryTime.textContent=eventTime(timestamp);
|
|
918
|
+
if(card.group)updateActivityGroup(card.group,`🔧 ${card.toolName}`,timestamp);
|
|
919
|
+
return card;
|
|
920
|
+
}
|
|
921
|
+
const result=textContent(ev.result?.content??ev.result)||(ev.result&&!ev.result.content?JSON.stringify(ev.result,null,2):'')||(ev.isError?'执行失败':'执行完成');
|
|
922
|
+
card.details.classList.toggle('tool-error',!!ev.isError);
|
|
923
|
+
card.summaryText.textContent=`${ev.isError?'✗':'✓'} ${card.toolName}`;
|
|
924
|
+
setToolDuration(card,ev.durationMs);
|
|
925
|
+
renderCardBody(card.body,`${card.args}\n\n— 结果 —\n${result}`);
|
|
926
|
+
card.summaryTime.textContent=eventTime(timestamp);
|
|
927
|
+
if(card.group)updateActivityGroup(card.group,card.summaryText.textContent,timestamp);
|
|
928
|
+
state.toolCards.delete(key);
|
|
929
|
+
return card;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Nested calls live next to their caller's body, not inside it: the body is replaced whenever the
|
|
933
|
+
* caller streams output or finishes.
|
|
934
|
+
*/
|
|
935
|
+
function nestedContainer(card) {
|
|
936
|
+
let box=card.details.querySelector('.nested-tools');
|
|
937
|
+
if(!box){box=document.createElement('div');box.className='nested-tools';card.details.append(box);}
|
|
938
|
+
return box;
|
|
287
939
|
}
|
|
288
|
-
|
|
940
|
+
/** Pi 0.99+ reports the recorded execution time of a finished tool call. */
|
|
941
|
+
function setToolDuration(card, durationMs) {
|
|
942
|
+
const value=Number(durationMs);
|
|
943
|
+
if(!Number.isFinite(value)||value<0)return;
|
|
944
|
+
const label=card.durationLabel||(card.durationLabel=Object.assign(document.createElement('span'),{className:'tool-duration'}));
|
|
945
|
+
label.textContent=value<1000?`${Math.round(value)} ms`:value<60000?`${(value/1000).toFixed(1)} s`:`${Math.floor(value/60000)} 分 ${Math.round(value%60000/1000)} 秒`;
|
|
946
|
+
card.summaryTime.before(label);
|
|
947
|
+
}
|
|
948
|
+
/** A tool call that has no toolResult yet: the only visible sign that a long tool is still running. */
|
|
949
|
+
function addPendingToolCard(call, timestamp, target) {
|
|
950
|
+
const name=call.name||'Tool',args=call.arguments&&Object.keys(call.arguments).length?JSON.stringify(call.arguments,null,2):'';
|
|
951
|
+
const card=addCard(`⏳ ${name} · 执行中`,args,'tool',false,timestamp,target,false);
|
|
952
|
+
card.toolName=name;card.args=args;
|
|
953
|
+
// Keyed like upsertToolCard so the live tool_execution_update/end event finishes this very card.
|
|
954
|
+
if(call.id)state.toolCards.set(`tool:${call.id}`,card);
|
|
955
|
+
return card;
|
|
956
|
+
}
|
|
957
|
+
/** Session bash emits start, optional output deltas, and one completion event. */
|
|
958
|
+
function updateBashCard(ev, timestamp) {
|
|
959
|
+
const key=`bash:${ev.id||'default'}`;let card=state.toolCards.get(key);
|
|
960
|
+
if(!card){card=addCard(ev.command?`$ ${ev.command}`:'$ bash','','tool',false,timestamp);card.content='';state.toolCards.set(key,card);}
|
|
961
|
+
if(ev.type==='bash_execution_update')card.content+=String(ev.delta||'');
|
|
962
|
+
else if(ev.type==='bash_execution_end') {
|
|
963
|
+
card.content=String(ev.output??card.content);const failed=!!ev.isError||ev.cancelled||Number(ev.exitCode)>0;
|
|
964
|
+
const status=ev.isError?ev.errorMessage:ev.cancelled?'已取消':ev.exitCode===undefined?'完成':`exit ${ev.exitCode}${ev.truncated?' · 输出已截断':''}`;
|
|
965
|
+
card.details.classList.toggle('tool-error',failed);card.summaryText.textContent=`${failed?'✗':'✓'} ${ev.command?`$ ${ev.command}`:'bash'} · ${status}`;state.toolCards.delete(key);
|
|
966
|
+
}
|
|
967
|
+
renderCardBody(card.body,card.content);card.summaryTime.textContent=eventTime(timestamp);
|
|
968
|
+
if(card.group)updateActivityGroup(card.group,card.summaryText.textContent,timestamp);
|
|
969
|
+
}
|
|
970
|
+
// `pending` is only safe for the newest page: an older page can end mid tool call whose result
|
|
971
|
+
// lives in the next page, and that call is not actually running.
|
|
972
|
+
function renderMessages(messages, target = $('messages'), showEmpty = true, pending = false) {
|
|
289
973
|
if (!messages?.length) { if(showEmpty&&target===$('messages'))$('messages').innerHTML = '<div class="empty">尚无消息</div>'; return; }
|
|
974
|
+
const resolved = pending ? new Set(messages.filter(m=>m?.role==='toolResult'&&m.toolCallId).map(m=>m.toolCallId)) : null;
|
|
290
975
|
for (const message of messages) {
|
|
291
|
-
const role
|
|
292
|
-
if
|
|
976
|
+
const role=message.role||'event',timestamp=message.timestamp;
|
|
977
|
+
if(role==='bashExecution'){
|
|
978
|
+
const failed=message.cancelled||Number(message.exitCode)>0,status=message.cancelled?'已取消':message.exitCode===undefined?'完成':`exit ${message.exitCode}${message.truncated?' · 输出已截断':''}`;
|
|
979
|
+
const card=addCard(`${failed?'✗':'✓'} $ ${message.command} · ${status}`,message.output||'','tool',false,timestamp,target,false);card.details.classList.toggle('tool-error',!!failed);
|
|
980
|
+
} else if (Array.isArray(message.content) && role === 'assistant') {
|
|
293
981
|
for (const part of message.content) {
|
|
294
|
-
if (part.type === 'text' && part.text) addCard(null,part.text,'assistant',false,
|
|
295
|
-
else if (part.type === 'thinking' && (part.thinking||part.text)) addCard('Thinking',part.thinking||part.text,'thinking',false,
|
|
296
|
-
|
|
982
|
+
if (part.type === 'text' && part.text) addCard(null,part.text,'assistant',false,timestamp,target,false);
|
|
983
|
+
else if (part.type === 'thinking' && (part.thinking||part.text)) addCard('Thinking',part.thinking||part.text,'thinking',false,timestamp,target,false);
|
|
984
|
+
// Completed historical tools are represented by their toolResult message below; only a tool
|
|
985
|
+
// call still waiting for its result needs a card of its own.
|
|
986
|
+
else if (part.type === 'toolCall' && resolved && !resolved.has(part.id)) addPendingToolCard(part,timestamp,target);
|
|
297
987
|
}
|
|
298
|
-
|
|
988
|
+
const error=assistantError(message);if(error)addCard('LLM Error',error,'error',true,timestamp,target,false);
|
|
989
|
+
} else if(role==='toolResult'){
|
|
990
|
+
const failed=!!message.isError,content=textContent(message.content)||(failed?'执行失败':'执行完成');
|
|
991
|
+
const card=addCard(`${failed?'✗':'✓'} ${message.toolName||'Tool'}`,content,'tool',false,timestamp,target,false);card.details.classList.toggle('tool-error',failed);setToolDuration(card,message.durationMs);
|
|
992
|
+
} else { const content=textContent(message.content);if(content)addCard(['user','assistant'].includes(role)?null:role,content,role==='user'?'user':role==='assistant'?'assistant':'system',false,timestamp,target,false); }
|
|
299
993
|
}
|
|
300
994
|
if(target===$('messages'))$('messages').scrollTop=$('messages').scrollHeight;
|
|
301
995
|
}
|
|
@@ -307,42 +1001,82 @@
|
|
|
307
1001
|
}
|
|
308
1002
|
async function loadAllMessages(agentId,button) {
|
|
309
1003
|
button.disabled=true;button.textContent='正在加载全部对话…';
|
|
310
|
-
try { const messages=await
|
|
1004
|
+
try { const messages=await requestAgent(agentId,'/messages');if(state.agent?.agentId!==agentId)return;const box=$('messages');box.replaceChildren();state.toolCards.clear();renderMessages(messages,box,true,true);state.messagePageStart=0;state.messageTotal=messages.length;updateMessageHistoryControl(); }
|
|
311
1005
|
finally { button.disabled=false;button.textContent='加载全部对话'; }
|
|
312
1006
|
}
|
|
313
1007
|
async function loadMessagePage(agentId,before,reset) {
|
|
314
1008
|
const query=new URLSearchParams({limit:String(state.messagePageSize)});if(before!==undefined)query.set('before',String(before));
|
|
315
|
-
const page=await
|
|
316
|
-
const box=$('messages'),oldHeight=box.scrollHeight,oldTop=box.scrollTop,fragment=document.createDocumentFragment();
|
|
1009
|
+
const page=await requestAgent(agentId,`/messages?${query}`);if(state.agent?.agentId!==agentId)return;
|
|
1010
|
+
const box=$('messages'),oldHeight=box.scrollHeight,oldTop=box.scrollTop,fragment=document.createDocumentFragment();
|
|
1011
|
+
if(reset)state.toolCards.clear();renderMessages(page.items,fragment,false,!!reset);
|
|
317
1012
|
if(reset){box.replaceChildren(fragment);state.messageTotal=page.total;}else{box.querySelector('#messageHistoryControls')?.remove();box.prepend(fragment);}
|
|
318
1013
|
state.messagePageStart=page.start;state.messageTotal=page.total;updateMessageHistoryControl();
|
|
319
1014
|
if(reset)box.scrollTop=box.scrollHeight;else box.scrollTop=oldTop+(box.scrollHeight-oldHeight);
|
|
320
1015
|
if(!page.total)box.innerHTML='<div class="empty">尚无消息</div>';
|
|
321
1016
|
}
|
|
322
1017
|
function ensureStream(key, title, kind) {
|
|
323
|
-
if (!state.streams.has(key))
|
|
1018
|
+
if (!state.streams.has(key)) { const card=addCard(title,'',kind,true);card.kind=kind;card.renderFrame=null;state.streams.set(key,card); }
|
|
324
1019
|
return state.streams.get(key);
|
|
325
1020
|
}
|
|
1021
|
+
// Parsing and sanitizing the complete accumulated Markdown for every tiny
|
|
1022
|
+
// provider delta is O(n²) and can leave the browser minutes behind the agent.
|
|
1023
|
+
// Paint plain text at most once per animation frame and parse Markdown once
|
|
1024
|
+
// when the message finishes.
|
|
1025
|
+
function paintStream(card) {
|
|
1026
|
+
card.renderFrame=null;card.body.textContent=card.content;card.summaryText.textContent=(card.content.trim().split('\n')[0]||card.kind||'Assistant').slice(0,160);
|
|
1027
|
+
$('messages').scrollTop=$('messages').scrollHeight;
|
|
1028
|
+
}
|
|
1029
|
+
function scheduleStreamPaint(card) { if(card.renderFrame===null)card.renderFrame=requestAnimationFrame(()=>paintStream(card)); }
|
|
1030
|
+
function finalizeAgentStreams() {
|
|
1031
|
+
for(const key of ['assistant','thinking']){const card=state.streams.get(key);if(!card)continue;if(card.renderFrame!==null){cancelAnimationFrame(card.renderFrame);card.renderFrame=null;}renderCardBody(card.body,card.content,key==='assistant');card.summaryText.textContent=(card.content.trim().split('\n')[0]||key).slice(0,160);state.streams.delete(key);}
|
|
1032
|
+
}
|
|
1033
|
+
function discardStreams() { for(const card of state.streams.values())if(card.renderFrame!==null&&card.renderFrame!==undefined)cancelAnimationFrame(card.renderFrame);state.streams.clear();state.toolCards.clear(); }
|
|
326
1034
|
function extensionRequest(agentId, ev, timestamp) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
1035
|
+
if ($(`extension-request-${ev.requestId}`)) return;
|
|
1036
|
+
$('messages').querySelector('.empty')?.remove();
|
|
1037
|
+
const card = addCard(ev.title || `Extension ${ev.kind}`, ev.message || ev.placeholder || ev.prefill || '', 'dialog', true, timestamp);
|
|
1038
|
+
const controls = document.createElement('div'); controls.className = 'dialog-actions'; controls.id = `extension-request-${ev.requestId}`;
|
|
1039
|
+
const send = async value => { try { await postAgent(agentId,'/extension-response',{requestId:ev.requestId, value}); controls.replaceChildren(document.createTextNode('已响应')); } catch (e) { toast(e.message); } };
|
|
330
1040
|
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
1041
|
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
1042
|
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); }
|
|
333
1043
|
card.body.append(controls);
|
|
334
1044
|
}
|
|
1045
|
+
/** Steering and follow-up messages Pi has queued for the running turn. */
|
|
1046
|
+
function setAgentQueue(agentId, queue) {
|
|
1047
|
+
const value={steering:queue?.steering||[], followUp:queue?.followUp||[]};
|
|
1048
|
+
if(value.steering.length||value.followUp.length)state.queues.set(agentId,value); else state.queues.delete(agentId);
|
|
1049
|
+
if(state.selectedKind==='agent'&&state.agent?.agentId===agentId)renderQueueBar();
|
|
1050
|
+
}
|
|
1051
|
+
function renderQueueBar() {
|
|
1052
|
+
const queue=state.agent?state.queues.get(state.agent.agentId):null,bar=$('queueBar');
|
|
1053
|
+
if(!queue){bar.hidden=true;return;}
|
|
1054
|
+
const parts=[];
|
|
1055
|
+
if(queue.steering.length)parts.push(`Steer ${queue.steering.length}`);
|
|
1056
|
+
if(queue.followUp.length)parts.push(`Follow-up ${queue.followUp.length}`);
|
|
1057
|
+
const preview=[...queue.steering,...queue.followUp][0]||'';
|
|
1058
|
+
$('queueText').textContent=`排队中:${parts.join(' · ')}${preview?` — ${preview.replace(/\s+/g,' ').slice(0,60)}`:''}`;
|
|
1059
|
+
bar.hidden=false;
|
|
1060
|
+
}
|
|
1061
|
+
function restoreExtensionRequests(agentId, activity) {
|
|
1062
|
+
if (state.selectedKind !== 'agent' || state.agent?.agentId !== agentId || !activity?.dialogRequests) return;
|
|
1063
|
+
const requests = activity.dialogRequests, ids = new Set(requests.map(request => `extension-request-${request.requestId}`));
|
|
1064
|
+
for (const controls of $('messages').querySelectorAll('.dialog-actions')) {
|
|
1065
|
+
if (!ids.has(controls.id)) controls.replaceChildren(document.createTextNode('已结束'));
|
|
1066
|
+
}
|
|
1067
|
+
for (const request of requests) extensionRequest(agentId, request);
|
|
1068
|
+
}
|
|
335
1069
|
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;
|
|
1070
|
+
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;state.voiceAudio.backendId=null;
|
|
337
1071
|
}
|
|
338
1072
|
async function ensureAudioContext() {
|
|
339
1073
|
const AudioContext=window.AudioContext||window.webkitAudioContext;if(!AudioContext)throw Error('当前浏览器不支持音频播放');
|
|
340
1074
|
state.voiceAudio.context??=new AudioContext();if(state.voiceAudio.context.state==='suspended')await state.voiceAudio.context.resume();return state.voiceAudio.context;
|
|
341
1075
|
}
|
|
342
|
-
function playVoiceChunk(event) {
|
|
1076
|
+
function playVoiceChunk(event, backendId) {
|
|
343
1077
|
if(!state.voicePlaybackEnabled||!event.audio)return;const generation=state.voiceAudio.generation;
|
|
344
1078
|
state.voiceAudio.decodeChain=state.voiceAudio.decodeChain.then(async()=>{
|
|
345
|
-
const context=await ensureAudioContext();if(generation!==state.voiceAudio.generation)return;state.voiceAudio.playbackId=event.playbackId;
|
|
1079
|
+
const context=await ensureAudioContext();if(generation!==state.voiceAudio.generation)return;state.voiceAudio.playbackId=event.playbackId;state.voiceAudio.backendId=backendId;
|
|
346
1080
|
const raw=atob(event.audio),bytes=Uint8Array.from(raw,c=>c.charCodeAt(0));let buffer;
|
|
347
1081
|
if(event.encoding==='mp3')buffer=await context.decodeAudioData(bytes.buffer.slice(0));
|
|
348
1082
|
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);}
|
|
@@ -350,10 +1084,10 @@
|
|
|
350
1084
|
}).catch(error=>toast(`语音播放失败:${error.message}`));
|
|
351
1085
|
}
|
|
352
1086
|
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,'语音摘要','
|
|
355
|
-
else if(ev.type==='voice_summary_delta'){if(selected){const c=ensureStream(key,'语音摘要','
|
|
356
|
-
else if(ev.type==='voice_audio_chunk')playVoiceChunk(ev);
|
|
1087
|
+
const ev=message.event||{},selected=state.selectedKind==='agent'&&state.agent?.agentId===message.agentId,key=`voice-${resourceKey(message.backendId,ev.playbackId||message.agentId)}`;
|
|
1088
|
+
if(ev.type==='voice_start'){if(state.voicePlaybackEnabled)void ensureAudioContext().catch(()=>{});if(selected){const c=ensureStream(key,'语音摘要','voice');c.content='';renderCardBody(c.body,'正在生成语音摘要…',false);}}
|
|
1089
|
+
else if(ev.type==='voice_summary_delta'){if(selected){const c=ensureStream(key,'语音摘要','voice');c.content+=ev.text||'';renderCardBody(c.body,c.content,false);c.summaryText.textContent='语音摘要';}}
|
|
1090
|
+
else if(ev.type==='voice_audio_chunk')playVoiceChunk({...ev,playbackId:resourceKey(message.backendId,ev.playbackId)},message.backendId);
|
|
357
1091
|
else if(ev.type==='voice_cancelled'){stopVoiceAudio();}
|
|
358
1092
|
else if(ev.type==='voice_error'){if(selected)addCard('语音服务',ev.message||'语音生成失败','error',true);}
|
|
359
1093
|
else if(ev.type==='voice_end'){if(selected)state.streams.delete(key);}
|
|
@@ -372,9 +1106,27 @@
|
|
|
372
1106
|
}
|
|
373
1107
|
async function transcribeVoice(){
|
|
374
1108
|
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(
|
|
1109
|
+
if(!agentId){$('voiceInput').textContent='🎙';return;}const {backendId,remoteId}=splitResourceKey(agentId),transcribeBackend=backendOf(backendId);if(!transcribeBackend){$('voiceInput').textContent='🎙';return toast('该 Session 的 backend 已断开');}try{const response=await fetch(transcribeBackend.base+`/api/v1/agents/${encodeURIComponent(remoteId)}/transcribe`,{method:'POST',headers:{Authorization:`Bearer ${transcribeBackend.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='开始语音输入';}
|
|
1110
|
+
}
|
|
1111
|
+
const handledDrafts = new Set();
|
|
1112
|
+
function handleDraftEvent(message) {
|
|
1113
|
+
const draft = message.draft, backend = backendOf(message.backendId);
|
|
1114
|
+
if (!draft || typeof draft.text !== 'string' || !backend) return;
|
|
1115
|
+
const ack = () => { handledDrafts.add(draft.id); if (handledDrafts.size > 200) handledDrafts.delete(handledDrafts.values().next().value); if (backend.ws?.readyState === WebSocket.OPEN) backend.ws.send(JSON.stringify({type:'draft_ack', id:draft.id})); };
|
|
1116
|
+
if (handledDrafts.has(draft.id)) { ack(); return; }
|
|
1117
|
+
try {
|
|
1118
|
+
// The voice client cannot select a session: only the input currently shown on this backend is changed.
|
|
1119
|
+
if (state.selectedKind !== 'agent' || !state.agent || state.agent.backendId !== backend.id) return;
|
|
1120
|
+
const input = $('input');
|
|
1121
|
+
if (draft.mode === 'replace') input.value = draft.text;
|
|
1122
|
+
else input.value += (input.value && !/\s$/.test(input.value) ? ' ' : '') + draft.text;
|
|
1123
|
+
input.focus(); input.setSelectionRange(input.value.length,input.value.length); input.scrollTop = input.scrollHeight;
|
|
1124
|
+
toast('语音输入已写入输入框'); ack();
|
|
1125
|
+
} catch (error) { toast(`语音输入失败:${error.message}`); }
|
|
376
1126
|
}
|
|
377
1127
|
function handleAgentEvent(message) {
|
|
1128
|
+
if (message.type === 'draft_event') { handleDraftEvent(message); return; }
|
|
1129
|
+
if (message.type === 'subscribed_drafts') return;
|
|
378
1130
|
if(message.type==='voice_event'){handleVoiceEvent(message);return;}
|
|
379
1131
|
if (message.type === 'subscribed') {
|
|
380
1132
|
const key = `rpSeq:${message.agentId}`, saved = Number(localStorage[key] || 0), current = Number(message.currentSequence || 0);
|
|
@@ -383,48 +1135,91 @@
|
|
|
383
1135
|
if (saved > current) localStorage[key] = current;
|
|
384
1136
|
return;
|
|
385
1137
|
}
|
|
1138
|
+
if (message.type === 'agent_state') {
|
|
1139
|
+
const key = `rpSeq:${message.agentId}`, last = Number(localStorage[key] || 0);
|
|
1140
|
+
if (message.sequence < last) return;
|
|
1141
|
+
localStorage[key] = message.sequence;
|
|
1142
|
+
// This baseline follows replay. Keep recovered tool/LLM details while
|
|
1143
|
+
// replacing standalone operation IDs, without touching the transcript.
|
|
1144
|
+
sessionKeyboard.updateAgent({...message.state, agentId:message.agentId});
|
|
1145
|
+
setAgentStatus(message.agentId, message.state.status);
|
|
1146
|
+
setAgentQueue(message.agentId, message.state.activity?.queue);
|
|
1147
|
+
restoreExtensionRequests(message.agentId, message.state.activity);
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
386
1150
|
if (message.type === 'agent_snapshot') {
|
|
387
|
-
localStorage[`rpSeq:${message.agentId}`] = message.lastSequence;
|
|
1151
|
+
localStorage[`rpSeq:${message.agentId}`] = message.lastSequence;
|
|
1152
|
+
if (message.state) {
|
|
1153
|
+
sessionKeyboard.setAgentSnapshot({...message.state, agentId:message.agentId});
|
|
1154
|
+
setAgentStatus(message.agentId, message.state.status);
|
|
1155
|
+
setAgentQueue(message.agentId, message.state.activity?.queue);
|
|
1156
|
+
}
|
|
1157
|
+
if (state.agent?.agentId === message.agentId) {
|
|
1158
|
+
discardStreams(); $('messages').replaceChildren(); renderMessages(message.messages,$('messages'),true,true);
|
|
1159
|
+
state.messagePageStart = message.messagePage?.start || 0;
|
|
1160
|
+
state.messageTotal = message.messagePage?.total ?? message.messages?.length ?? 0;
|
|
1161
|
+
updateMessageHistoryControl();
|
|
1162
|
+
restoreExtensionRequests(message.agentId, message.state?.activity);
|
|
1163
|
+
}
|
|
1164
|
+
return;
|
|
388
1165
|
}
|
|
389
1166
|
if (message.type !== 'agent_event') return;
|
|
390
1167
|
const key = `rpSeq:${message.agentId}`, last = Number(localStorage[key] || 0); if (message.sequence <= last) return; localStorage[key] = message.sequence;
|
|
391
1168
|
const ev = message.event || {}, selected = state.selectedKind==='agent'&&state.agent?.agentId === message.agentId;
|
|
1169
|
+
sessionKeyboard.handleAgentEvent(message.agentId, ev);
|
|
392
1170
|
let eventAgent=state.agents.find(x=>x.agentId===message.agentId);
|
|
393
1171
|
if(eventAgent&&Number(message.timestamp)>0)eventAgent.lastActiveAt=new Date(Number(message.timestamp)*1000).toISOString();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1172
|
+
// The server sends the authoritative status with every event, including dialog waits. Backends
|
|
1173
|
+
// older than this field still report their lifecycle events, so those stand in for it.
|
|
1174
|
+
const liveStatus=message.status||(ev.type==='agent_start'||ev.type==='auto_retry_start'?'streaming':ev.type==='agent_end'?(ev.willRetry?'streaming':'idle'):ev.type==='agent_settled'?'idle':null);
|
|
1175
|
+
if (liveStatus) eventAgent=setAgentStatus(message.agentId, liveStatus)||eventAgent;
|
|
1176
|
+
if (ev.type === 'queue_update') { setAgentQueue(message.agentId, ev); return; }
|
|
1177
|
+
if (ev.type === 'agent_unloaded') {
|
|
1178
|
+
setAgentStatus(message.agentId, 'unloaded');
|
|
1179
|
+
setAgentQueue(message.agentId, null);
|
|
1180
|
+
restoreExtensionRequests(message.agentId, {dialogRequests:[]});
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (ev.type === 'agent_end') {
|
|
1184
|
+
if(!ev.willRetry){if(selected)finalizeAgentStreams();notifyComplete(eventAgent||{agentId:message.agentId},message.timestamp);if(eventAgent)void loadAgentContexts([eventAgent]);}
|
|
1185
|
+
} else if (ev.type === 'agent_settled') { if(eventAgent)void loadAgentContexts([eventAgent]); }
|
|
399
1186
|
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
1187
|
if (!selected) return;
|
|
401
1188
|
if (ev.type === 'agent_start') { $('messages').querySelector('.empty')?.remove(); }
|
|
402
1189
|
else if (ev.type === 'message_update') {
|
|
403
1190
|
const update = ev.assistantMessageEvent || {};
|
|
404
|
-
if (update.type === 'text_delta') { const c=ensureStream('assistant',null,'assistant');c.content+=update.delta||'';
|
|
405
|
-
else if (update.type === 'thinking_delta') {const c=ensureStream('thinking','Thinking','thinking');c.content+=update.delta||'';c
|
|
406
|
-
} else if (ev.type === '
|
|
407
|
-
else if (ev.type === '
|
|
408
|
-
else if (ev.type === '
|
|
1191
|
+
if (update.type === 'text_delta') { const c=ensureStream('assistant',null,'assistant');c.content+=update.delta||'';scheduleStreamPaint(c); }
|
|
1192
|
+
else if (update.type === 'thinking_delta') {const c=ensureStream('thinking','Thinking','thinking');c.content+=update.delta||'';scheduleStreamPaint(c);}
|
|
1193
|
+
} else if (ev.type === 'message_end') {finalizeAgentStreams();const error=assistantError(ev.message);if(error)addCard('LLM Error',error,'error',true,message.timestamp);}
|
|
1194
|
+
else if (ev.type === 'tool_execution_start' || ev.type === 'tool_execution_update' || ev.type === 'tool_execution_end') upsertToolCard(ev, message.timestamp);
|
|
1195
|
+
else if (ev.type === 'bash_execution_start' || ev.type === 'bash_execution_update' || ev.type === 'bash_execution_end') updateBashCard(ev, message.timestamp);
|
|
1196
|
+
else if (ev.type === 'auto_retry_start' || ev.type === 'auto_retry_end') addCard('Retry', ev.errorMessage || ev.finalError || `${ev.type}${ev.attempt?` · attempt ${ev.attempt}`:''}`, 'system', false, message.timestamp);
|
|
1197
|
+
else if (String(ev.type).startsWith('summarization_retry')) addCard('Summarization Retry', ev.errorMessage || `${ev.type}${ev.attempt?` · ${ev.attempt}/${ev.maxAttempts}`:''}`, 'system', false, message.timestamp);
|
|
409
1198
|
else if (ev.type === 'extension_ui_request') extensionRequest(message.agentId, ev, message.timestamp);
|
|
1199
|
+
else if (ev.type === 'extension_ui_response') $(`extension-request-${ev.requestId}`)?.replaceChildren(document.createTextNode('已结束'));
|
|
410
1200
|
else if (ev.type === 'extension_ui_notify') addCard(ev.notificationType || '通知', ev.message, 'system', false, message.timestamp);
|
|
411
1201
|
else if (ev.type === 'extension_ui_status') { ev.text ? state.extensionStatus.set(ev.key, ev.text) : state.extensionStatus.delete(ev.key); renderExtensionStatus(); }
|
|
412
1202
|
else if (ev.type === 'extension_ui_widget') { ev.content ? state.widgets.set(ev.key, ev.content) : state.widgets.delete(ev.key); renderWidgets(); }
|
|
413
1203
|
else if (ev.type === 'extension_ui_title') { state.agent.sessionName = ev.title; updateAgentHeader(); }
|
|
414
1204
|
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','
|
|
1205
|
+
else if (!['message_start','agent_end','agent_settled','session_info_changed','turn_start','turn_end','model_select','thinking_level_select','thinking_level_changed'].includes(ev.type)) addCard(ev.title || ev.type || 'Event', JSON.stringify(ev, null, 2), 'system', false, message.timestamp);
|
|
416
1206
|
$('messages').scrollTop = $('messages').scrollHeight;
|
|
417
1207
|
}
|
|
418
1208
|
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
1209
|
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
1210
|
|
|
421
|
-
function
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
1211
|
+
function subscribeAgent(backend, agent) {
|
|
1212
|
+
if (backend.ws?.readyState !== WebSocket.OPEN) return;
|
|
1213
|
+
const key = `rpSeq:${agent.agentId}`, cursor = localStorage[key];
|
|
1214
|
+
backend.ws.send(JSON.stringify(cursor === undefined ? {type:'subscribe', agentId:agent.remoteId, fromNow:true, messageLimit:state.messagePageSize} : {type:'subscribe', agentId:agent.remoteId, lastSequence:Number(cursor), messageLimit:state.messagePageSize}));
|
|
1215
|
+
}
|
|
1216
|
+
function connectSocket(backend) {
|
|
1217
|
+
if (!backend || backend.status !== 'connected') return;
|
|
1218
|
+
clearTimeout(backend.reconnectTimer); backend.manuallyClosed = true; const previous = backend.ws; backend.ws = null; previous?.close(); backend.manuallyClosed = false;
|
|
1219
|
+
const ws = new WebSocket(backend.base.replace(/^http/, 'ws') + '/api/v1/ws', ['access-token.' + backend.token]); backend.ws = ws;
|
|
1220
|
+
ws.onopen = () => { if (ws !== backend.ws) return; backend.reconnectAttempt = 0; backend.reconnecting = false; updateHeaderStatus(); for (const agent of backend.agents) subscribeAgent(backend, agent); ws.send(JSON.stringify({type:'subscribe_all', fromNow:true})); ws.send(JSON.stringify({type:'subscribe_drafts'})); };
|
|
1221
|
+
ws.onmessage = event => { if (ws !== backend.ws) return; try { const message = JSON.parse(event.data); if (typeof message.agentId === 'string') message.agentId = resourceKey(backend.id, message.agentId); message.backendId = backend.id; handleAgentEvent(message); } catch (error) { console.error(error); } };
|
|
1222
|
+
ws.onclose = () => { if (backend.ws !== ws || backend.manuallyClosed) return; backend.reconnecting = true; updateHeaderStatus(); const delays=[1000,2000,4000,8000,15000,30000], delay=delays[Math.min(backend.reconnectAttempt++, delays.length-1)]; backend.reconnectTimer = setTimeout(() => connectSocket(backend), delay); };
|
|
428
1223
|
ws.onerror = () => {};
|
|
429
1224
|
}
|
|
430
1225
|
function notificationUnavailableReason() {
|
|
@@ -458,27 +1253,96 @@
|
|
|
458
1253
|
const occurredAt=Number(timestamp)||Math.floor(Date.now()/1000);
|
|
459
1254
|
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); }
|
|
460
1255
|
}
|
|
461
|
-
async function
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
1256
|
+
async function loadPrimaryMailSettings() {
|
|
1257
|
+
const requestNumber = ++state.mailRequest;
|
|
1258
|
+
const backend = primaryBackend();
|
|
1259
|
+
state.mailBackendId = null;
|
|
1260
|
+
state.mailNotificationsAvailable = false;
|
|
1261
|
+
updateMailControls();
|
|
1262
|
+
if (!backend) return;
|
|
1263
|
+
|
|
1264
|
+
if (backend.statusInfo?.mailNotificationsConfigured === false) {
|
|
1265
|
+
state.mailBackendId = backend.id;
|
|
1266
|
+
updateMailControls();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
try {
|
|
1271
|
+
const result = await requestBackend(backend.id, '/api/v1/mail-notifications');
|
|
1272
|
+
if (requestNumber !== state.mailRequest || primaryBackend()?.id !== backend.id) return;
|
|
1273
|
+
state.mailBackendId = backend.id;
|
|
1274
|
+
state.mailNotificationsAvailable = Boolean(result.available);
|
|
1275
|
+
if (result.settings) state.mailSettings = {...state.mailSettings, ...result.settings};
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
if (requestNumber !== state.mailRequest) return;
|
|
1278
|
+
console.error('Unable to load mail notification settings', error);
|
|
1279
|
+
}
|
|
1280
|
+
updateMailControls();
|
|
465
1281
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
1282
|
+
|
|
1283
|
+
function updateMailControls() {
|
|
1284
|
+
const backend = backendOf(state.mailBackendId);
|
|
1285
|
+
const ownsForm = backend?.status === 'connected' && primaryBackend()?.id === backend.id;
|
|
1286
|
+
const available = ownsForm && state.mailNotificationsAvailable;
|
|
1287
|
+
const settings = state.mailSettings;
|
|
1288
|
+
$('mailNotificationEnabled').checked = Boolean(settings.enabled);
|
|
1289
|
+
$('mailAggregationDelay').value = String(settings.aggregationDelaySeconds ?? 0);
|
|
1290
|
+
$('mailIncludeResponse').checked = Boolean(settings.includeResponse);
|
|
1291
|
+
$('mailIncludeSessionDetails').checked = Boolean(settings.includeSessionDetails);
|
|
1292
|
+
$('mailCollabEscalations').checked = Boolean(settings.collabEscalations);
|
|
1293
|
+
for (const id of ['mailNotificationEnabled','mailAggregationDelay','mailIncludeResponse','mailIncludeSessionDetails','mailCollabEscalations','configSave']) {
|
|
1294
|
+
$(id).disabled = !available;
|
|
1295
|
+
}
|
|
1296
|
+
$('mailNotificationAvailability').textContent = !hasConnection()
|
|
1297
|
+
? '连接服务后可以配置邮件通知。'
|
|
1298
|
+
: !ownsForm
|
|
1299
|
+
? '正在读取主 Backend 的邮件通知设置…'
|
|
1300
|
+
: available
|
|
1301
|
+
? `MailDispatch 已在「${backend.name}」配置。设置对该 Backend 的所有 Agent 和客户端生效。`
|
|
1302
|
+
: `Backend「${backend.name}」未配置 MailDispatch endpoint、API key 和通知邮箱。`;
|
|
471
1303
|
}
|
|
472
|
-
|
|
473
|
-
|
|
1304
|
+
|
|
1305
|
+
function updateConfigUi() {
|
|
1306
|
+
const connected = connectedBackends().length;
|
|
1307
|
+
$('configConnectionStatus').textContent = connected ? `已连接 ${connected} 个 backend` : '当前未配对';
|
|
1308
|
+
$('disconnectAll').hidden = !connected;
|
|
1309
|
+
updateNotificationButton();
|
|
1310
|
+
updateVoiceButton();
|
|
1311
|
+
updateMailControls();
|
|
1312
|
+
renderBackendList();
|
|
474
1313
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
$('
|
|
480
|
-
|
|
481
|
-
|
|
1314
|
+
|
|
1315
|
+
function openConfig() {
|
|
1316
|
+
updateConfigUi();
|
|
1317
|
+
renderKeyboardBindings();
|
|
1318
|
+
if (!$('configDialog').open) $('configDialog').showModal();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
async function saveMailSettings() {
|
|
1322
|
+
if (!requireConnection()) return;
|
|
1323
|
+
const backend = backendOf(state.mailBackendId);
|
|
1324
|
+
if (!backend || primaryBackend()?.id !== backend.id) return toast('主 Backend 已变化,请等待邮件设置重新加载');
|
|
1325
|
+
if (!state.mailNotificationsAvailable) return toast('服务端未配置 MailDispatch');
|
|
1326
|
+
const delay = Number($('mailAggregationDelay').value);
|
|
1327
|
+
if (!Number.isInteger(delay) || delay < 0 || delay > 86400) return toast('聚合时间必须是 0 到 86400 之间的整数秒');
|
|
1328
|
+
$('configSave').disabled = true;
|
|
1329
|
+
try {
|
|
1330
|
+
const result = await postBackend(backend.id, '/api/v1/mail-notifications', {
|
|
1331
|
+
enabled: $('mailNotificationEnabled').checked,
|
|
1332
|
+
aggregationDelaySeconds: delay,
|
|
1333
|
+
includeResponse: $('mailIncludeResponse').checked,
|
|
1334
|
+
includeSessionDetails: $('mailIncludeSessionDetails').checked,
|
|
1335
|
+
collabEscalations: $('mailCollabEscalations').checked,
|
|
1336
|
+
});
|
|
1337
|
+
if (primaryBackend()?.id !== backend.id || state.mailBackendId !== backend.id) return;
|
|
1338
|
+
state.mailSettings = result.settings;
|
|
1339
|
+
updateMailControls();
|
|
1340
|
+
toast('邮件通知设置已保存');
|
|
1341
|
+
} catch (error) {
|
|
1342
|
+
toast(error.message);
|
|
1343
|
+
} finally {
|
|
1344
|
+
updateMailControls();
|
|
1345
|
+
}
|
|
482
1346
|
}
|
|
483
1347
|
|
|
484
1348
|
function modal(title, build, okText = '确定') {
|
|
@@ -487,58 +1351,205 @@
|
|
|
487
1351
|
}
|
|
488
1352
|
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
1353
|
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-
|
|
1354
|
+
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),binding=kind==='agent'&&sessionKeyboard.getBinding(item.agentId);archive.hidden=kind!=='agent';archive.disabled=active;archive.title=active?'Agent 执行期间不能 Archive':'';$('menuBindKeyboard').hidden=kind!=='agent'||!!binding;$('menuBindKeyboard').disabled=!binding&&sessionKeyboard.getBindings().length>=6;$('menuBindKeyboard').title=$('menuBindKeyboard').disabled?'六个按键均已绑定':'';$('menuKeyboardColor').hidden=kind!=='agent'||!binding;$('menuUnbindKeyboard').hidden=kind!=='agent'||!binding;$('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-190)}px`;}
|
|
1355
|
+
async function bindKeyboard({agentId, slot} = {}) {
|
|
1356
|
+
const free = sessionKeyboard.getFreeSlots();
|
|
1357
|
+
const agents = state.agents.filter(agent => !sessionKeyboard.getBinding(agent.agentId));
|
|
1358
|
+
if (!free.length) return toast('六个键盘按键均已绑定');
|
|
1359
|
+
if (!agents.length) return toast('没有可绑定的 Session');
|
|
1360
|
+
const result = await modal('绑定键盘按键', body => {
|
|
1361
|
+
const sessionLabel = document.createElement('label'); sessionLabel.textContent = 'Session';
|
|
1362
|
+
const sessions = document.createElement('select');
|
|
1363
|
+
for (const agent of agents) {
|
|
1364
|
+
const option = document.createElement('option'); option.value = agent.agentId;
|
|
1365
|
+
option.textContent = connectedBackends().length > 1 ? `${agent.backendName} · ${agentLabel(agent)}` : agentLabel(agent); option.selected = agent.agentId === agentId;
|
|
1366
|
+
sessions.append(option);
|
|
1367
|
+
}
|
|
1368
|
+
sessionLabel.append(sessions); body.append(sessionLabel);
|
|
1369
|
+
const slotLabel = document.createElement('label'); slotLabel.textContent = '按键';
|
|
1370
|
+
const keys = document.createElement('select');
|
|
1371
|
+
for (const key of free) {
|
|
1372
|
+
const option = document.createElement('option'); option.value = String(key);
|
|
1373
|
+
option.textContent = `按键 ${key+1} / AG0${key}`; option.selected = key === slot;
|
|
1374
|
+
keys.append(option);
|
|
1375
|
+
}
|
|
1376
|
+
slotLabel.append(keys); body.append(slotLabel);
|
|
1377
|
+
const color = field(body, '灯光颜色', sessionKeyboard.nextColor(), 'color');
|
|
1378
|
+
return () => ({agentId:sessions.value, slot:Number(keys.value), color:color.value});
|
|
1379
|
+
}, '绑定');
|
|
1380
|
+
if (!result) return;
|
|
1381
|
+
sessionKeyboard.bind(result.agentId, result.slot, result.color);
|
|
1382
|
+
toast(`已绑定到按键 ${result.slot+1}`);
|
|
1383
|
+
}
|
|
1384
|
+
async function changeKeyboardColor(agent){
|
|
1385
|
+
const binding=sessionKeyboard.getBinding(agent.agentId);if(!binding)return;
|
|
1386
|
+
const color=await modal('更换按键颜色',body=>{const input=field(body,`按键 ${binding.slot+1} 的颜色`,binding.color,'color');return()=>input.value;},'应用');
|
|
1387
|
+
if(color){sessionKeyboard.setColor(agent.agentId,color);toast(`按键 ${binding.slot+1} 颜色已更新`);}
|
|
1388
|
+
}
|
|
491
1389
|
async function createAgent() {
|
|
492
|
-
|
|
493
|
-
|
|
1390
|
+
const workspace = state.workspace;
|
|
1391
|
+
if (!workspace) return toast('请先选择 Workspace');
|
|
1392
|
+
const cwd = $('agentCwd').value.trim() || '.';
|
|
1393
|
+
let sessions;
|
|
1394
|
+
try {
|
|
1395
|
+
sessions = await requestBackend(workspace.backendId, `/api/v1/sessions?workspaceId=${encodeURIComponent(workspace.id)}&path=${encodeURIComponent(cwd)}`);
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
return toast(error.message);
|
|
1398
|
+
}
|
|
494
1399
|
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;
|
|
1400
|
+
if (!result) return;
|
|
1401
|
+
try {
|
|
1402
|
+
const created = await postBackend(workspace.backendId, '/api/v1/agents', {workspaceId:workspace.id, relativeCwd:result.cwd, sessionFile:result.sessionFile});
|
|
1403
|
+
const agent = stampNewItem(workspace.backendId, created);
|
|
1404
|
+
const binding = result.sessionFile ? null : autoBindNewSession(agent);
|
|
1405
|
+
await refreshAgents();
|
|
1406
|
+
await selectAgent(agent);
|
|
1407
|
+
if (binding) toast(`新 Session 已自动绑定到按键 ${binding.slot+1}`);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
toast(error.message);
|
|
1410
|
+
}
|
|
496
1411
|
}
|
|
497
|
-
async function navigateAgent(entryId) {
|
|
498
|
-
|
|
499
|
-
const result=await
|
|
1412
|
+
async function navigateAgent(entryId, agent = state.agent) {
|
|
1413
|
+
if (!agent) return;
|
|
1414
|
+
const result = await postAgent(agent.agentId, '/navigate', {entryId});
|
|
500
1415
|
await selectAgent(agent);
|
|
501
|
-
if(result.editorText!==undefined)$('input').value=result.editorText;
|
|
502
|
-
toast(result.editorText!==undefined?'已回退到该用户输入,可编辑后重新发送':'已回退到所选输出');
|
|
1416
|
+
if (result.editorText !== undefined) $('input').value = result.editorText;
|
|
1417
|
+
toast(result.editorText !== undefined ? '已回退到该用户输入,可编辑后重新发送' : '已回退到所选输出');
|
|
503
1418
|
}
|
|
504
1419
|
async function showSessionTree() {
|
|
505
|
-
|
|
1420
|
+
const sourceAgent = state.agent;
|
|
1421
|
+
if (!sourceAgent) return toast('请选择 Agent');
|
|
1422
|
+
const session = await requestAgent(sourceAgent.agentId, '/session');
|
|
1423
|
+
const entries = session.entries || [];
|
|
506
1424
|
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
|
|
1425
|
+
if (!choice) return;
|
|
1426
|
+
if (choice.action === 'fork') {
|
|
1427
|
+
const result = await postAgent(sourceAgent.agentId, '/fork', {entryId:choice.entryId});
|
|
1428
|
+
const forkedAgent = stampNewItem(sourceAgent.backendId, result.agent);
|
|
1429
|
+
const binding = autoBindNewSession(forkedAgent);
|
|
1430
|
+
await refreshAgents();
|
|
1431
|
+
await selectAgent(forkedAgent);
|
|
1432
|
+
if (binding) toast(`Fork Session 已自动绑定到按键 ${binding.slot+1}`);
|
|
1433
|
+
} else {
|
|
1434
|
+
await navigateAgent(choice.entryId, sourceAgent);
|
|
1435
|
+
}
|
|
508
1436
|
}
|
|
509
1437
|
async function revert() {
|
|
510
|
-
if(!state.agent)return toast('请选择 Agent');const session=await
|
|
1438
|
+
if(!state.agent)return toast('请选择 Agent');const session=await requestAgent(state.agent.agentId,'/session');
|
|
511
1439
|
const messages=(session.entries||[]).filter(e=>e.type==='message'&&['user','assistant'].includes(e.message?.role));
|
|
512
1440
|
if(!messages.length)return toast('当前 Session 没有可回退的消息');
|
|
513
1441
|
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
1442
|
if(selected)await navigateAgent(selected);
|
|
515
1443
|
}
|
|
516
1444
|
async function modelControls() {
|
|
517
|
-
if(!state.agent)return toast('请选择 Agent');const cap=await
|
|
1445
|
+
if(!state.agent)return toast('请选择 Agent');const cap=await requestAgent(state.agent.agentId,'/capabilities');
|
|
518
1446
|
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
|
|
1447
|
+
if(result){if(result.model)await postAgent(state.agent.agentId,'/model',{provider:result.model[0],modelId:result.model[1]});if(result.thinking)await postAgent(state.agent.agentId,'/thinking',{level:result.thinking});toast('设置已更新');}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** MCP servers Pi reads from mcp.json. Toggling writes the config; a session picks it up when it loads. */
|
|
1451
|
+
async function mcpControls() {
|
|
1452
|
+
if(!state.agent)return toast('请选择 Agent');
|
|
1453
|
+
const agentId=state.agent.agentId;
|
|
1454
|
+
const render=(overview,list)=>{
|
|
1455
|
+
list.replaceChildren();
|
|
1456
|
+
if(!overview.servers.length){const empty=document.createElement('p');empty.className='muted';empty.textContent='没有配置 MCP 服务器。在 mcp.json 中添加后会显示在这里。';list.append(empty);}
|
|
1457
|
+
for(const server of overview.servers){
|
|
1458
|
+
const row=document.createElement('div');row.className=`mcp-row${server.enabled?'':' mcp-off'}`;
|
|
1459
|
+
const info=document.createElement('div');info.className='mcp-info';
|
|
1460
|
+
const name=document.createElement('b');name.textContent=server.name;
|
|
1461
|
+
const detail=document.createElement('small');detail.className='muted';
|
|
1462
|
+
detail.textContent=`${server.transport} · ${server.exposure} · ${server.scope==='project'?'项目':'全局'}${server.override?'(项目覆盖)':''}\n${server.description||server.target}`;
|
|
1463
|
+
info.append(name,detail);
|
|
1464
|
+
const toggle=document.createElement('button');toggle.type='button';toggle.textContent=server.enabled?'停用':'启用';
|
|
1465
|
+
toggle.onclick=async()=>{
|
|
1466
|
+
toggle.disabled=true;
|
|
1467
|
+
try{render(await postAgent(agentId,'/mcp',{name:server.name,enabled:!server.enabled}),list);toast('已保存,重新加载 Agent 后生效');}
|
|
1468
|
+
catch(error){toast(`修改失败:${error.message}`);toggle.disabled=false;}
|
|
1469
|
+
};
|
|
1470
|
+
row.append(info,toggle);list.append(row);
|
|
1471
|
+
}
|
|
1472
|
+
for(const error of overview.errors||[]){const line=document.createElement('p');line.className='mcp-error';line.textContent=error;list.append(line);}
|
|
1473
|
+
if(!overview.projectTrusted){const note=document.createElement('p');note.className='muted';note.textContent='项目未被信任,因此忽略 .pi/mcp.json,修改会写入全局配置。';list.append(note);}
|
|
1474
|
+
};
|
|
1475
|
+
const overview=await requestAgent(agentId,'/mcp');
|
|
1476
|
+
await modal('MCP 服务器',body=>{const list=document.createElement('div');list.className='mcp-list';render(overview,list);body.append(list);return null;},'关闭');
|
|
520
1477
|
}
|
|
521
1478
|
|
|
522
1479
|
$('pairCancel').onclick=()=>$('pairDialog').close();
|
|
523
1480
|
$('modalCancel').onclick=()=>$('modal').close('cancel');
|
|
524
|
-
$('pairForm').onsubmit=async event=>
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1481
|
+
$('pairForm').onsubmit = async event => {
|
|
1482
|
+
event.preventDefault();
|
|
1483
|
+
$('pairSubmit').disabled = true;
|
|
1484
|
+
try {
|
|
1485
|
+
const base = $('pairBase').value.trim().replace(/\/$/, '');
|
|
1486
|
+
const token = $('pairToken').value;
|
|
1487
|
+
let name = $('pairName').value.trim();
|
|
1488
|
+
if (!name) {
|
|
1489
|
+
try { name = new URL(base).hostname; } catch {}
|
|
1490
|
+
}
|
|
1491
|
+
const editingId = $('pairDialog').dataset.backendId;
|
|
1492
|
+
const existing = backendOf(editingId) || backendRegistry.findByBase(base);
|
|
1493
|
+
const entry = {id:existing?.id || createBackendId(), name, base, token, saved:existing?.saved || false};
|
|
1494
|
+
await connectBackend(entry, {confirmSave:true});
|
|
1495
|
+
$('pairDialog').close();
|
|
1496
|
+
} catch (error) {
|
|
1497
|
+
updateHeaderStatus();
|
|
1498
|
+
toast(error.message);
|
|
1499
|
+
} finally {
|
|
1500
|
+
$('pairSubmit').disabled = false;
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
$('openConfig').onclick=openConfig;$('configClose').onclick=()=>$('configDialog').close();$('configSave').onclick=saveMailSettings;$('connect').onclick=()=>openPair();$('keyboardConnect').onclick=async()=>{try{$('keyboardConnect').disabled=true;await sessionKeyboard.connect();}catch(error){toast(`键盘连接失败:${error.message}`);}finally{$('keyboardConnect').disabled=false;}};$('keyboardDisconnect').onclick=()=>sessionKeyboard.disconnect().catch(error=>toast(`键盘断开失败:${error.message}`));$('notifications').onclick=enableNotifications;$('voicePlayback').onclick=toggleVoicePlayback;$('voiceInput').onclick=toggleVoiceInput;$('disconnectAll').onclick=()=>void disconnectAll();$('backendFilter').onchange=()=>{state.backendFilter=$('backendFilter').value;if(state.backendFilter)localStorage.rpBackendFilter=state.backendFilter;else localStorage.removeItem('rpBackendFilter');renderWorkspaces().catch(()=>{});renderAgentList();};
|
|
1504
|
+
$('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){const target=state.backendFilter||state.workspace?.backendId||primaryBackend()?.id;if(!target)return toast('没有可用的 Backend');await postBackend(target,'/api/v1/workspaces',result);await refreshWs();}};
|
|
1505
|
+
$('workspaces').onchange=()=>void 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
1506
|
$('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
|
|
530
|
-
$('newAgent').onclick=createAgent;$('newTerminal').onclick=()=>openTerminal($('agentCwd').value.trim()||'.');$('abort').onclick=()=>state.agent
|
|
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
|
|
532
|
-
$('
|
|
533
|
-
$('
|
|
534
|
-
$('
|
|
1507
|
+
$('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;};$('menuBindKeyboard').onclick=()=>{const agent=agentContextTarget?.kind==='agent'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(agent)void bindKeyboard({agentId:agent.agentId}).catch(error=>toast(error.message));};$('menuKeyboardColor').onclick=()=>{const agent=agentContextTarget?.kind==='agent'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(agent)void changeKeyboardColor(agent).catch(error=>toast(error.message));};$('menuUnbindKeyboard').onclick=()=>{const agent=agentContextTarget?.kind==='agent'?agentContextTarget.item:null;$('agentContextMenu').hidden=true;if(agent&&sessionKeyboard.unbind(agent.agentId))toast('已取消键盘按键绑定');};$('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 postAgent(agent.agentId,'/archive');sessionKeyboard.unbind(agent.agentId);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;});
|
|
1508
|
+
$('newAgent').onclick=createAgent;$('newTerminal').onclick=()=>openTerminal($('agentCwd').value.trim()||'.');$('abort').onclick=()=>{if(!state.agent)return;const id=state.agent.agentId;Promise.all([postAgent(id,'/abort'),postAgent(id,'/bash-abort')]).catch(e=>toast(e.message));};$('stop').onclick=async()=>{if(state.agent&&confirm('停止这个 Agent?之后再次使用时会按需启动。')){await requestAgent(state.agent.agentId,'',{method:'DELETE'});setAgentStatus(state.agent.agentId,'unloaded');}};
|
|
1509
|
+
$('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 postAgent(state.agent.agentId,'/session-name',{name:result});state.agent.sessionName=s.sessionName||result;updateAgentHeader();await refreshAgents();}};
|
|
1510
|
+
$('mcp').onclick=()=>mcpControls().catch(e=>toast(e.message));
|
|
1511
|
+
$('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 postAgent(state.agent.agentId,'/compact',{instructions:instructions||undefined});toast('Compact 完成');}};
|
|
1512
|
+
$('prompt').onsubmit=async event=>{event.preventDefault();if(!state.agent)return toast('请先创建或选择 Agent');const text=$('input').value.trim();if(!text)return;closeCommandPicker();const mode=$('sendMode').value,agentId=state.agent.agentId,sequenceBefore=localStorage[`rpSeq:${agentId}`];$('messages').querySelector('.empty')?.remove();
|
|
1513
|
+
// `!cmd` runs inside the Agent session (output is recorded in the Session); `!!cmd` keeps the output out of the model context.
|
|
1514
|
+
if(text.startsWith('!')){const excludeFromContext=text.startsWith('!!'),command=text.slice(excludeFromContext?2:1).trim();if(!command)return toast('请输入要执行的命令');addCard(null,text,'user');$('input').value='';
|
|
1515
|
+
try{await postAgent(agentId,'/bash',{command,excludeFromContext});if(state.agent?.agentId===agentId&&localStorage[`rpSeq:${agentId}`]===sequenceBefore){await loadMessagePage(agentId,undefined,true);}}catch(e){toast(`命令失败:${e.message}`);}return;}
|
|
1516
|
+
addCard(null,text,'user');$('input').value='';try{const sent=await postAgent(agentId,`/${mode}`,{message:text});if(mode!=='prompt'&&sent?.disposition)toast(sent.disposition==='queued'?'已排队,等待当前回合':'已送达当前回合');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);}};
|
|
1517
|
+
$('input').onkeydown=event=>{if(!$('commandPicker').hidden){if(event.key==='ArrowDown'||event.key==='ArrowUp'){event.preventDefault();setCommandSelection(state.commandIndex+(event.key==='ArrowDown'?1:-1));return;}if(event.key==='Tab'||(event.key==='Enter'&&!event.ctrlKey&&!event.metaKey)){event.preventDefault();chooseCommand(state.commandFiltered[state.commandIndex]);return;}if(event.key==='Escape'){event.preventDefault();closeCommandPicker();return;}}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 command=commandContext();if(command){if(!$('mentionPicker').hidden)closeMention();void openCommandPicker(command.query);return;}if(!$('commandPicker').hidden)closeCommandPicker();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);};
|
|
1518
|
+
// Clearing hands the queued messages back, so nothing typed is lost when a run is redirected.
|
|
1519
|
+
$('queueClear').onclick=async()=>{
|
|
1520
|
+
if(!state.agent)return;const agentId=state.agent.agentId;
|
|
1521
|
+
try{
|
|
1522
|
+
// The server's queue_update event clears the banner; applying an empty queue here would hide a
|
|
1523
|
+
// message queued in the meantime.
|
|
1524
|
+
const cleared=await requestAgent(agentId,'/queue',{method:'DELETE'});
|
|
1525
|
+
const restored=[...(cleared.steering||[]),...(cleared.followUp||[])].join('\n');
|
|
1526
|
+
// The composer is shared, so restoring into another Agent's input would send the text there.
|
|
1527
|
+
if(restored&&state.agent?.agentId!==agentId)return toast('队列已清空,但已切换 Agent,内容未放回输入框');
|
|
1528
|
+
if(restored){const input=$('input');input.value=input.value.trim()?`${restored}\n${input.value}`:restored;input.focus();}
|
|
1529
|
+
toast(restored?'队列已清空,内容已放回输入框':'队列已清空');
|
|
1530
|
+
}catch(error){toast(`清空队列失败:${error.message}`);}
|
|
1531
|
+
};
|
|
1532
|
+
$('commandClose').onclick=closeCommandPicker;
|
|
535
1533
|
$('mentionUp').onclick=()=>browseMention(parentPath(state.mentionPath));$('mentionClose').onclick=closeMention;
|
|
536
1534
|
$('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
1535
|
$('mobileAgentActions').onclick=()=>$('mobileAgentActions').closest('.toolbar').classList.toggle('actions-open');
|
|
538
1536
|
document.querySelectorAll('.agent-action').forEach(button=>button.addEventListener('click',()=>button.closest('.toolbar').classList.remove('actions-open')));
|
|
539
1537
|
window.addEventListener('popstate',event=>setMobileView(event.state?.rpView||'home'));
|
|
540
1538
|
mobileMedia.addEventListener?.('change',event=>{if(event.matches)setMobileView('home',{replace:true});});
|
|
541
|
-
window.addEventListener('beforeunload',()=>{state.
|
|
1539
|
+
window.addEventListener('beforeunload',()=>{for(const backend of state.backends)closeBackendSocket(backend);state.terminalWs?.close();cancelVoiceRecording();stopVoiceAudio();});
|
|
542
1540
|
window.addEventListener('focus',updateNotificationButton);
|
|
543
|
-
setMobileView('home', {replace:true}); updateConfigUi();
|
|
1541
|
+
setMobileView('home', {replace:true}); updateConfigUi(); updateKeyboardUi(); renderBackendFilter(); updateHeaderStatus(); void sessionKeyboard.restoreAuthorized();
|
|
1542
|
+
// Backends whose pairing code was explicitly saved reconnect on their own, and so do codeless
|
|
1543
|
+
// backends because a --no-auth server needs none; the pairing dialog is only for first-time
|
|
1544
|
+
// or failed connections.
|
|
1545
|
+
void (async () => {
|
|
1546
|
+
const candidates = state.backends.filter(backend => backend.base && (backend.saved || !backend.token));
|
|
1547
|
+
if (!candidates.length) { openPair(state.backends[0] || null); return; }
|
|
1548
|
+
const results = await Promise.allSettled(candidates.map(backend => connectBackend(backend, {confirmSave:false, pair:false})));
|
|
1549
|
+
if (!hasConnection()) {
|
|
1550
|
+
const error = results.find(result => result.status === 'rejected')?.reason;
|
|
1551
|
+
if (error) toast(`自动连接失败:${error.message}`);
|
|
1552
|
+
openPair(state.backends[0] || null);
|
|
1553
|
+
}
|
|
1554
|
+
})();
|
|
544
1555
|
})();
|