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