pi2web 0.2.6 → 0.2.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -5
- package/dist/activity.js +83 -9
- package/dist/agents.js +244 -43
- package/dist/http/agent-routes.js +37 -3
- package/dist/http/utils.js +5 -1
- package/dist/http/web-assets.js +1 -0
- package/dist/mail-notifier.js +7 -2
- package/dist/metadata-store.js +14 -1
- package/dist/safe-pause.js +100 -0
- package/dist/sdk-backend.js +48 -3
- package/dist/server.js +52 -2
- package/dist/session-performance.js +139 -0
- package/dist/sotto-stt.js +217 -0
- package/dist/voice.js +2 -2
- package/dist/ws/agent-socket.js +2 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/protocol/openapi.yaml +22 -1
- package/packages/protocol/websocket.schema.json +4 -3
- package/web/activity-view.js +49 -4
- package/web/app.js +764 -94
- package/web/collab.js +18 -4
- package/web/index.html +5 -2
- package/web/styles.css +8 -2
- package/web/voice-pcm.js +36 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
export const DEFAULT_SOTTO_ENDPOINT = 'http://127.0.0.1:8766';
|
|
3
|
+
const invalid = (message, code = 'INVALID_SOTTO_SETTINGS') => Object.assign(new Error(message), { code });
|
|
4
|
+
/** No arbitrary URLs: the Sotto Web API is local-only, and this server is the authenticated proxy. */
|
|
5
|
+
export function validateSottoEndpoint(value) {
|
|
6
|
+
if (typeof value !== 'string')
|
|
7
|
+
throw invalid('Sotto endpoint must be a loopback HTTP URL');
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(value);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw invalid('Sotto endpoint must be a valid URL');
|
|
14
|
+
}
|
|
15
|
+
if (url.protocol !== 'http:' ||
|
|
16
|
+
url.hostname !== '127.0.0.1' ||
|
|
17
|
+
!url.port ||
|
|
18
|
+
url.username ||
|
|
19
|
+
url.password ||
|
|
20
|
+
(url.pathname !== '/' && url.pathname !== '') ||
|
|
21
|
+
url.search ||
|
|
22
|
+
url.hash)
|
|
23
|
+
throw invalid('Sotto endpoint must be http://127.0.0.1:<port> with no path or credentials');
|
|
24
|
+
return url.origin;
|
|
25
|
+
}
|
|
26
|
+
/** An authenticated browser socket relays only audio and start/stop, never a Sotto control API. */
|
|
27
|
+
function proxySottoStream(client, url) {
|
|
28
|
+
const upstream = new WebSocket(url, { handshakeTimeout: 5000, maxPayload: 65536 });
|
|
29
|
+
let started = false, finished = false, closed = false;
|
|
30
|
+
let timer;
|
|
31
|
+
const close = () => {
|
|
32
|
+
closed = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
if (upstream.readyState === WebSocket.OPEN)
|
|
35
|
+
upstream.close();
|
|
36
|
+
else if (upstream.readyState === WebSocket.CONNECTING)
|
|
37
|
+
upstream.terminate();
|
|
38
|
+
};
|
|
39
|
+
const fail = (message, code = 1011) => {
|
|
40
|
+
if (closed)
|
|
41
|
+
return;
|
|
42
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
43
|
+
client.send(JSON.stringify({ type: 'error', message }));
|
|
44
|
+
client.close(code);
|
|
45
|
+
}
|
|
46
|
+
close();
|
|
47
|
+
};
|
|
48
|
+
// Silence still contains PCM. Only a stalled capture/connection times out, not a long dictation.
|
|
49
|
+
const touch = () => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
timer = setTimeout(() => fail('Audio stream stalled: no microphone frames received for 30 seconds'), 30000);
|
|
52
|
+
};
|
|
53
|
+
touch();
|
|
54
|
+
client.on('close', close);
|
|
55
|
+
client.on('error', close);
|
|
56
|
+
upstream.on('error', () => fail('Sotto streaming service is unavailable'));
|
|
57
|
+
upstream.on('close', code => {
|
|
58
|
+
if (closed)
|
|
59
|
+
return;
|
|
60
|
+
if (!finished || code !== 1000)
|
|
61
|
+
fail('Sotto streaming connection closed unexpectedly');
|
|
62
|
+
else {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (client.readyState === WebSocket.OPEN)
|
|
65
|
+
client.close(1000);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
upstream.on('message', (data, binary) => {
|
|
69
|
+
if (closed || binary || client.readyState !== WebSocket.OPEN)
|
|
70
|
+
return;
|
|
71
|
+
if (client.bufferedAmount > 1_000_000) {
|
|
72
|
+
fail('Transcription receiver is too slow', 1009);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
client.send(data.toString());
|
|
76
|
+
});
|
|
77
|
+
client.on('message', (data, binary) => {
|
|
78
|
+
if (closed || finished || client.readyState !== WebSocket.OPEN)
|
|
79
|
+
return;
|
|
80
|
+
const length = data.byteLength;
|
|
81
|
+
if (upstream.readyState !== WebSocket.OPEN) {
|
|
82
|
+
fail('Sotto streaming service is not ready');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!binary) {
|
|
86
|
+
try {
|
|
87
|
+
const value = JSON.parse(data.toString());
|
|
88
|
+
if (!started &&
|
|
89
|
+
value.type === 'start' &&
|
|
90
|
+
value.format === 'float32' &&
|
|
91
|
+
Number.isInteger(value.sample_rate) &&
|
|
92
|
+
value.sample_rate >= 16000 &&
|
|
93
|
+
value.sample_rate <= 96000) {
|
|
94
|
+
started = true;
|
|
95
|
+
upstream.send(JSON.stringify({ type: 'start', format: 'float32', sample_rate: value.sample_rate }));
|
|
96
|
+
touch();
|
|
97
|
+
}
|
|
98
|
+
else if (started && value.type === 'stop') {
|
|
99
|
+
finished = true;
|
|
100
|
+
upstream.send(JSON.stringify({ type: 'stop' }));
|
|
101
|
+
}
|
|
102
|
+
else
|
|
103
|
+
fail('Invalid audio protocol', 1008);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
fail('Invalid audio protocol', 1008);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (started &&
|
|
110
|
+
length > 0 &&
|
|
111
|
+
length % 4 === 0 &&
|
|
112
|
+
length <= 32768 &&
|
|
113
|
+
upstream.bufferedAmount < 1_000_000) {
|
|
114
|
+
upstream.send(data, { binary: true });
|
|
115
|
+
touch();
|
|
116
|
+
}
|
|
117
|
+
else
|
|
118
|
+
fail('Invalid audio frame or Sotto cannot keep up with microphone audio', 1009);
|
|
119
|
+
});
|
|
120
|
+
return fail;
|
|
121
|
+
}
|
|
122
|
+
export class SottoSttService {
|
|
123
|
+
metadata;
|
|
124
|
+
fetcher;
|
|
125
|
+
streams = new Set();
|
|
126
|
+
settings = { enabled: false, endpoint: DEFAULT_SOTTO_ENDPOINT };
|
|
127
|
+
/** Bumped by every update, so a slow enable probe cannot overwrite a newer choice. */
|
|
128
|
+
updates = 0;
|
|
129
|
+
constructor(metadata, fetcher = fetch) {
|
|
130
|
+
this.metadata = metadata;
|
|
131
|
+
this.fetcher = fetcher;
|
|
132
|
+
}
|
|
133
|
+
load() {
|
|
134
|
+
const saved = this.metadata.getSetting('sottoStt');
|
|
135
|
+
if (saved && typeof saved.enabled === 'boolean') {
|
|
136
|
+
try {
|
|
137
|
+
this.settings = { enabled: saved.enabled, endpoint: validateSottoEndpoint(saved.endpoint) };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* Corrupt or obsolete settings must not enable an untrusted destination. */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
getSettings() {
|
|
145
|
+
return { ...this.settings };
|
|
146
|
+
}
|
|
147
|
+
streamUrl() {
|
|
148
|
+
if (!this.settings.enabled)
|
|
149
|
+
throw invalid('Sotto transcription is disabled', 'SOTTO_DISABLED');
|
|
150
|
+
const port = Number(new URL(this.settings.endpoint).port) + 2;
|
|
151
|
+
if (port > 65535)
|
|
152
|
+
throw invalid('Invalid Sotto streaming port');
|
|
153
|
+
return `ws://127.0.0.1:${port}/asr`;
|
|
154
|
+
}
|
|
155
|
+
stream(client) {
|
|
156
|
+
const stop = proxySottoStream(client, this.streamUrl());
|
|
157
|
+
this.streams.add(stop);
|
|
158
|
+
client.once('close', () => this.streams.delete(stop));
|
|
159
|
+
}
|
|
160
|
+
async request(endpoint, path, init, timeoutMs) {
|
|
161
|
+
const response = await this.fetcher(`${endpoint}${path}`, {
|
|
162
|
+
...init,
|
|
163
|
+
redirect: 'error',
|
|
164
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
165
|
+
}).catch(error => {
|
|
166
|
+
throw invalid(`Cannot reach local Sotto: ${error.message}`, 'SOTTO_UNAVAILABLE');
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const message = (await response.json().catch(() => ({})));
|
|
170
|
+
throw invalid(message.error || `Sotto returned HTTP ${response.status}`, 'SOTTO_UNAVAILABLE');
|
|
171
|
+
}
|
|
172
|
+
return response;
|
|
173
|
+
}
|
|
174
|
+
async update(input) {
|
|
175
|
+
if (!input || typeof input !== 'object')
|
|
176
|
+
throw invalid('enabled and endpoint are required');
|
|
177
|
+
const { enabled, endpoint } = input;
|
|
178
|
+
if (typeof enabled !== 'boolean')
|
|
179
|
+
throw invalid('enabled must be a boolean');
|
|
180
|
+
const next = { enabled, endpoint: validateSottoEndpoint(endpoint) };
|
|
181
|
+
const update = ++this.updates;
|
|
182
|
+
if (enabled) {
|
|
183
|
+
const capabilities = await (await this.request(next.endpoint, '/api/capabilities', { method: 'GET' }, 4000)).json();
|
|
184
|
+
if (capabilities?.stt !== true ||
|
|
185
|
+
capabilities?.format !== 'pcm-f32le' ||
|
|
186
|
+
capabilities?.sample_rate !== 16000 ||
|
|
187
|
+
capabilities?.stream_port !== Number(new URL(next.endpoint).port) + 2)
|
|
188
|
+
throw invalid('Sotto does not support live PCM transcription', 'SOTTO_UNAVAILABLE');
|
|
189
|
+
if (update !== this.updates)
|
|
190
|
+
throw invalid('Sotto settings were changed by a newer request', 'SOTTO_SETTINGS_SUPERSEDED');
|
|
191
|
+
}
|
|
192
|
+
this.metadata.setSetting('sottoStt', next);
|
|
193
|
+
const changed = !next.enabled || next.endpoint !== this.settings.endpoint;
|
|
194
|
+
this.settings = next;
|
|
195
|
+
if (changed) {
|
|
196
|
+
for (const stop of this.streams)
|
|
197
|
+
stop('Sotto 语音输入已关闭或服务地址已更改');
|
|
198
|
+
this.streams.clear();
|
|
199
|
+
}
|
|
200
|
+
return this.getSettings();
|
|
201
|
+
}
|
|
202
|
+
async transcribe(audio) {
|
|
203
|
+
if (!this.settings.enabled)
|
|
204
|
+
throw invalid('Sotto transcription is disabled', 'SOTTO_DISABLED');
|
|
205
|
+
if (!audio.length || audio.length % 4 || audio.length > 16000 * 4 * 60)
|
|
206
|
+
throw invalid('Expected at most 60 seconds of 16 kHz float32 PCM');
|
|
207
|
+
const response = await this.request(this.settings.endpoint, '/api/transcribe', {
|
|
208
|
+
method: 'POST',
|
|
209
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
210
|
+
body: new Uint8Array(audio)
|
|
211
|
+
}, 120000);
|
|
212
|
+
const body = (await response.json());
|
|
213
|
+
if (typeof body.text !== 'string')
|
|
214
|
+
throw invalid('Invalid response from Sotto', 'SOTTO_UNAVAILABLE');
|
|
215
|
+
return { text: body.text };
|
|
216
|
+
}
|
|
217
|
+
}
|
package/dist/voice.js
CHANGED
|
@@ -241,8 +241,8 @@ export class VoiceManager {
|
|
|
241
241
|
const finalOutput = this.lastAssistant.get(agentId), userPrompt = this.lastPrompt.get(agentId) ?? '';
|
|
242
242
|
this.lastAssistant.delete(agentId);
|
|
243
243
|
this.lastPrompt.delete(agentId);
|
|
244
|
-
// A run the user cancelled has no result worth speaking
|
|
245
|
-
if (finalOutput && event.aborted !== true)
|
|
244
|
+
// A run the user cancelled or safely paused has no result worth speaking (`aborted` / `paused`).
|
|
245
|
+
if (finalOutput && event.aborted !== true && event.paused !== true)
|
|
246
246
|
void this.announce(agentId, { userPrompt, finalOutput, ...context });
|
|
247
247
|
}
|
|
248
248
|
}
|
package/dist/ws/agent-socket.js
CHANGED
|
@@ -10,6 +10,7 @@ const OVERVIEW_EVENTS = new Set([
|
|
|
10
10
|
'agent_settled',
|
|
11
11
|
'activity_changed',
|
|
12
12
|
'queue_update',
|
|
13
|
+
'safe_pause',
|
|
13
14
|
'session_info_changed',
|
|
14
15
|
'agent_unloaded',
|
|
15
16
|
'agent_loaded',
|
|
@@ -225,7 +226,7 @@ export function handleAgentSocket(ctx, ws) {
|
|
|
225
226
|
ctx.voice?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
226
227
|
ctx.sessionNamer?.recordUserPrompt(m.agentId, String(m.message ?? ''));
|
|
227
228
|
}
|
|
228
|
-
const disposition = await ctx.agents.
|
|
229
|
+
const disposition = await ctx.agents.submitCommand(m.agentId, m.type, m.message);
|
|
229
230
|
send({ type: 'command_result', requestId: m.requestId, success: true, disposition });
|
|
230
231
|
return;
|
|
231
232
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi2web",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pi2web",
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.12",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@earendil-works/chord": "1.1.0",
|
package/package.json
CHANGED
|
@@ -21,11 +21,32 @@ paths:
|
|
|
21
21
|
post: {operationId: createAgent, requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'201': {$ref: '#/components/responses/Success'}}}
|
|
22
22
|
/agents/{id}/timeline:
|
|
23
23
|
get: {operationId: agentTimeline, description: 'Older history as display items (see timelineItem in websocket.schema.json): about `limit` items before item `before`, never splitting a tool call from its result. Data {sessionId, items, total, start, hasMore}. Viewers get the newest page from the WebSocket focus snapshot. 404 when `before` is not on the current branch.', parameters: [{$ref: '#/components/parameters/Id'}, {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 500, default: 50}}, {name: before, in: query, schema: {type: string}}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
24
|
+
/agents/{id}/performance:
|
|
25
|
+
get: {operationId: agentPerformance, description: 'Current branch wall-clock model and tool spans recorded by recent runs; older sessions return only available tool durations. Data {sessionId, runs, legacyTools}. Parallel tools overlap.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
24
26
|
/agents/{id}/sessions/{sessionId}/items/{itemId}:
|
|
25
27
|
get: {operationId: agentTimelineItem, description: 'Full body of one timeline item, data {id, body}. Items never change, so it is served with Cache-Control private, max-age=31536000, immutable. 404 when the Agent is no longer on that session or branch.', parameters: [{$ref: '#/components/parameters/Id'}, {name: sessionId, in: path, required: true, schema: {type: string}}, {name: itemId, in: path, required: true, schema: {type: string}}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
28
|
+
/agents/{id}/system-prompt:
|
|
29
|
+
get: {operationId: getAgentSystemPrompt, parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
30
|
+
post: {operationId: changeAgentSystemPrompt, description: 'Only before the first conversation message; body {templateId?: string}, empty ID selects the Pi default.', parameters: [{$ref: '#/components/parameters/Id'}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
31
|
+
/sotto-stt:
|
|
32
|
+
get: {operationId: getSottoSttSettings, description: 'Backend-wide Sotto transcription setting; disabled by default, endpoint defaults to http://127.0.0.1:8766.', responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
33
|
+
put: {operationId: setSottoSttSettings, description: 'Body {enabled: boolean, endpoint: http://127.0.0.1:<port>}; probes local Sotto capabilities before enabling. Requires full access token.', requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
34
|
+
/transcribe:
|
|
35
|
+
post: {operationId: transcribeDraft, description: 'Backend-wide authenticated transcription, not tied to an Agent. Audio format follows the configured Sotto or STT service; returns {data:{text}}.', responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
36
|
+
/transcribe/ws:
|
|
37
|
+
get: {operationId: streamSottoTranscription, description: 'Authenticated backend-wide Sotto transcription WebSocket; start/PCM/stop protocol matches the Agent route.', responses: {'101': {description: 'WebSocket upgrade'}}}
|
|
38
|
+
/agents/{id}/transcribe:
|
|
39
|
+
post: {operationId: transcribeAgentDraft, description: 'When Sotto is enabled, body is 16 kHz mono float32 little-endian PCM (application/octet-stream; max 60 s); otherwise uses configured OpenAI-compatible STT audio. Returns {data:{text}} without submitting to the Agent.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
40
|
+
/agents/{id}/transcribe/ws:
|
|
41
|
+
get: {operationId: streamSottoDraft, description: 'Authenticated WebSocket: start {type:start,format:float32,sample_rate}, binary mono PCM chunks, stop {type:stop}; forwards Sotto ready/partial/final/error messages. Only when Sotto is enabled; never submits to Agent.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'101': {description: 'WebSocket upgrade'}}}
|
|
42
|
+
/agents/{id}/pause:
|
|
43
|
+
post: {operationId: requestSafePause, description: 'Safe pause: the conversation stops advancing. Running tools finish and their results are recorded, then no further model response or tool call is produced: the next agent-loop turn, auto-retry, overflow-compaction retry, queued messages and extension agent_before_settle continuations are all withheld, and a waiting retry backoff is cancelled. Queued input stays queued for the next prompt. Housekeeping model calls (compaction summaries, prompt-cache warming) are not withheld. Progress is published as safe_pause events (requested, reached, paused, cancelled, finished) and as activity.pauseRequested; the agent_settled of a paused run carries paused: true. Rejects with AGENT_NOT_RUNNING while idle.', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
44
|
+
delete: {operationId: cancelSafePause, description: 'Withdraw a pending safe pause. Rejects with PAUSE_ALREADY_REACHED while the run is winding down at the safe point (after reached, before paused). Without a pending pause, including after the run has settled, it is a no-op that succeeds. Whether the withdrawal took effect is reported by the safe_pause event (cancelled or paused).', parameters: [{$ref: '#/components/parameters/Id'}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
45
|
+
/agents/{id}/queue/move:
|
|
46
|
+
post: {operationId: moveQueuedMessage, description: 'Move one unconsumed message to the other queue during a run. Body {from: steering|followUp, index: integer, expected: string}; rejects stale entries.', parameters: [{$ref: '#/components/parameters/Id'}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
26
47
|
/agents/{id}/{action}:
|
|
27
48
|
get: {operationId: getAgentResource, parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [state,capabilities,session]}}], responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
28
|
-
post: {operationId: agentCommand, parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [prompt,steer,follow-up,abort,compact,model,thinking,session-name,navigate,fork,extension-response]}}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
49
|
+
post: {operationId: agentCommand, description: 'For prompt/steer/follow-up, success acknowledges acceptance by Pi, not completion of the run. Preflight rejections are HTTP errors; subsequent execution updates and errors use the WebSocket event stream. A lost response does not imply the message was rejected; clients must not automatically resend it.', parameters: [{$ref: '#/components/parameters/Id'}, {name: action, in: path, required: true, schema: {enum: [prompt,steer,follow-up,abort,compact,model,thinking,session-name,navigate,fork,extension-response]}}], requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
29
50
|
/draft:
|
|
30
51
|
post: {operationId: draftText, description: 'Insert text into the currently displayed Web UI input box on this backend, without sending it to an Agent. Body {text, mode?: append|replace, source?}; explicit agent/slot targets are rejected. Pending text is kept in memory until a page acknowledges it.', requestBody: {$ref: '#/components/requestBodies/Json'}, responses: {'200': {$ref: '#/components/responses/Success'}}}
|
|
31
52
|
components:
|
|
@@ -19,9 +19,9 @@
|
|
|
19
19
|
"focus": {"type":"object","additionalProperties":false,"required":["type","agentId"],"properties":{"type":{"const":"focus"},"agentId":{"type":["string","null"]},"limit":{"type":"integer","minimum":1,"maximum":500,"description":"Size of the snapshot's page, 50 by default."}},"description":"View one Agent in detail (null: none). Loads it if needed and answers with focus_state, or focus_error; replaces the previous focus and its watches."},
|
|
20
20
|
"watch": {"type":"object","additionalProperties":false,"required":["type","toolCallId"],"properties":{"type":{"enum":["watch","unwatch"]},"toolCallId":{"type":"string"}},"description":"Start or stop tool_output for a running tool of the focused Agent, e.g. while its card is expanded."},
|
|
21
21
|
"command": {"type":"object","additionalProperties":false,"required":["type","requestId","agentId"],"properties":{"type":{"enum":["prompt","steer","follow-up","abort","clear-queue"]},"requestId":{"type":"string"},"agentId":{"type":"string"},"message":{"type":"string","maxLength":2097152}}},
|
|
22
|
-
"commandResult": {"type":"object","required":["type","success"],"properties":{"type":{"const":"command_result"},"requestId":{"type":"string"},"success":{"type":"boolean"},"error":{"type":"object"},"disposition":{"enum":["handled","queued"],"description":"For steer and follow-up: whether the running turn took the message or it waits in the queue."},"queue":{"$ref":"#/$defs/queue"}}},
|
|
22
|
+
"commandResult": {"type":"object","description":"For prompt/steer/follow-up, success acknowledges acceptance, not completion. A lost acknowledgement must not trigger an automatic resend.","required":["type","success"],"properties":{"type":{"const":"command_result"},"requestId":{"type":"string"},"success":{"type":"boolean"},"error":{"type":"object"},"disposition":{"enum":["handled","queued"],"description":"For steer and follow-up: whether the running turn took the message or it waits in the queue."},"queue":{"$ref":"#/$defs/queue"}}},
|
|
23
23
|
"queue": {"type":"object","required":["steering","followUp"],"description":"Messages waiting for the running turn.","properties":{"steering":{"type":"array","items":{"type":"string"}},"followUp":{"type":"array","items":{"type":"string"}}}},
|
|
24
|
-
"agentEvent": {"type":"object","required":["type","agentId","timestamp","status","event"],"properties":{"type":{"const":"agent_event"},"agentId":{"type":"string"},"timestamp":{"type":"integer","minimum":0,"description":"Epoch milliseconds."},"status":{"$ref":"#/$defs/agentStatus","description":"Agent status after this event."},"event":{"type":"object","required":["type"],"description":"Overview events of every Agent: agent_loaded (its backend started on demand), agent_start, agent_end {willRetry, aborted}, agent_settled, activity_changed {activity}, queue_update, session_info_changed, agent_unloaded, auto_retry_*, summarization_retry_*, extension_ui_*. For the focused Agent also bash_execution_start/update/end (output of user shell commands)."}}},
|
|
24
|
+
"agentEvent": {"type":"object","required":["type","agentId","timestamp","status","event"],"properties":{"type":{"const":"agent_event"},"agentId":{"type":"string"},"timestamp":{"type":"integer","minimum":0,"description":"Epoch milliseconds."},"status":{"$ref":"#/$defs/agentStatus","description":"Agent status after this event."},"event":{"type":"object","required":["type"],"description":"Overview events of every Agent: agent_loaded (its backend started on demand), agent_start, agent_end {willRetry, aborted}, agent_settled, activity_changed {activity}, queue_update, safe_pause {state: requested|reached|paused|cancelled|finished}, session_info_changed, agent_unloaded, auto_retry_*, summarization_retry_*, extension_ui_*. For the focused Agent also bash_execution_start/update/end (output of user shell commands)."}}},
|
|
25
25
|
"focusState": {"type":"object","required":["type","agentId","sessionId","page","live"],"description":"Snapshot of the focused Agent. Every detail message after it is newer.","properties":{"type":{"const":"focus_state"},"agentId":{"type":"string"},"sessionId":{"type":"string"},"page":{"type":"object","required":["items","total","start","hasMore"],"properties":{"items":{"type":"array","items":{"$ref":"#/$defs/timelineItem"}},"total":{"type":"integer"},"start":{"type":"integer"},"hasMore":{"type":"boolean"}}},"live":{"oneOf":[{"type":"null"},{"type":"object","required":["text","thinking"],"properties":{"text":{"type":"string"},"thinking":{"type":"string"}}}],"description":"Text streamed so far for the message being generated."},"dialogRequests":{"type":"array","description":"Pending dialogs, to rebuild their controls in the transcript."}}},
|
|
26
26
|
"focusError": {"type":"object","required":["type","agentId","error"],"properties":{"type":{"const":"focus_error"},"agentId":{"type":"string"},"error":{"type":"object"}}},
|
|
27
27
|
"delta": {"type":"object","required":["type","agentId","kind","delta"],"properties":{"type":{"const":"delta"},"agentId":{"type":"string"},"kind":{"enum":["text","thinking"]},"delta":{"type":"string"}},"description":"More streamed text; adjacent tokens are coalesced."},
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
}
|
|
64
64
|
},
|
|
65
65
|
"bash":{"type":"array","items":{"type":"object","required":["id","since"],"properties":{"id":{"type":"string"},"command":{"type":"string"},"since":{"type":"integer"}}}},
|
|
66
|
-
"dialogs":{"type":"array","items":{"type":"object","required":["requestId","since"],"properties":{"requestId":{"type":"string"},"title":{"type":"string"},"since":{"type":"integer"}}}}
|
|
66
|
+
"dialogs":{"type":"array","items":{"type":"object","required":["requestId","since"],"properties":{"requestId":{"type":"string"},"title":{"type":"string"},"since":{"type":"integer"}}}},
|
|
67
|
+
"pauseRequested":{"type":"object","required":["since"],"description":"A safe pause is pending: the run stops before its next model request. reached: a request was withheld and the run is winding down; it can no longer be withdrawn.","properties":{"since":{"type":"integer"},"reached":{"const":true}}}
|
|
67
68
|
}
|
|
68
69
|
},
|
|
69
70
|
"agentState": {
|
package/web/activity-view.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
compacting: ['🗜️', '压缩上下文'],
|
|
14
14
|
stopping: ['⏹️', '正在停止']
|
|
15
15
|
};
|
|
16
|
+
/** Streaming phases that report an estimated throughput, and how their output count reads. */
|
|
17
|
+
const STREAM_COUNT = {thinking: '已思考', generating: '已输出', preparing_tool: '参数'};
|
|
16
18
|
|
|
17
19
|
const isBusy = activity =>
|
|
18
20
|
!!activity &&
|
|
@@ -21,6 +23,10 @@
|
|
|
21
23
|
!!activity.bash?.length ||
|
|
22
24
|
!!activity.dialogs?.length);
|
|
23
25
|
|
|
26
|
+
function formatDuration(ms) {
|
|
27
|
+
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
function formatElapsed(ms) {
|
|
25
31
|
const total = Math.max(0, Math.floor(ms / 1000)),
|
|
26
32
|
h = Math.floor(total / 3600),
|
|
@@ -40,6 +46,20 @@
|
|
|
40
46
|
const phase = activity.phase;
|
|
41
47
|
if (phase && phase.kind !== 'idle') {
|
|
42
48
|
const [icon, label] = PHASE_TEXT[phase.kind] || ['•', phase.kind];
|
|
49
|
+
const metrics = activity.metrics;
|
|
50
|
+
const ttft =
|
|
51
|
+
metrics?.firstResponseAt === undefined
|
|
52
|
+
? ''
|
|
53
|
+
: ` · TTFT ${formatDuration(metrics.firstResponseAt - metrics.turnStartedAt)}`;
|
|
54
|
+
// `textStartedAt`: backends before per-phase throughput only measured the text phase.
|
|
55
|
+
const streamStartedAt = metrics?.streamStartedAt ?? metrics?.textStartedAt;
|
|
56
|
+
const streaming =
|
|
57
|
+
STREAM_COUNT[phase.kind] && (metrics?.streamStartedAt !== undefined || phase.kind === 'generating');
|
|
58
|
+
const output = streaming && metrics?.chars ? ` · ${STREAM_COUNT[phase.kind]} ${metrics.chars} 字` : '';
|
|
59
|
+
const speed =
|
|
60
|
+
streaming && streamStartedAt !== undefined
|
|
61
|
+
? {speed: {tokens: metrics.estimatedTokens, since: streamStartedAt}}
|
|
62
|
+
: {};
|
|
43
63
|
if (phase.kind === 'running_tools')
|
|
44
64
|
for (const tool of phase.tools || [])
|
|
45
65
|
parts.push({
|
|
@@ -50,8 +70,9 @@
|
|
|
50
70
|
else if (phase.kind === 'preparing_tool')
|
|
51
71
|
parts.push({
|
|
52
72
|
icon,
|
|
53
|
-
text: `${label}${phase.toolName ? `:${phase.toolName}` : ''}`,
|
|
54
|
-
since: phase.since
|
|
73
|
+
text: `${label}${phase.toolName ? `:${phase.toolName}` : ''}${ttft}${output}`,
|
|
74
|
+
since: phase.since,
|
|
75
|
+
...speed
|
|
55
76
|
});
|
|
56
77
|
else if (phase.kind === 'retrying')
|
|
57
78
|
parts.push({
|
|
@@ -59,8 +80,14 @@
|
|
|
59
80
|
text: `${label}${phase.attempt ? ` ${phase.attempt}/${phase.maxAttempts || '?'}` : ''}${phase.error ? ` · ${phase.error}` : ''}`,
|
|
60
81
|
since: phase.since
|
|
61
82
|
});
|
|
62
|
-
else parts.push({icon, text: label
|
|
83
|
+
else parts.push({icon, text: `${label}${ttft}${output}`, since: phase.since, ...speed});
|
|
63
84
|
}
|
|
85
|
+
if (activity.pauseRequested)
|
|
86
|
+
parts.push({
|
|
87
|
+
icon: '⏸️',
|
|
88
|
+
text: activity.pauseRequested.reached ? '已到暂停点,正在收尾' : '将在下次调用模型前暂停',
|
|
89
|
+
since: activity.pauseRequested.since
|
|
90
|
+
});
|
|
64
91
|
for (const bash of activity.bash || [])
|
|
65
92
|
parts.push({icon: '$', text: bash.command || 'bash', since: bash.since});
|
|
66
93
|
return parts;
|
|
@@ -86,7 +113,17 @@
|
|
|
86
113
|
text.className = 'activity-text';
|
|
87
114
|
text.textContent = `${part.icon} ${part.text}`;
|
|
88
115
|
text.title = part.text;
|
|
89
|
-
node.append(text
|
|
116
|
+
node.append(text);
|
|
117
|
+
if (part.speed) {
|
|
118
|
+
const speed = document.createElement('span');
|
|
119
|
+
speed.className = 'activity-speed';
|
|
120
|
+
speed.dataset.tokens = String(part.speed.tokens);
|
|
121
|
+
speed.dataset.since = String(part.speed.since);
|
|
122
|
+
speed.dataset.backend = backendId || '';
|
|
123
|
+
speed.textContent = speedText(part.speed.tokens, serverNow(backendId) - part.speed.since);
|
|
124
|
+
node.append(speed);
|
|
125
|
+
}
|
|
126
|
+
node.append(elapsed(backendId, part.since));
|
|
90
127
|
return node;
|
|
91
128
|
}
|
|
92
129
|
/** The full activity of the selected Agent: every part plus the run's total time. */
|
|
@@ -110,9 +147,17 @@
|
|
|
110
147
|
if (parts.length > 1) node.append(` +${parts.length - 1}`);
|
|
111
148
|
return node;
|
|
112
149
|
}
|
|
150
|
+
function speedText(tokens, ms) {
|
|
151
|
+
return ms < 1000 ? ' · 估算中' : ` · 约 ${((tokens * 1000) / ms).toFixed(1)} token/s`;
|
|
152
|
+
}
|
|
113
153
|
function tick() {
|
|
114
154
|
for (const el of document.querySelectorAll('.elapsed[data-since]'))
|
|
115
155
|
el.textContent = formatElapsed(serverNow(el.dataset.backend) - Number(el.dataset.since));
|
|
156
|
+
for (const el of document.querySelectorAll('.activity-speed[data-since]'))
|
|
157
|
+
el.textContent = speedText(
|
|
158
|
+
Number(el.dataset.tokens),
|
|
159
|
+
serverNow(el.dataset.backend) - Number(el.dataset.since)
|
|
160
|
+
);
|
|
116
161
|
}
|
|
117
162
|
return {bar, line, tick};
|
|
118
163
|
}
|