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/dist/voice.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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(['”', '’', '"', '
|
|
4
|
+
const closing = new Set(['”', '’', '"', "'", ')', ')', '】', ']']);
|
|
5
5
|
export const endpoint = (base, path) => `${base.replace(/\/$/, '')}${base.replace(/\/$/, '').endsWith('/v1') ? '' : '/v1'}${path}`;
|
|
6
6
|
export const authHeaders = (key) => key ? { authorization: `Bearer ${key}` } : {};
|
|
7
7
|
const abortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
@@ -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
|
}
|
|
@@ -43,7 +47,10 @@ export function prepareSummaryInput(text, maxChars = 32000) {
|
|
|
43
47
|
return cleaned;
|
|
44
48
|
}
|
|
45
49
|
export function clipSummaryField(text, maxChars) {
|
|
46
|
-
const normalized = text
|
|
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) {
|
|
@@ -174,13 +196,30 @@ export class VoiceManager {
|
|
|
174
196
|
config;
|
|
175
197
|
constructor(config, fetcher = fetch) {
|
|
176
198
|
this.fetcher = fetcher;
|
|
177
|
-
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);
|
|
178
222
|
}
|
|
179
|
-
subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
|
|
180
|
-
capabilities() { return { tts: true, stt: !!this.config.sttModel }; }
|
|
181
|
-
recordUserPrompt(agentId, prompt) { const value = prompt.trim(); if (value)
|
|
182
|
-
this.lastPrompt.set(agentId, value); }
|
|
183
|
-
emit(agentId, event) { this.emitter.emit('event', agentId, event); }
|
|
184
223
|
handleAgentEvent(agentId, event, context = {}) {
|
|
185
224
|
if (event.type === 'before_agent_start' && typeof event.prompt === 'string') {
|
|
186
225
|
this.lastPrompt.set(agentId, event.prompt);
|
|
@@ -207,13 +246,21 @@ export class VoiceManager {
|
|
|
207
246
|
void this.announce(agentId, { userPrompt, finalOutput, ...context });
|
|
208
247
|
}
|
|
209
248
|
}
|
|
210
|
-
cancel(agentId) {
|
|
211
|
-
|
|
212
|
-
this.active.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
+
}
|
|
217
264
|
async transcribe(audio, mimeType = 'audio/webm', filename = 'recording.webm') {
|
|
218
265
|
if (!this.config.sttModel)
|
|
219
266
|
throw Object.assign(new Error('Voice STT model is not configured'), { code: 'VOICE_STT_DISABLED' });
|
|
@@ -223,7 +270,16 @@ export class VoiceManager {
|
|
|
223
270
|
form.append('file', new Blob([copy.buffer], { type: mimeType }), filename);
|
|
224
271
|
form.append('model', this.config.sttModel);
|
|
225
272
|
form.append('response_format', 'json');
|
|
226
|
-
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
|
+
});
|
|
227
283
|
if (!response.ok)
|
|
228
284
|
throw Object.assign(new Error(`Speech transcription failed (${response.status}): ${(await response.text()).slice(0, 500)}`), { code: 'VOICE_STT_FAILED' });
|
|
229
285
|
const value = await response.json();
|
|
@@ -236,14 +292,22 @@ export class VoiceManager {
|
|
|
236
292
|
const input = typeof value === 'string' ? { userPrompt: '', finalOutput: value } : value;
|
|
237
293
|
this.cancel(agentId);
|
|
238
294
|
const generation = this.generations.get(agentId);
|
|
239
|
-
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));
|
|
240
298
|
this.announcementQueue = job.then(() => undefined, () => undefined);
|
|
241
299
|
return job;
|
|
242
300
|
}
|
|
243
301
|
async runAnnouncement(agentId, input, generation) {
|
|
244
302
|
const controller = new AbortController(), playbackId = randomUUID();
|
|
245
303
|
this.active.set(agentId, controller);
|
|
246
|
-
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
|
+
});
|
|
247
311
|
let rawResult = '', spokenSummary = '';
|
|
248
312
|
try {
|
|
249
313
|
const summaryInput = prepareSummaryContext(input, this.config.maxInputChars);
|
|
@@ -257,7 +321,11 @@ export class VoiceManager {
|
|
|
257
321
|
await input.setSessionName(result.sessionName);
|
|
258
322
|
}
|
|
259
323
|
catch (error) {
|
|
260
|
-
this.emit(agentId, {
|
|
324
|
+
this.emit(agentId, {
|
|
325
|
+
type: 'voice_session_name_error',
|
|
326
|
+
playbackId,
|
|
327
|
+
message: error.message
|
|
328
|
+
});
|
|
261
329
|
}
|
|
262
330
|
}
|
|
263
331
|
this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: spokenSummary });
|
|
@@ -265,19 +333,28 @@ export class VoiceManager {
|
|
|
265
333
|
for (const sentence of [...chunker.add(spokenSummary), ...chunker.close()])
|
|
266
334
|
await this.speak(agentId, playbackId, sentence, controller.signal);
|
|
267
335
|
if (this.active.get(agentId) === controller)
|
|
268
|
-
this.emit(agentId, {
|
|
336
|
+
this.emit(agentId, {
|
|
337
|
+
type: 'voice_end',
|
|
338
|
+
playbackId,
|
|
339
|
+
summary: spokenSummary,
|
|
340
|
+
sessionName: result.sessionName
|
|
341
|
+
});
|
|
269
342
|
}
|
|
270
343
|
catch (error) {
|
|
271
344
|
if (!abortError(error) && this.active.get(agentId) === controller) {
|
|
272
345
|
this.emit(agentId, { type: 'voice_error', playbackId, message: error.message });
|
|
273
|
-
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.';
|
|
274
349
|
if (!spokenSummary)
|
|
275
350
|
try {
|
|
276
351
|
this.emit(agentId, { type: 'voice_summary_delta', playbackId, text: fallback });
|
|
277
352
|
await this.speak(agentId, playbackId, fallback, controller.signal);
|
|
278
353
|
this.emit(agentId, { type: 'voice_end', playbackId, summary: fallback });
|
|
279
354
|
}
|
|
280
|
-
catch {
|
|
355
|
+
catch {
|
|
356
|
+
/* The text notification remains available when TTS also fails. */
|
|
357
|
+
}
|
|
281
358
|
else
|
|
282
359
|
this.emit(agentId, { type: 'voice_end', playbackId, summary: spokenSummary, partial: true });
|
|
283
360
|
}
|
|
@@ -292,12 +369,24 @@ export class VoiceManager {
|
|
|
292
369
|
/** Retries once per unsupported parameter so a strict model still produces a summary instead of failing the announcement. */
|
|
293
370
|
async requestSummary(summaryInput, signal) {
|
|
294
371
|
for (let attempt = 0;; attempt++) {
|
|
295
|
-
const body = {
|
|
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
|
+
};
|
|
296
380
|
if (this.summaryParams.temperature && typeof this.config.summaryTemperature === 'number')
|
|
297
381
|
body.temperature = this.config.summaryTemperature;
|
|
298
382
|
if (this.summaryParams.tokenLimitField && this.config.maxOutputTokens)
|
|
299
383
|
body[this.summaryParams.tokenLimitField] = this.config.maxOutputTokens;
|
|
300
|
-
const response = await this.fetcher(endpoint(this.config.summaryBaseUrl, '/chat/completions'), {
|
|
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
|
+
});
|
|
301
390
|
if (response.ok)
|
|
302
391
|
return response;
|
|
303
392
|
const detail = (await response.text()).slice(0, 500);
|
|
@@ -317,7 +406,9 @@ export class VoiceManager {
|
|
|
317
406
|
}
|
|
318
407
|
const field = this.summaryParams.tokenLimitField;
|
|
319
408
|
if (field === 'max_tokens' && text.includes('max_tokens')) {
|
|
320
|
-
this.summaryParams.tokenLimitField = text.includes('max_completion_tokens')
|
|
409
|
+
this.summaryParams.tokenLimitField = text.includes('max_completion_tokens')
|
|
410
|
+
? 'max_completion_tokens'
|
|
411
|
+
: null;
|
|
321
412
|
changed = true;
|
|
322
413
|
}
|
|
323
414
|
else if (field === 'max_completion_tokens' && text.includes('max_completion_tokens')) {
|
|
@@ -326,16 +417,38 @@ export class VoiceManager {
|
|
|
326
417
|
}
|
|
327
418
|
return changed;
|
|
328
419
|
}
|
|
329
|
-
summaryPrompt() {
|
|
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
|
+
}
|
|
330
426
|
async speak(agentId, playbackId, text, signal) {
|
|
331
|
-
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
|
+
});
|
|
332
440
|
if (!response.ok)
|
|
333
441
|
throw new Error(`Speech synthesis failed (${response.status}): ${(await response.text()).slice(0, 500)}`);
|
|
334
442
|
if (this.config.ttsFormat === 'mp3') {
|
|
335
443
|
const audio = Buffer.from(await response.arrayBuffer());
|
|
336
444
|
if (!audio.length)
|
|
337
445
|
throw new Error('Speech synthesis returned no audio');
|
|
338
|
-
this.emit(agentId, {
|
|
446
|
+
this.emit(agentId, {
|
|
447
|
+
type: 'voice_audio_chunk',
|
|
448
|
+
playbackId,
|
|
449
|
+
encoding: 'mp3',
|
|
450
|
+
audio: audio.toString('base64')
|
|
451
|
+
});
|
|
339
452
|
return;
|
|
340
453
|
}
|
|
341
454
|
if (!response.body)
|
|
@@ -351,7 +464,12 @@ export class VoiceManager {
|
|
|
351
464
|
const bytes = pending ? Buffer.concat([pending, Buffer.from(value)]) : Buffer.from(value);
|
|
352
465
|
const evenLength = bytes.length & ~1;
|
|
353
466
|
if (evenLength)
|
|
354
|
-
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
|
+
});
|
|
355
473
|
pending = evenLength < bytes.length ? Buffer.from(bytes.subarray(evenLength)) : undefined;
|
|
356
474
|
}
|
|
357
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
|
}
|