pi2web 0.2.0 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +209 -18
  2. package/dist/activity.js +178 -0
  3. package/dist/agent-feed.js +133 -0
  4. package/dist/agent-templates.js +144 -0
  5. package/dist/agents.js +575 -97
  6. package/dist/auth.js +85 -20
  7. package/dist/collab/baseline.js +84 -0
  8. package/dist/collab/dispatcher.js +221 -0
  9. package/dist/collab/hub.js +2888 -0
  10. package/dist/collab/pi-extension.js +494 -0
  11. package/dist/collab/review-flow.js +459 -0
  12. package/dist/collab/routes.js +285 -0
  13. package/dist/collab/schemas.js +281 -0
  14. package/dist/collab/scoring-flow.js +336 -0
  15. package/dist/collab/similarity.js +15 -0
  16. package/dist/collab/store.js +918 -0
  17. package/dist/collab/types.js +137 -0
  18. package/dist/collab/validate.js +185 -0
  19. package/dist/drafts.js +50 -0
  20. package/dist/http/agent-routes.js +196 -0
  21. package/dist/http/login-limiter.js +53 -0
  22. package/dist/http/mail-settings-routes.js +53 -0
  23. package/dist/http/system-prompt-routes.js +52 -0
  24. package/dist/http/utils.js +43 -0
  25. package/dist/http/web-assets.js +63 -0
  26. package/dist/http/workspace-routes.js +58 -0
  27. package/dist/mail-notifier.js +95 -21
  28. package/dist/main.js +141 -17
  29. package/dist/mcp.js +132 -0
  30. package/dist/metadata-store.js +199 -56
  31. package/dist/paths.js +10 -0
  32. package/dist/sdk-backend.js +288 -49
  33. package/dist/server.js +558 -358
  34. package/dist/session-namer.js +163 -0
  35. package/dist/terminals.js +94 -27
  36. package/dist/timeline.js +185 -0
  37. package/dist/voice.js +196 -40
  38. package/dist/workspaces.js +91 -26
  39. package/dist/ws/agent-socket.js +256 -0
  40. package/dist/ws/terminal-socket.js +46 -0
  41. package/npm-shrinkwrap.json +3685 -0
  42. package/package.json +18 -6
  43. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
  44. package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
  45. package/packages/protocol/openapi.yaml +8 -2
  46. package/packages/protocol/websocket.schema.json +94 -10
  47. package/web/activity-view.js +121 -0
  48. package/web/app.js +3770 -404
  49. package/web/backend-state.js +269 -0
  50. package/web/collab.html +95 -0
  51. package/web/collab.js +1080 -0
  52. package/web/hid-keyboard.js +453 -0
  53. package/web/index.html +14 -9
  54. package/web/styles.css +73 -0
  55. package/ANDROID_APP_FEATURES.md +0 -256
