pi2web 0.2.0 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- 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 +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- 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 +18 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
package/dist/voice.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
const punctuation = new Set(['。', '!', '?', '!', '?', '.']);
|
|
4
|
-
const closing = new Set(['”', '’', '"', '
|
|
5
|
-
const endpoint = (base, path) => `${base.replace(/\/$/, '')}${base.replace(/\/$/, '').endsWith('/v1') ? '' : '/v1'}${path}`;
|
|
6
|
-
const authHeaders = (key) => key ? { authorization: `Bearer ${key}` } : {};
|
|
4
|
+
const closing = new Set(['”', '’', '"', "'", ')', ')', '】', ']']);
|
|
5
|
+
export const endpoint = (base, path) => `${base.replace(/\/$/, '')}${base.replace(/\/$/, '').endsWith('/v1') ? '' : '/v1'}${path}`;
|
|
6
|
+
export const authHeaders = (key) => key ? { authorization: `Bearer ${key}` } : {};
|
|
7
7
|
const abortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
8
8
|
const timeoutError = (error) => error instanceof Error && error.name === 'TimeoutError';
|
|
9
9
|
/** Combines caller cancellation with a hard timeout so a stalled speech server cannot block the announcement queue forever. */
|
|
10
|
-
const deadline = (ms, signal) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
|
|
10
|
+
export const deadline = (ms, signal) => signal ? AbortSignal.any([signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms);
|
|
11
11
|
export function assistantText(message) {
|
|
12
12
|
if (typeof message === 'string')
|
|
13
13
|
return message.trim() || undefined;
|
|
@@ -19,7 +19,11 @@ export function assistantText(message) {
|
|
|
19
19
|
if (typeof value.content === 'string')
|
|
20
20
|
return value.content.trim() || undefined;
|
|
21
21
|
if (Array.isArray(value.content)) {
|
|
22
|
-
const text = value.content
|
|
22
|
+
const text = value.content
|
|
23
|
+
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
24
|
+
.map((part) => part.text)
|
|
25
|
+
.join('\n')
|
|
26
|
+
.trim();
|
|
23
27
|
return text || undefined;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
@@ -42,8 +46,11 @@ export function prepareSummaryInput(text, maxChars = 32000) {
|
|
|
42
46
|
cleaned = `${cleaned.slice(0, head)}\n\n[中间内容已省略]\n\n${cleaned.slice(-tail)}`;
|
|
43
47
|
return cleaned;
|
|
44
48
|
}
|
|
45
|
-
function clipSummaryField(text, maxChars) {
|
|
46
|
-
const normalized = text
|
|
49
|
+
export function clipSummaryField(text, maxChars) {
|
|
50
|
+
const normalized = text
|
|
51
|
+
.replace(/[ \t]+/g, ' ')
|
|
52
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
53
|
+
.trim();
|
|
47
54
|
if (normalized.length <= maxChars)
|
|
48
55
|
return normalized;
|
|
49
56
|
const head = Math.floor(maxChars * 0.375), tail = maxChars - head;
|
|
@@ -78,14 +85,24 @@ export function parseSummaryResult(text) {
|
|
|
78
85
|
if (!summary)
|
|
79
86
|
throw new Error('Summary endpoint returned no summary');
|
|
80
87
|
const rawName = typeof value?.sessionName === 'string' ? value.sessionName.trim() : '';
|
|
81
|
-
const sessionName = rawName
|
|
88
|
+
const sessionName = rawName
|
|
89
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
90
|
+
.replace(/\s{2,}/g, ' ')
|
|
91
|
+
.slice(0, 60)
|
|
92
|
+
.trim();
|
|
82
93
|
return { summary, ...(sessionName ? { sessionName } : {}) };
|
|
83
94
|
}
|
|
84
95
|
export function formatSpokenSummary(summary, sessionName, language = 'zh-CN') {
|
|
85
|
-
const name = sessionName
|
|
96
|
+
const name = sessionName
|
|
97
|
+
?.replace(/[\r\n\t]+/g, ' ')
|
|
98
|
+
.replace(/\s{2,}/g, ' ')
|
|
99
|
+
.slice(0, 60)
|
|
100
|
+
.trim();
|
|
86
101
|
if (!name)
|
|
87
102
|
return summary;
|
|
88
|
-
return language.toLowerCase().startsWith('zh')
|
|
103
|
+
return language.toLowerCase().startsWith('zh')
|
|
104
|
+
? `会话${name}已完成:${summary}`
|
|
105
|
+
: `Session ${name} completed: ${summary}`;
|
|
89
106
|
}
|
|
90
107
|
export class SentenceChunker {
|
|
91
108
|
maxChars;
|
|
@@ -93,8 +110,13 @@ export class SentenceChunker {
|
|
|
93
110
|
constructor(maxChars = 100) {
|
|
94
111
|
this.maxChars = maxChars;
|
|
95
112
|
}
|
|
96
|
-
add(delta) {
|
|
97
|
-
|
|
113
|
+
add(delta) {
|
|
114
|
+
this.buffer += delta;
|
|
115
|
+
return this.consume(false);
|
|
116
|
+
}
|
|
117
|
+
close() {
|
|
118
|
+
return this.consume(true);
|
|
119
|
+
}
|
|
98
120
|
consume(final) {
|
|
99
121
|
const chunks = [];
|
|
100
122
|
while (this.buffer) {
|
|
@@ -169,16 +191,35 @@ export class VoiceManager {
|
|
|
169
191
|
generations = new Map();
|
|
170
192
|
announcementQueue = Promise.resolve();
|
|
171
193
|
closed = false;
|
|
194
|
+
/** Some backends rename or reject optional sampling parameters; the working shape is learned once and reused. */
|
|
195
|
+
summaryParams = { temperature: true, tokenLimitField: 'max_tokens' };
|
|
172
196
|
config;
|
|
173
197
|
constructor(config, fetcher = fetch) {
|
|
174
198
|
this.fetcher = fetcher;
|
|
175
|
-
this.config = {
|
|
199
|
+
this.config = {
|
|
200
|
+
language: 'zh-CN',
|
|
201
|
+
maxInputChars: 32000,
|
|
202
|
+
sampleRate: 24000,
|
|
203
|
+
ttsFormat: 'pcm',
|
|
204
|
+
requestTimeoutMs: 120000,
|
|
205
|
+
...config
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
subscribe(listener) {
|
|
209
|
+
this.emitter.on('event', listener);
|
|
210
|
+
return () => this.emitter.off('event', listener);
|
|
211
|
+
}
|
|
212
|
+
capabilities() {
|
|
213
|
+
return { tts: true, stt: !!this.config.sttModel };
|
|
214
|
+
}
|
|
215
|
+
recordUserPrompt(agentId, prompt) {
|
|
216
|
+
const value = prompt.trim();
|
|
217
|
+
if (value)
|
|
218
|
+
this.lastPrompt.set(agentId, value);
|
|
219
|
+
}
|
|
220
|
+
emit(agentId, event) {
|
|
221
|
+
this.emitter.emit('event', agentId, event);
|
|
176
222
|
}
|
|
177
|
-
subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
|
|
178
|
-
capabilities() { return { tts: true, stt: !!this.config.sttModel }; }
|
|
179
|
-
recordUserPrompt(agentId, prompt) { const value = prompt.trim(); if (value)
|
|
180
|
-
this.lastPrompt.set(agentId, value); }
|
|
181
|
-
emit(agentId, event) { this.emitter.emit('event', agentId, event); }
|
|
182
223
|
handleAgentEvent(agentId, event, context = {}) {
|
|
183
224
|
if (event.type === 'before_agent_start' && typeof event.prompt === 'string') {
|
|
184
225
|
this.lastPrompt.set(agentId, event.prompt);
|
|
@@ -200,17 +241,26 @@ export class VoiceManager {
|
|
|
200
241
|
const finalOutput = this.lastAssistant.get(agentId), userPrompt = this.lastPrompt.get(agentId) ?? '';
|
|
201
242
|
this.lastAssistant.delete(agentId);
|
|
202
243
|
this.lastPrompt.delete(agentId);
|
|
203
|
-
|
|
244
|
+
// A run the user cancelled has no result worth speaking; Pi 0.99+ reports that as `aborted`.
|
|
245
|
+
if (finalOutput && event.aborted !== true)
|
|
204
246
|
void this.announce(agentId, { userPrompt, finalOutput, ...context });
|
|
205
247
|
}
|
|
206
248
|
}
|
|
207
|
-
cancel(agentId) {
|
|
208
|
-
|
|
209
|
-
this.active.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
249
|
+
cancel(agentId) {
|
|
250
|
+
this.generations.set(agentId, (this.generations.get(agentId) ?? 0) + 1);
|
|
251
|
+
const controller = this.active.get(agentId);
|
|
252
|
+
if (controller) {
|
|
253
|
+
controller.abort();
|
|
254
|
+
this.active.delete(agentId);
|
|
255
|
+
this.emit(agentId, { type: 'voice_cancelled' });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
close() {
|
|
259
|
+
this.closed = true;
|
|
260
|
+
for (const id of [...new Set([...this.active.keys(), ...this.generations.keys()])])
|
|
261
|
+
this.cancel(id);
|
|
262
|
+
this.emitter.removeAllListeners();
|
|
263
|
+
}
|
|
214
264
|
async transcribe(audio, mimeType = 'audio/webm', filename = 'recording.webm') {
|
|
215
265
|
if (!this.config.sttModel)
|
|
216
266
|
throw Object.assign(new Error('Voice STT model is not configured'), { code: 'VOICE_STT_DISABLED' });
|
|
@@ -220,7 +270,16 @@ export class VoiceManager {
|
|
|
220
270
|
form.append('file', new Blob([copy.buffer], { type: mimeType }), filename);
|
|
221
271
|
form.append('model', this.config.sttModel);
|
|
222
272
|
form.append('response_format', 'json');
|
|
223
|
-
const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/transcriptions'), {
|
|
273
|
+
const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/transcriptions'), {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
signal: deadline(this.config.requestTimeoutMs),
|
|
276
|
+
headers: authHeaders(this.config.speechApiKey),
|
|
277
|
+
body: form
|
|
278
|
+
}).catch(error => {
|
|
279
|
+
throw timeoutError(error)
|
|
280
|
+
? Object.assign(new Error(`Speech transcription timed out after ${this.config.requestTimeoutMs} ms`), { code: 'VOICE_STT_FAILED' })
|
|
281
|
+
: error;
|
|
282
|
+
});
|
|
224
283
|
if (!response.ok)
|
|
225
284
|
throw Object.assign(new Error(`Speech transcription failed (${response.status}): ${(await response.text()).slice(0, 500)}`), { code: 'VOICE_STT_FAILED' });
|
|
226
285
|
const value = await response.json();
|
|
@@ -233,20 +292,26 @@ export class VoiceManager {
|
|
|
233
292
|
const input = typeof value === 'string' ? { userPrompt: '', finalOutput: value } : value;
|
|
234
293
|
this.cancel(agentId);
|
|
235
294
|
const generation = this.generations.get(agentId);
|
|
236
|
-
const job = this.announcementQueue.then(() => this.closed || this.generations.get(agentId) !== generation
|
|
295
|
+
const job = this.announcementQueue.then(() => this.closed || this.generations.get(agentId) !== generation
|
|
296
|
+
? undefined
|
|
297
|
+
: this.runAnnouncement(agentId, input, generation));
|
|
237
298
|
this.announcementQueue = job.then(() => undefined, () => undefined);
|
|
238
299
|
return job;
|
|
239
300
|
}
|
|
240
301
|
async runAnnouncement(agentId, input, generation) {
|
|
241
302
|
const controller = new AbortController(), playbackId = randomUUID();
|
|
242
303
|
this.active.set(agentId, controller);
|
|
243
|
-
this.emit(agentId, {
|
|
304
|
+
this.emit(agentId, {
|
|
305
|
+
type: 'voice_start',
|
|
306
|
+
playbackId,
|
|
307
|
+
encoding: this.config.ttsFormat === 'mp3' ? 'mp3' : 'pcm_s16le',
|
|
308
|
+
sampleRate: this.config.sampleRate,
|
|
309
|
+
channels: 1
|
|
310
|
+
});
|
|
244
311
|
let rawResult = '', spokenSummary = '';
|
|
245
312
|
try {
|
|
246
313
|
const summaryInput = prepareSummaryContext(input, this.config.maxInputChars);
|
|
247
|
-
const response = await this.
|
|
248
|
-
if (!response.ok)
|
|
249
|
-
throw new Error(`Summary request failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
|
|
314
|
+
const response = await this.requestSummary(summaryInput, controller.signal);
|
|
250
315
|
for await (const delta of sseText(response))
|
|
251
316
|
rawResult += delta;
|
|
252
317
|
const result = parseSummaryResult(rawResult), sessionName = input.sessionName?.trim() || result.sessionName;
|
|
@@ -256,7 +321,11 @@ export class VoiceManager {
|
|
|
256
321
|
await input.setSessionName(result.sessionName);
|
|
257
322
|
}
|
|
258
323
|
catch (error) {
|
|
259
|
-
this.emit(agentId, {
|
|
324
|
+
this.emit(agentId, {
|
|
325
|
+
type: 'voice_session_name_error',
|
|
326
|
+
playbackId,
|
|
327
|
+
message: error.message
|
|
328
|
+
});
|
|
260
329
|
}
|
|
261
330
|
}
|
|
262
331
|
this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: spokenSummary });
|
|
@@ -264,19 +333,28 @@ export class VoiceManager {
|
|
|
264
333
|
for (const sentence of [...chunker.add(spokenSummary), ...chunker.close()])
|
|
265
334
|
await this.speak(agentId, playbackId, sentence, controller.signal);
|
|
266
335
|
if (this.active.get(agentId) === controller)
|
|
267
|
-
this.emit(agentId, {
|
|
336
|
+
this.emit(agentId, {
|
|
337
|
+
type: 'voice_end',
|
|
338
|
+
playbackId,
|
|
339
|
+
summary: spokenSummary,
|
|
340
|
+
sessionName: result.sessionName
|
|
341
|
+
});
|
|
268
342
|
}
|
|
269
343
|
catch (error) {
|
|
270
344
|
if (!abortError(error) && this.active.get(agentId) === controller) {
|
|
271
345
|
this.emit(agentId, { type: 'voice_error', playbackId, message: error.message });
|
|
272
|
-
const fallback = this.config.language.toLowerCase().startsWith('zh')
|
|
346
|
+
const fallback = this.config.language.toLowerCase().startsWith('zh')
|
|
347
|
+
? 'Agent 已完成,等待输入。'
|
|
348
|
+
: 'The agent has finished and is ready for input.';
|
|
273
349
|
if (!spokenSummary)
|
|
274
350
|
try {
|
|
275
351
|
this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: fallback });
|
|
276
352
|
await this.speak(agentId, playbackId, fallback, controller.signal);
|
|
277
353
|
this.emit(agentId, { type: 'voice_end', playbackId, summary: fallback });
|
|
278
354
|
}
|
|
279
|
-
catch {
|
|
355
|
+
catch {
|
|
356
|
+
/* The text notification remains available when TTS also fails. */
|
|
357
|
+
}
|
|
280
358
|
else
|
|
281
359
|
this.emit(agentId, { type: 'voice_end', playbackId, summary: spokenSummary, partial: true });
|
|
282
360
|
}
|
|
@@ -288,16 +366,89 @@ export class VoiceManager {
|
|
|
288
366
|
controller.abort();
|
|
289
367
|
}
|
|
290
368
|
}
|
|
291
|
-
|
|
369
|
+
/** Retries once per unsupported parameter so a strict model still produces a summary instead of failing the announcement. */
|
|
370
|
+
async requestSummary(summaryInput, signal) {
|
|
371
|
+
for (let attempt = 0;; attempt++) {
|
|
372
|
+
const body = {
|
|
373
|
+
model: this.config.summaryModel,
|
|
374
|
+
stream: true,
|
|
375
|
+
messages: [
|
|
376
|
+
{ role: 'system', content: this.summaryPrompt() },
|
|
377
|
+
{ role: 'user', content: summaryInput }
|
|
378
|
+
]
|
|
379
|
+
};
|
|
380
|
+
if (this.summaryParams.temperature && typeof this.config.summaryTemperature === 'number')
|
|
381
|
+
body.temperature = this.config.summaryTemperature;
|
|
382
|
+
if (this.summaryParams.tokenLimitField && this.config.maxOutputTokens)
|
|
383
|
+
body[this.summaryParams.tokenLimitField] = this.config.maxOutputTokens;
|
|
384
|
+
const response = await this.fetcher(endpoint(this.config.summaryBaseUrl, '/chat/completions'), {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
signal: deadline(this.config.requestTimeoutMs, signal),
|
|
387
|
+
headers: { 'content-type': 'application/json', ...authHeaders(this.config.summaryApiKey) },
|
|
388
|
+
body: JSON.stringify(body)
|
|
389
|
+
});
|
|
390
|
+
if (response.ok)
|
|
391
|
+
return response;
|
|
392
|
+
const detail = (await response.text()).slice(0, 500);
|
|
393
|
+
if (response.status === 400 && attempt < 2 && this.relaxSummaryParams(detail))
|
|
394
|
+
continue;
|
|
395
|
+
throw new Error(`Summary request failed (${response.status}): ${detail}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
relaxSummaryParams(detail) {
|
|
399
|
+
const text = detail.toLowerCase();
|
|
400
|
+
if (!/unsupported|not supported|unrecognized|unknown|invalid/.test(text))
|
|
401
|
+
return false;
|
|
402
|
+
let changed = false;
|
|
403
|
+
if (this.summaryParams.temperature && text.includes('temperature')) {
|
|
404
|
+
this.summaryParams.temperature = false;
|
|
405
|
+
changed = true;
|
|
406
|
+
}
|
|
407
|
+
const field = this.summaryParams.tokenLimitField;
|
|
408
|
+
if (field === 'max_tokens' && text.includes('max_tokens')) {
|
|
409
|
+
this.summaryParams.tokenLimitField = text.includes('max_completion_tokens')
|
|
410
|
+
? 'max_completion_tokens'
|
|
411
|
+
: null;
|
|
412
|
+
changed = true;
|
|
413
|
+
}
|
|
414
|
+
else if (field === 'max_completion_tokens' && text.includes('max_completion_tokens')) {
|
|
415
|
+
this.summaryParams.tokenLimitField = null;
|
|
416
|
+
changed = true;
|
|
417
|
+
}
|
|
418
|
+
return changed;
|
|
419
|
+
}
|
|
420
|
+
summaryPrompt() {
|
|
421
|
+
const language = this.config.language.toLowerCase().startsWith('zh')
|
|
422
|
+
? '简体中文'
|
|
423
|
+
: '与用户输入相同的主要语言';
|
|
424
|
+
return `你负责把一次 Pi 编程任务压缩成准确、自然、可直接朗读的完成通知,并在需要时为 Session 命名。\n\n输入是一个 JSON 对象:\n- userPrompt:用户本轮真正想完成的任务。\n- piFinalOutput:Pi 最后一次回复,包含完成情况、改动、验证结果和后续事项。\n- sessionNeedsName:只有为 true 时才需要生成 Session 名称。\n\n先结合 userPrompt 判断目标,再以 piFinalOutput 为事实依据总结。不要把用户的要求误说成已经完成;没有明确证据时不要声称测试通过或任务成功。摘要使用${language},写 2 到 4 个短句,最多 120 个中文字符或 70 个英文单词。优先交代:是否完成、最重要的结果或修改、测试/验证结果、失败原因,以及用户必须采取的下一步。省略代码、命令、URL、完整路径、哈希、冗长文件清单、日志和 Markdown;不要使用标题、列表、“总结如下”等套话。\n\n如果 sessionNeedsName 为 true,请根据 userPrompt 生成一个具体、简短、便于检索的名称:中文建议 6 到 18 个字,英文建议 3 到 8 个词;使用任务主题或目标,不写“新会话”“任务总结”等空泛名称,不带句号、引号、Emoji 或路径。如果 sessionNeedsName 为 false,sessionName 必须为 null。\n\n只输出一个合法 JSON 对象,不要输出 Markdown 代码块、解释或任何额外文字。有名称时使用 {"summary":"适合直接朗读的摘要","sessionName":"简短名称"};无需命名时使用 {"summary":"适合直接朗读的摘要","sessionName":null}。summary 必须是 JSON 字符串,sessionName 必须是 JSON 字符串或 null。`;
|
|
425
|
+
}
|
|
292
426
|
async speak(agentId, playbackId, text, signal) {
|
|
293
|
-
const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/speech'), {
|
|
427
|
+
const response = await this.fetcher(endpoint(this.config.speechBaseUrl, '/audio/speech'), {
|
|
428
|
+
method: 'POST',
|
|
429
|
+
signal: deadline(this.config.requestTimeoutMs, signal),
|
|
430
|
+
headers: { 'content-type': 'application/json', ...authHeaders(this.config.speechApiKey) },
|
|
431
|
+
body: JSON.stringify({
|
|
432
|
+
model: this.config.ttsModel,
|
|
433
|
+
voice: this.config.ttsVoice,
|
|
434
|
+
input: text,
|
|
435
|
+
response_format: this.config.ttsFormat,
|
|
436
|
+
sample_rate: this.config.sampleRate,
|
|
437
|
+
stream_format: 'audio'
|
|
438
|
+
})
|
|
439
|
+
});
|
|
294
440
|
if (!response.ok)
|
|
295
441
|
throw new Error(`Speech synthesis failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
|
|
296
442
|
if (this.config.ttsFormat === 'mp3') {
|
|
297
443
|
const audio = Buffer.from(await response.arrayBuffer());
|
|
298
444
|
if (!audio.length)
|
|
299
445
|
throw new Error('Speech synthesis returned no audio');
|
|
300
|
-
this.emit(agentId, {
|
|
446
|
+
this.emit(agentId, {
|
|
447
|
+
type: 'voice_audio_chunk',
|
|
448
|
+
playbackId,
|
|
449
|
+
encoding: 'mp3',
|
|
450
|
+
audio: audio.toString('base64')
|
|
451
|
+
});
|
|
301
452
|
return;
|
|
302
453
|
}
|
|
303
454
|
if (!response.body)
|
|
@@ -313,7 +464,12 @@ export class VoiceManager {
|
|
|
313
464
|
const bytes = pending ? Buffer.concat([pending, Buffer.from(value)]) : Buffer.from(value);
|
|
314
465
|
const evenLength = bytes.length & ~1;
|
|
315
466
|
if (evenLength)
|
|
316
|
-
this.emit(agentId, {
|
|
467
|
+
this.emit(agentId, {
|
|
468
|
+
type: 'voice_audio_chunk',
|
|
469
|
+
playbackId,
|
|
470
|
+
sampleRate: this.config.sampleRate,
|
|
471
|
+
audio: bytes.subarray(0, evenLength).toString('base64')
|
|
472
|
+
});
|
|
317
473
|
pending = evenLength < bytes.length ? Buffer.from(bytes.subarray(evenLength)) : undefined;
|
|
318
474
|
}
|
|
319
475
|
if (pending)
|
package/dist/workspaces.js
CHANGED
|
@@ -7,31 +7,88 @@ export class WorkspaceStore {
|
|
|
7
7
|
constructor(items = []) {
|
|
8
8
|
this.items = items;
|
|
9
9
|
}
|
|
10
|
-
list() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
10
|
+
list() {
|
|
11
|
+
return this.items.map(x => ({ ...x }));
|
|
12
|
+
}
|
|
13
|
+
replace(items) {
|
|
14
|
+
this.items = items.map(item => ({ ...item, rootPath: path.resolve(item.rootPath) }));
|
|
15
|
+
}
|
|
16
|
+
add(label, rootPath) {
|
|
17
|
+
const root = path.resolve(rootPath);
|
|
18
|
+
let rootStat;
|
|
19
|
+
try {
|
|
20
|
+
rootStat = statSync(root);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT' ||
|
|
24
|
+
error.code === 'ENOTDIR')
|
|
25
|
+
throw Object.assign(new Error('Workspace root does not exist'), { code: 'WORKSPACE_ROOT_NOT_FOUND' });
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
if (!rootStat.isDirectory())
|
|
29
|
+
throw Object.assign(new Error('Workspace root is not a directory'), {
|
|
30
|
+
code: 'WORKSPACE_ROOT_NOT_DIRECTORY'
|
|
31
|
+
});
|
|
32
|
+
const key = process.platform === 'win32' ? root.toLowerCase() : root;
|
|
33
|
+
if (this.items.some(item => (process.platform === 'win32' ? item.rootPath.toLowerCase() : item.rootPath) === key))
|
|
34
|
+
throw Object.assign(new Error('Workspace root already exists'), { code: 'WORKSPACE_ALREADY_EXISTS' });
|
|
35
|
+
const item = { id: randomUUID(), label, rootPath: root, createdAt: new Date().toISOString() };
|
|
36
|
+
this.items.push(item);
|
|
37
|
+
return item;
|
|
38
|
+
}
|
|
39
|
+
get(id) {
|
|
40
|
+
return this.items.find(x => x.id === id);
|
|
41
|
+
}
|
|
42
|
+
async resolve(ws, relative = '.') {
|
|
43
|
+
const candidate = path.resolve(ws.rootPath, relative);
|
|
44
|
+
const root = await realpath(ws.rootPath);
|
|
45
|
+
let actual;
|
|
46
|
+
try {
|
|
47
|
+
actual = await realpath(candidate);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw Object.assign(new Error('Path does not exist'), { code: 'PATH_NOT_FOUND' });
|
|
51
|
+
}
|
|
52
|
+
if (actual !== root && !actual.startsWith(root + path.sep))
|
|
53
|
+
throw Object.assign(new Error('Path is outside the workspace root'), {
|
|
54
|
+
code: 'WORKSPACE_PATH_OUTSIDE_ROOT'
|
|
55
|
+
});
|
|
56
|
+
return actual;
|
|
57
|
+
}
|
|
58
|
+
async tree(id, relative = '.') {
|
|
59
|
+
const ws = this.get(id);
|
|
60
|
+
if (!ws)
|
|
61
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
62
|
+
const dir = await this.resolve(ws, relative);
|
|
63
|
+
if (!(await stat(dir)).isDirectory())
|
|
64
|
+
throw new Error('Not a directory');
|
|
65
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
66
|
+
return Promise.all(entries
|
|
67
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
68
|
+
.map(async (e) => {
|
|
69
|
+
const p = path.join(dir, e.name);
|
|
70
|
+
const s = await lstat(p);
|
|
71
|
+
return {
|
|
72
|
+
name: e.name,
|
|
73
|
+
type: e.isDirectory() ? 'directory' : 'file',
|
|
74
|
+
size: s.size,
|
|
75
|
+
modifiedAt: s.mtime.toISOString()
|
|
76
|
+
};
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
async stat(id, relative = '.') {
|
|
80
|
+
const ws = this.get(id);
|
|
81
|
+
if (!ws)
|
|
82
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' });
|
|
83
|
+
const p = await this.resolve(ws, relative);
|
|
84
|
+
const s = await lstat(p);
|
|
85
|
+
return {
|
|
86
|
+
path: relative,
|
|
87
|
+
type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other',
|
|
88
|
+
size: s.size,
|
|
89
|
+
modifiedAt: s.mtime.toISOString()
|
|
90
|
+
};
|
|
91
|
+
}
|
|
35
92
|
async file(id, relative, offset = 0, limit = 1024 * 1024) {
|
|
36
93
|
const ws = this.get(id);
|
|
37
94
|
if (!ws)
|
|
@@ -55,6 +112,14 @@ export class WorkspaceStore {
|
|
|
55
112
|
await handle.close();
|
|
56
113
|
}
|
|
57
114
|
const binary = chunk.includes(0);
|
|
58
|
-
return {
|
|
115
|
+
return {
|
|
116
|
+
path: relative,
|
|
117
|
+
size: s.size,
|
|
118
|
+
modifiedAt: s.mtime.toISOString(),
|
|
119
|
+
binary,
|
|
120
|
+
content: binary ? undefined : chunk.toString('utf8'),
|
|
121
|
+
offset,
|
|
122
|
+
limit: chunk.length
|
|
123
|
+
};
|
|
59
124
|
}
|
|
60
125
|
}
|