package/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.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n').trim();
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.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
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.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 60).trim();
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?.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').slice(0, 60).trim();
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') ? `会话${name}已完成:${summary}` : `Session ${name} completed: ${summary}`;
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) { this.buffer += delta; return this.consume(false); }
97
- close() { return this.consume(true); }
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 = { language: 'zh-CN', maxInputChars: 32000, maxOutputTokens: 160, sampleRate: 24000, ttsFormat: 'pcm', requestTimeoutMs: 120000, ...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
- if (finalOutput)
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) { this.generations.set(agentId, (this.generations.get(agentId) ?? 0) + 1); const controller = this.active.get(agentId); if (controller) {
208
- controller.abort();
209
- this.active.delete(agentId);
210
- this.emit(agentId, { type: 'voice_cancelled' });
211
- } }
212
- close() { this.closed = true; for (const id of [...new Set([...this.active.keys(), ...this.generations.keys()])])
213
- this.cancel(id); this.emitter.removeAllListeners(); }
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'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs), headers: authHeaders(this.config.speechApiKey), body: form }).catch(error => { throw timeoutError(error) ? Object.assign(new Error(`Speech transcription timed out after ${this.config.requestTimeoutMs} ms`), { code: 'VOICE_STT_FAILED' }) : error; });
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 ? undefined : this.runAnnouncement(agentId, input, 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, { type: 'voice_start', playbackId, encoding: this.config.ttsFormat === 'mp3' ? 'mp3' : 'pcm_s16le', sampleRate: this.config.sampleRate, channels: 1 });
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.fetcher(endpoint(this.config.summaryBaseUrl, '/chat/completions'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs, controller.signal), headers: { 'content-type': 'application/json', ...authHeaders(this.config.summaryApiKey) }, body: JSON.stringify({ model: this.config.summaryModel, stream: true, temperature: 0.2, max_tokens: this.config.maxOutputTokens, messages: [{ role: 'system', content: this.summaryPrompt() }, { role: 'user', content: summaryInput }] }) });
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, { type: 'voice_session_name_error', playbackId, message: error.message });
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, { type: 'voice_end', playbackId, summary: spokenSummary, sessionName: result.sessionName });
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') ? 'Agent 已完成,等待输入。' : 'The agent has finished and is ready for input.';
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 { /* The text notification remains available when TTS also fails. */ }
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
- summaryPrompt() { const language = this.config.language.toLowerCase().startsWith('zh') ? '简体中文' : '与用户输入相同的主要语言'; 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。`; }
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'), { method: 'POST', signal: deadline(this.config.requestTimeoutMs, signal), headers: { 'content-type': 'application/json', ...authHeaders(this.config.speechApiKey) }, body: JSON.stringify({ model: this.config.ttsModel, voice: this.config.ttsVoice, input: text, response_format: this.config.ttsFormat, sample_rate: this.config.sampleRate, stream_format: 'audio' }) });
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, { type: 'voice_audio_chunk', playbackId, encoding: 'mp3', audio: audio.toString('base64') });
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, { type: 'voice_audio_chunk', playbackId, sampleRate: this.config.sampleRate, audio: bytes.subarray(0, evenLength).toString('base64') });
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)
@@ -7,31 +7,88 @@ export class WorkspaceStore {
7
7
  constructor(items = []) {
8
8
  this.items = items;
9
9
  }
10
- list() { return this.items.map(x => ({ ...x })); }
11
- replace(items) { this.items = items.map(item => ({ ...item, rootPath: path.resolve(item.rootPath) })); }
12
- add(label, rootPath) { const root = path.resolve(rootPath); let rootStat; try {
13
- rootStat = statSync(root);
14
- }
15
- catch (error) {
16
- if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
17
- throw Object.assign(new Error('Workspace root does not exist'), { code: 'WORKSPACE_ROOT_NOT_FOUND' });
18
- throw error;
19
- } if (!rootStat.isDirectory())
20
- throw Object.assign(new Error('Workspace root is not a directory'), { code: 'WORKSPACE_ROOT_NOT_DIRECTORY' }); const key = process.platform === 'win32' ? root.toLowerCase() : root; if (this.items.some(item => (process.platform === 'win32' ? item.rootPath.toLowerCase() : item.rootPath) === key))
21
- throw Object.assign(new Error('Workspace root already exists'), { code: 'WORKSPACE_ALREADY_EXISTS' }); const item = { id: randomUUID(), label, rootPath: root, createdAt: new Date().toISOString() }; this.items.push(item); return item; }
22
- get(id) { return this.items.find(x => x.id === id); }
23
- async resolve(ws, relative = '.') { const candidate = path.resolve(ws.rootPath, relative); const root = await realpath(ws.rootPath); let actual; try {
24
- actual = await realpath(candidate);
25
- }
26
- catch {
27
- throw Object.assign(new Error('Path does not exist'), { code: 'PATH_NOT_FOUND' });
28
- } if (actual !== root && !actual.startsWith(root + path.sep))
29
- throw Object.assign(new Error('Path is outside the workspace root'), { code: 'WORKSPACE_PATH_OUTSIDE_ROOT' }); return actual; }
30
- async tree(id, relative = '.') { const ws = this.get(id); if (!ws)
31
- throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const dir = await this.resolve(ws, relative); if (!(await stat(dir)).isDirectory())
32
- throw new Error('Not a directory'); const entries = await readdir(dir, { withFileTypes: true }); return Promise.all(entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)).map(async (e) => { const p = path.join(dir, e.name); const s = await lstat(p); return { name: e.name, type: e.isDirectory() ? 'directory' : 'file', size: s.size, modifiedAt: s.mtime.toISOString() }; })); }
33
- async stat(id, relative = '.') { const ws = this.get(id); if (!ws)
34
- throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const p = await this.resolve(ws, relative); const s = await lstat(p); return { path: relative, type: s.isDirectory() ? 'directory' : s.isFile() ? 'file' : 'other', size: s.size, modifiedAt: s.mtime.toISOString() }; }
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 { path: relative, size: s.size, modifiedAt: s.mtime.toISOString(), binary, content: binary ? undefined : chunk.toString('utf8'), offset, limit: chunk.length };
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
  }