@narumitw/pi-webui 0.21.0 → 0.22.0
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 -12
- package/package.json +3 -1
- package/src/attachments.ts +715 -0
- package/src/conversation.ts +23 -6
- package/src/drafts.ts +226 -0
- package/src/image-limits.ts +36 -0
- package/src/image-pipeline.ts +325 -0
- package/src/images.ts +182 -140
- package/src/pi-settings.ts +11 -6
- package/src/runtime.ts +138 -34
- package/src/sent-images.ts +279 -0
- package/src/server.ts +490 -30
- package/src/settings.ts +102 -7
- package/src/vendor.d.ts +30 -0
- package/src/web/app.js +558 -100
- package/src/web/image-drag.js +86 -0
- package/src/web/index.html +25 -2
- package/src/web/state.js +178 -8
- package/src/web/styles.css +176 -11
- package/src/web/transcript.js +66 -9
package/src/server.ts
CHANGED
|
@@ -4,23 +4,43 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
4
4
|
import type { Socket } from "node:net";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import {
|
|
8
|
+
AttachmentError,
|
|
9
|
+
type AttachmentLimits,
|
|
10
|
+
AttachmentStore,
|
|
11
|
+
type PreparedAttachment,
|
|
12
|
+
type PublicAttachmentState,
|
|
13
|
+
} from "./attachments.js";
|
|
7
14
|
import type { ConversationEvent, ConversationProjection } from "./conversation.js";
|
|
15
|
+
import { DraftError, DraftStore, type PublicDraftState } from "./drafts.js";
|
|
16
|
+
import { type ImageLimits, imageLimits, PROVIDER_IMAGE_LIMITS } from "./image-limits.js";
|
|
8
17
|
import type { BrowserImageInput } from "./images.js";
|
|
18
|
+
import { SentImageError, type SentImageSettings, SentImageStore } from "./sent-images.js";
|
|
9
19
|
|
|
10
20
|
const JSON_LIMIT = 64 * 1024 * 1024;
|
|
11
21
|
const CLIENT_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
12
22
|
const REQUEST_ID = /^[A-Za-z0-9_-]{1,120}$/;
|
|
13
23
|
const MAX_REQUESTS = 128;
|
|
24
|
+
const DEFAULT_MAX_DRAFT_TEXT_BYTES = 1024 * 1024;
|
|
14
25
|
const SSE_FLUSH_TIMEOUT_MS = 250;
|
|
15
26
|
|
|
16
27
|
export interface WebSendRequest {
|
|
17
28
|
requestId: string;
|
|
18
29
|
text: string;
|
|
30
|
+
attachmentRevision?: number;
|
|
31
|
+
attachmentIds?: string[];
|
|
19
32
|
images: BrowserImageInput[];
|
|
33
|
+
retainedImageIds?: string[];
|
|
20
34
|
delivery: "next" | "steer";
|
|
21
35
|
signal?: AbortSignal;
|
|
22
36
|
}
|
|
23
37
|
|
|
38
|
+
interface ParsedSendRequest {
|
|
39
|
+
requestId: string;
|
|
40
|
+
draftRevision: number;
|
|
41
|
+
delivery: "next" | "steer";
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export interface WebSendResult {
|
|
25
45
|
delivery: "immediate" | "followUp" | "steer";
|
|
26
46
|
}
|
|
@@ -28,6 +48,11 @@ export interface WebSendResult {
|
|
|
28
48
|
export interface WebUIServerOptions {
|
|
29
49
|
conversation: ConversationProjection;
|
|
30
50
|
send: (request: WebSendRequest) => Promise<WebSendResult>;
|
|
51
|
+
processAttachment?: (source: Uint8Array, signal?: AbortSignal) => Promise<PreparedAttachment>;
|
|
52
|
+
attachmentLimits?: AttachmentLimits;
|
|
53
|
+
imageLimits?: Readonly<ImageLimits>;
|
|
54
|
+
sentImageSettings?: SentImageSettings;
|
|
55
|
+
maxDraftTextBytes?: number;
|
|
31
56
|
maxRequestBytes?: number;
|
|
32
57
|
}
|
|
33
58
|
|
|
@@ -60,6 +85,13 @@ export class WebUIServer {
|
|
|
60
85
|
private readonly sseClients = new Set<SseClient>();
|
|
61
86
|
private readonly requests = new Map<string, RequestRecord>();
|
|
62
87
|
private readonly activeSendControllers = new Set<AbortController>();
|
|
88
|
+
private readonly activeAttachmentControllers = new Set<AbortController>();
|
|
89
|
+
private readonly attachments: AttachmentStore;
|
|
90
|
+
private readonly attachmentLimits: AttachmentLimits;
|
|
91
|
+
private readonly imageLimits: Readonly<ImageLimits>;
|
|
92
|
+
private readonly draft: DraftStore;
|
|
93
|
+
private readonly sentImages: SentImageStore;
|
|
94
|
+
private readonly imageResidentBudget: number;
|
|
63
95
|
private activeClientId?: string;
|
|
64
96
|
private leaseGeneration = 0;
|
|
65
97
|
private closed = false;
|
|
@@ -72,6 +104,49 @@ export class WebUIServer {
|
|
|
72
104
|
port: number,
|
|
73
105
|
) {
|
|
74
106
|
this.origin = `http://127.0.0.1:${port}`;
|
|
107
|
+
this.imageLimits = options.imageLimits
|
|
108
|
+
? imageLimits(options.imageLimits)
|
|
109
|
+
: imageLimits({
|
|
110
|
+
maxImages: options.attachmentLimits?.maxImages,
|
|
111
|
+
maxImageBytes: options.attachmentLimits?.maxImageBytes,
|
|
112
|
+
maxBatchBytes: options.attachmentLimits?.maxPromptBytes,
|
|
113
|
+
});
|
|
114
|
+
this.attachmentLimits = {
|
|
115
|
+
maxImages: this.imageLimits.maxImages,
|
|
116
|
+
maxImageBytes: this.imageLimits.maxImageBytes,
|
|
117
|
+
maxPromptBytes: this.imageLimits.maxBatchBytes,
|
|
118
|
+
maxPreparedImageBytes: PROVIDER_IMAGE_LIMITS.maxBase64Bytes,
|
|
119
|
+
};
|
|
120
|
+
this.draft = new DraftStore({
|
|
121
|
+
maxTextBytes: options.maxDraftTextBytes ?? DEFAULT_MAX_DRAFT_TEXT_BYTES,
|
|
122
|
+
});
|
|
123
|
+
const sentImageSettings = options.sentImageSettings ?? {
|
|
124
|
+
enabled: false,
|
|
125
|
+
maxImages: 32,
|
|
126
|
+
maxBytes: 128 * 1024 * 1024,
|
|
127
|
+
};
|
|
128
|
+
this.sentImages = new SentImageStore(sentImageSettings);
|
|
129
|
+
this.imageResidentBudget = Math.max(
|
|
130
|
+
sentImageSettings.maxBytes,
|
|
131
|
+
this.attachmentLimits.maxPromptBytes +
|
|
132
|
+
Math.max(
|
|
133
|
+
this.attachmentLimits.maxImageBytes,
|
|
134
|
+
this.attachmentLimits.maxPreparedImageBytes ?? 0,
|
|
135
|
+
) *
|
|
136
|
+
2,
|
|
137
|
+
);
|
|
138
|
+
this.attachments = new AttachmentStore({
|
|
139
|
+
limits: this.attachmentLimits,
|
|
140
|
+
process:
|
|
141
|
+
options.processAttachment ??
|
|
142
|
+
(async () => {
|
|
143
|
+
throw new Error("Image processing is unavailable.");
|
|
144
|
+
}),
|
|
145
|
+
onChange: (state) => {
|
|
146
|
+
this.reconcileSentImageBytes(state);
|
|
147
|
+
this.broadcastControl("attachments", state);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
75
150
|
server.on("connection", (socket) => {
|
|
76
151
|
this.sockets.add(socket);
|
|
77
152
|
socket.once("close", () => this.sockets.delete(socket));
|
|
@@ -123,7 +198,12 @@ export class WebUIServer {
|
|
|
123
198
|
this.bootstrapToken = undefined;
|
|
124
199
|
this.unsubscribe();
|
|
125
200
|
for (const controller of this.activeSendControllers) controller.abort();
|
|
201
|
+
for (const controller of this.activeAttachmentControllers) controller.abort();
|
|
126
202
|
this.activeSendControllers.clear();
|
|
203
|
+
this.activeAttachmentControllers.clear();
|
|
204
|
+
this.attachments.close();
|
|
205
|
+
this.draft.close();
|
|
206
|
+
this.sentImages.close();
|
|
127
207
|
this.requests.clear();
|
|
128
208
|
const responses = [...this.sseClients].map((client) => client.response);
|
|
129
209
|
this.broadcastControl("session-ended", { message: "Pi session ended" });
|
|
@@ -153,7 +233,9 @@ export class WebUIServer {
|
|
|
153
233
|
}
|
|
154
234
|
if (
|
|
155
235
|
request.method === "GET" &&
|
|
156
|
-
["/app.js", "/state.js", "/markdown.js", "/transcript.js"].includes(
|
|
236
|
+
["/app.js", "/state.js", "/markdown.js", "/transcript.js", "/image-drag.js"].includes(
|
|
237
|
+
url.pathname,
|
|
238
|
+
)
|
|
157
239
|
) {
|
|
158
240
|
await this.asset(response, url.pathname.slice(1), "text/javascript; charset=utf-8");
|
|
159
241
|
return;
|
|
@@ -166,7 +248,31 @@ export class WebUIServer {
|
|
|
166
248
|
this.json(response, 200, {
|
|
167
249
|
...this.options.conversation.snapshot(),
|
|
168
250
|
lease: this.leaseSnapshot(),
|
|
251
|
+
draft: this.draft.publicState(),
|
|
252
|
+
attachments: this.attachments.publicState(),
|
|
253
|
+
imageLimits: this.imageLimits,
|
|
254
|
+
sentImages: this.sentImages.publicState(),
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const sentPreviewId = sentImagePathId(url.pathname, "preview");
|
|
259
|
+
if (request.method === "GET" && sentPreviewId) {
|
|
260
|
+
const preview = this.sentImages.preview(sentPreviewId);
|
|
261
|
+
response.writeHead(200, {
|
|
262
|
+
"Content-Type": preview.mimeType,
|
|
263
|
+
"Content-Length": preview.bytes.byteLength,
|
|
169
264
|
});
|
|
265
|
+
response.end(preview.bytes);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const previewId = attachmentPathId(url.pathname, "preview");
|
|
269
|
+
if (request.method === "GET" && previewId) {
|
|
270
|
+
const preview = this.attachments.preview(previewId);
|
|
271
|
+
response.writeHead(200, {
|
|
272
|
+
"Content-Type": preview.mimeType,
|
|
273
|
+
"Content-Length": preview.bytes.byteLength,
|
|
274
|
+
});
|
|
275
|
+
response.end(preview.bytes);
|
|
170
276
|
return;
|
|
171
277
|
}
|
|
172
278
|
if (request.method === "GET" && url.pathname === "/api/events") {
|
|
@@ -184,6 +290,8 @@ export class WebUIServer {
|
|
|
184
290
|
this.activeClientId = clientId;
|
|
185
291
|
this.leaseGeneration += 1;
|
|
186
292
|
for (const controller of this.activeSendControllers) controller.abort();
|
|
293
|
+
for (const controller of this.activeAttachmentControllers) controller.abort();
|
|
294
|
+
this.attachments.cancelInFlight("Editing moved to another browser tab.");
|
|
187
295
|
this.broadcastControl("lease", {
|
|
188
296
|
activeClientId: clientId,
|
|
189
297
|
generation: this.leaseGeneration,
|
|
@@ -195,17 +303,162 @@ export class WebUIServer {
|
|
|
195
303
|
return;
|
|
196
304
|
}
|
|
197
305
|
const lease = this.assertActiveClient(request);
|
|
306
|
+
if (request.method === "POST" && url.pathname === "/api/draft") {
|
|
307
|
+
const body = requireRecord(
|
|
308
|
+
await readJson(
|
|
309
|
+
request,
|
|
310
|
+
(this.options.maxDraftTextBytes ?? DEFAULT_MAX_DRAFT_TEXT_BYTES) + 8192,
|
|
311
|
+
),
|
|
312
|
+
"draft mutation",
|
|
313
|
+
);
|
|
314
|
+
this.assertLease(lease);
|
|
315
|
+
const state = this.draft.setText(
|
|
316
|
+
stringField(body, "text"),
|
|
317
|
+
numberField(body, "revision"),
|
|
318
|
+
stringField(body, "requestId"),
|
|
319
|
+
);
|
|
320
|
+
this.assertLease(lease);
|
|
321
|
+
this.broadcastDraft(state);
|
|
322
|
+
this.json(response, 200, state);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (request.method === "POST" && url.pathname === "/api/sent-images/reattach") {
|
|
326
|
+
const body = requireRecord(await readJson(request, 64 * 1024), "sent-image reattach");
|
|
327
|
+
this.assertLease(lease);
|
|
328
|
+
const items = parseSentImageReattachments(body.items);
|
|
329
|
+
const clones = this.sentImages.clone(items.map((item) => item.retainedId));
|
|
330
|
+
const attachments = this.attachments.attachPrepared(
|
|
331
|
+
clones.map((clone, index) => ({
|
|
332
|
+
id: items[index]?.id ?? "",
|
|
333
|
+
name: clone.name,
|
|
334
|
+
prepared: { bytes: clone.bytes, mimeType: clone.mimeType, notes: [] },
|
|
335
|
+
})),
|
|
336
|
+
numberField(body, "revision"),
|
|
337
|
+
);
|
|
338
|
+
this.assertLease(lease);
|
|
339
|
+
const draft = this.syncDraftAttachments(attachments);
|
|
340
|
+
this.json(response, 200, {
|
|
341
|
+
attachments,
|
|
342
|
+
draft,
|
|
343
|
+
sentImages: this.sentImages.publicState(),
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const sentDeleteId = sentImagePathId(url.pathname);
|
|
348
|
+
if (request.method === "DELETE" && sentDeleteId) {
|
|
349
|
+
const state = this.sentImages.remove(sentDeleteId, revisionParameter(url));
|
|
350
|
+
this.assertLease(lease);
|
|
351
|
+
this.broadcastSentImages(state);
|
|
352
|
+
this.json(response, 200, state);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (request.method === "POST" && url.pathname === "/api/sent-images/clear") {
|
|
356
|
+
const body = requireRecord(await readJson(request, 8 * 1024), "sent-image clear");
|
|
357
|
+
const state = this.sentImages.clear(numberField(body, "revision"));
|
|
358
|
+
this.assertLease(lease);
|
|
359
|
+
this.broadcastSentImages(state);
|
|
360
|
+
this.json(response, 200, state);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (request.method === "POST" && url.pathname === "/api/attachments/reserve") {
|
|
364
|
+
const body = requireRecord(await readJson(request, 64 * 1024), "attachment reservation");
|
|
365
|
+
this.assertLease(lease);
|
|
366
|
+
const state = this.attachments.reserve(
|
|
367
|
+
parseAttachmentReservations(body.items),
|
|
368
|
+
numberField(body, "revision"),
|
|
369
|
+
);
|
|
370
|
+
this.assertLease(lease);
|
|
371
|
+
this.syncDraftAttachments(state);
|
|
372
|
+
this.json(response, 201, state);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const uploadId = attachmentPathId(url.pathname, "upload");
|
|
376
|
+
if (request.method === "POST" && uploadId) {
|
|
377
|
+
const revision = revisionParameter(url);
|
|
378
|
+
let source: Buffer;
|
|
379
|
+
try {
|
|
380
|
+
source = await readBytes(request, this.attachmentLimits.maxImageBytes);
|
|
381
|
+
} catch (error) {
|
|
382
|
+
this.assertLease(lease);
|
|
383
|
+
try {
|
|
384
|
+
this.attachments.failUpload(uploadId, `Upload failed: ${formatError(error)}`);
|
|
385
|
+
} catch (failure) {
|
|
386
|
+
if (!(failure instanceof AttachmentError) || failure.status !== 409) throw failure;
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
this.assertLease(lease);
|
|
391
|
+
const state = await this.runAttachmentOperation(request, response, (signal) =>
|
|
392
|
+
this.attachments.upload(uploadId, source, revision, signal),
|
|
393
|
+
);
|
|
394
|
+
this.assertLease(lease);
|
|
395
|
+
this.json(response, 200, state);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const retryId = attachmentPathId(url.pathname, "retry");
|
|
399
|
+
if (request.method === "POST" && retryId) {
|
|
400
|
+
const body = requireRecord(await readJson(request, 8 * 1024), "attachment retry");
|
|
401
|
+
this.assertLease(lease);
|
|
402
|
+
const state = await this.runAttachmentOperation(request, response, (signal) =>
|
|
403
|
+
this.attachments.retry(retryId, numberField(body, "revision"), signal),
|
|
404
|
+
);
|
|
405
|
+
this.assertLease(lease);
|
|
406
|
+
this.json(response, 200, state);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const deleteId = attachmentPathId(url.pathname);
|
|
410
|
+
if (request.method === "DELETE" && deleteId) {
|
|
411
|
+
const state = this.attachments.remove(deleteId, revisionParameter(url));
|
|
412
|
+
this.assertLease(lease);
|
|
413
|
+
this.syncDraftAttachments(state);
|
|
414
|
+
this.json(response, 200, state);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (request.method === "POST" && url.pathname === "/api/attachments/reorder") {
|
|
418
|
+
const body = requireRecord(await readJson(request, 64 * 1024), "attachment reorder");
|
|
419
|
+
this.assertLease(lease);
|
|
420
|
+
const state = this.attachments.reorder(
|
|
421
|
+
stringArrayField(body, "ids"),
|
|
422
|
+
numberField(body, "revision"),
|
|
423
|
+
);
|
|
424
|
+
this.assertLease(lease);
|
|
425
|
+
this.syncDraftAttachments(state);
|
|
426
|
+
this.json(response, 200, state);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (request.method === "POST" && url.pathname === "/api/attachments/clear") {
|
|
430
|
+
const body = requireRecord(await readJson(request, 8 * 1024), "attachment clear");
|
|
431
|
+
this.assertLease(lease);
|
|
432
|
+
const state = this.attachments.clear(numberField(body, "revision"));
|
|
433
|
+
this.assertLease(lease);
|
|
434
|
+
this.syncDraftAttachments(state);
|
|
435
|
+
this.json(response, 200, state);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
198
438
|
if (request.method === "POST" && url.pathname === "/api/messages") {
|
|
199
439
|
const body = await readJson(request, this.options.maxRequestBytes ?? JSON_LIMIT);
|
|
200
440
|
this.assertLease(lease);
|
|
201
441
|
const message = parseSendRequest(body);
|
|
202
442
|
const result = await this.sendDeduplicated(message, request, response);
|
|
203
|
-
this.json(response, 202, {
|
|
443
|
+
this.json(response, 202, {
|
|
444
|
+
accepted: true,
|
|
445
|
+
requestId: message.requestId,
|
|
446
|
+
...result,
|
|
447
|
+
draft: this.draft.publicState(),
|
|
448
|
+
attachments: this.attachments.publicState(),
|
|
449
|
+
sentImages: this.sentImages.publicState(),
|
|
450
|
+
});
|
|
204
451
|
return;
|
|
205
452
|
}
|
|
206
453
|
throw new HttpError(404, "Not found");
|
|
207
454
|
} catch (error) {
|
|
208
|
-
const status =
|
|
455
|
+
const status =
|
|
456
|
+
error instanceof HttpError ||
|
|
457
|
+
error instanceof AttachmentError ||
|
|
458
|
+
error instanceof DraftError ||
|
|
459
|
+
error instanceof SentImageError
|
|
460
|
+
? error.status
|
|
461
|
+
: 500;
|
|
209
462
|
if (error instanceof HttpError && error.closeConnection)
|
|
210
463
|
response.setHeader("Connection", "close");
|
|
211
464
|
this.json(response, status, { error: formatError(error) });
|
|
@@ -253,8 +506,27 @@ export class WebUIServer {
|
|
|
253
506
|
}
|
|
254
507
|
}
|
|
255
508
|
|
|
509
|
+
private async runAttachmentOperation(
|
|
510
|
+
request: IncomingMessage,
|
|
511
|
+
response: ServerResponse,
|
|
512
|
+
operation: (signal: AbortSignal) => PublicAttachmentState | Promise<PublicAttachmentState>,
|
|
513
|
+
): Promise<PublicAttachmentState> {
|
|
514
|
+
const controller = new AbortController();
|
|
515
|
+
this.activeAttachmentControllers.add(controller);
|
|
516
|
+
const abort = () => controller.abort();
|
|
517
|
+
request.once("aborted", abort);
|
|
518
|
+
response.once("close", abort);
|
|
519
|
+
try {
|
|
520
|
+
return await operation(controller.signal);
|
|
521
|
+
} finally {
|
|
522
|
+
request.off("aborted", abort);
|
|
523
|
+
response.off("close", abort);
|
|
524
|
+
this.activeAttachmentControllers.delete(controller);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
256
528
|
private async sendDeduplicated(
|
|
257
|
-
message:
|
|
529
|
+
message: ParsedSendRequest,
|
|
258
530
|
request: IncomingMessage,
|
|
259
531
|
response: ServerResponse,
|
|
260
532
|
): Promise<WebSendResult> {
|
|
@@ -265,14 +537,75 @@ export class WebUIServer {
|
|
|
265
537
|
throw new HttpError(409, "Request id was reused with different content");
|
|
266
538
|
return current.promise;
|
|
267
539
|
}
|
|
540
|
+
const draftReservation = this.draft.beginSend(message.draftRevision);
|
|
541
|
+
const attachmentState = this.attachments.publicState();
|
|
542
|
+
if (
|
|
543
|
+
!sameIds(
|
|
544
|
+
draftReservation.attachmentIds,
|
|
545
|
+
attachmentState.items.map((item) => item.id),
|
|
546
|
+
)
|
|
547
|
+
) {
|
|
548
|
+
this.draft.finishSend(draftReservation.token, false);
|
|
549
|
+
throw new HttpError(409, "Draft attachment references are stale");
|
|
550
|
+
}
|
|
551
|
+
let attachmentReservation: ReturnType<AttachmentStore["beginSend"]> | undefined;
|
|
552
|
+
try {
|
|
553
|
+
attachmentReservation =
|
|
554
|
+
draftReservation.attachmentIds.length > 0
|
|
555
|
+
? this.attachments.beginSend(draftReservation.attachmentIds, attachmentState.revision)
|
|
556
|
+
: undefined;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
this.draft.finishSend(draftReservation.token, false);
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
const retainedImageIds = this.sentImages.referencesFor(
|
|
562
|
+
message.requestId,
|
|
563
|
+
attachmentReservation?.images ?? [],
|
|
564
|
+
);
|
|
268
565
|
const controller = new AbortController();
|
|
269
566
|
this.activeSendControllers.add(controller);
|
|
270
567
|
const abort = () => controller.abort();
|
|
271
568
|
request.once("aborted", abort);
|
|
272
569
|
response.once("close", abort);
|
|
570
|
+
let reservationsSettled = false;
|
|
273
571
|
const promise = this.options
|
|
274
|
-
.send({
|
|
572
|
+
.send({
|
|
573
|
+
requestId: message.requestId,
|
|
574
|
+
text: draftReservation.text,
|
|
575
|
+
attachmentRevision: attachmentState.revision,
|
|
576
|
+
attachmentIds: draftReservation.attachmentIds,
|
|
577
|
+
images: attachmentReservation?.images ?? [],
|
|
578
|
+
retainedImageIds,
|
|
579
|
+
delivery: message.delivery,
|
|
580
|
+
signal: controller.signal,
|
|
581
|
+
})
|
|
275
582
|
.then((result) => {
|
|
583
|
+
if (retainedImageIds.length > 0) {
|
|
584
|
+
try {
|
|
585
|
+
const before = this.sentImages.publicState().revision;
|
|
586
|
+
this.sentImages.commit(
|
|
587
|
+
message.requestId,
|
|
588
|
+
attachmentReservation?.images ?? [],
|
|
589
|
+
retainedImageIds,
|
|
590
|
+
);
|
|
591
|
+
const retained = this.sentImages.reconcile(
|
|
592
|
+
attachmentState.totalResidentBytes,
|
|
593
|
+
this.imageResidentBudget,
|
|
594
|
+
);
|
|
595
|
+
if (retained.revision !== before) this.broadcastSentImages(retained);
|
|
596
|
+
} catch {
|
|
597
|
+
// Retention is optional and must not turn an accepted Pi message into a failure.
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const nextAttachments = attachmentReservation
|
|
601
|
+
? this.attachments.finishSend(attachmentReservation.token, true)
|
|
602
|
+
: this.attachments.publicState();
|
|
603
|
+
const nextDraft = this.draft.finishSend(draftReservation.token, true, {
|
|
604
|
+
revision: nextAttachments.revision,
|
|
605
|
+
ids: nextAttachments.items.map((item) => item.id),
|
|
606
|
+
});
|
|
607
|
+
this.broadcastDraft(nextDraft);
|
|
608
|
+
reservationsSettled = true;
|
|
276
609
|
const record = this.requests.get(message.requestId);
|
|
277
610
|
if (record?.promise === promise) {
|
|
278
611
|
record.settled = true;
|
|
@@ -281,6 +614,17 @@ export class WebUIServer {
|
|
|
281
614
|
return result;
|
|
282
615
|
})
|
|
283
616
|
.catch((error) => {
|
|
617
|
+
if (!reservationsSettled) {
|
|
618
|
+
try {
|
|
619
|
+
if (attachmentReservation) {
|
|
620
|
+
this.attachments.finishSend(attachmentReservation.token, false);
|
|
621
|
+
}
|
|
622
|
+
this.draft.finishSend(draftReservation.token, false);
|
|
623
|
+
} catch {
|
|
624
|
+
// Session shutdown may already have released the reservations.
|
|
625
|
+
}
|
|
626
|
+
reservationsSettled = true;
|
|
627
|
+
}
|
|
284
628
|
if (this.requests.get(message.requestId)?.promise === promise) {
|
|
285
629
|
this.requests.delete(message.requestId);
|
|
286
630
|
}
|
|
@@ -326,6 +670,10 @@ export class WebUIServer {
|
|
|
326
670
|
return;
|
|
327
671
|
}
|
|
328
672
|
this.writeSse(client, "lease", this.leaseSnapshot());
|
|
673
|
+
this.writeSse(client, "draft", this.draft.publicState());
|
|
674
|
+
this.writeSse(client, "attachments", this.attachments.publicState());
|
|
675
|
+
this.writeSse(client, "image-limits", this.imageLimits);
|
|
676
|
+
this.writeSse(client, "sent-images", this.sentImages.publicState());
|
|
329
677
|
const replay = this.options.conversation.eventsAfter(since);
|
|
330
678
|
if (replay === undefined) {
|
|
331
679
|
this.writeSse(client, "snapshot", this.options.conversation.snapshot());
|
|
@@ -342,6 +690,40 @@ export class WebUIServer {
|
|
|
342
690
|
};
|
|
343
691
|
}
|
|
344
692
|
|
|
693
|
+
private syncDraftAttachments(attachments: PublicAttachmentState): PublicDraftState {
|
|
694
|
+
const before = this.draft.publicState().revision;
|
|
695
|
+
const next = this.draft.syncAttachments(
|
|
696
|
+
attachments.items.map((item) => item.id),
|
|
697
|
+
attachments.revision,
|
|
698
|
+
);
|
|
699
|
+
if (next.revision !== before) this.broadcastDraft(next);
|
|
700
|
+
return next;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private broadcastDraft(state: PublicDraftState): void {
|
|
704
|
+
this.broadcastControl("draft", state);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private broadcastSentImages(state: ReturnType<SentImageStore["publicState"]>): void {
|
|
708
|
+
this.broadcastControl("sent-images", state);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private reconcileSentImageBytes(attachments = this.attachments.publicState()): void {
|
|
712
|
+
const processingReserve =
|
|
713
|
+
attachments.phase === "processing"
|
|
714
|
+
? Math.max(
|
|
715
|
+
this.attachmentLimits.maxImageBytes,
|
|
716
|
+
this.attachmentLimits.maxPreparedImageBytes ?? 0,
|
|
717
|
+
) * 2
|
|
718
|
+
: 0;
|
|
719
|
+
const before = this.sentImages.publicState().revision;
|
|
720
|
+
const retained = this.sentImages.reconcile(
|
|
721
|
+
attachments.totalResidentBytes + processingReserve,
|
|
722
|
+
this.imageResidentBudget,
|
|
723
|
+
);
|
|
724
|
+
if (retained.revision !== before) this.broadcastSentImages(retained);
|
|
725
|
+
}
|
|
726
|
+
|
|
345
727
|
private broadcastEvent(event: ConversationEvent): void {
|
|
346
728
|
for (const client of [...this.sseClients]) this.writeSse(client, "conversation", event);
|
|
347
729
|
}
|
|
@@ -421,46 +803,42 @@ async function readAsset(name: string): Promise<Buffer> {
|
|
|
421
803
|
}
|
|
422
804
|
}
|
|
423
805
|
|
|
424
|
-
function messageDigest(message:
|
|
806
|
+
function messageDigest(message: ParsedSendRequest): string {
|
|
425
807
|
const hash = createHash("sha256");
|
|
426
|
-
for (const value of [message.requestId, message.delivery, message.
|
|
808
|
+
for (const value of [message.requestId, message.delivery, String(message.draftRevision)]) {
|
|
427
809
|
hash
|
|
428
810
|
.update(String(Buffer.byteLength(value)))
|
|
429
811
|
.update(":")
|
|
430
812
|
.update(value);
|
|
431
813
|
}
|
|
432
|
-
for (const image of message.images) {
|
|
433
|
-
for (const value of [image.name ?? "", image.mimeType ?? "", image.data]) {
|
|
434
|
-
hash
|
|
435
|
-
.update(String(Buffer.byteLength(value)))
|
|
436
|
-
.update(":")
|
|
437
|
-
.update(value);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
814
|
return hash.digest("hex");
|
|
441
815
|
}
|
|
442
816
|
|
|
443
|
-
function parseSendRequest(value: unknown):
|
|
817
|
+
function parseSendRequest(value: unknown): ParsedSendRequest {
|
|
444
818
|
if (!isRecord(value)) throw new HttpError(400, "Invalid message request");
|
|
445
819
|
const requestId = stringField(value, "requestId");
|
|
446
820
|
if (!REQUEST_ID.test(requestId)) throw new HttpError(400, "Invalid request id");
|
|
447
|
-
const
|
|
448
|
-
const imagesValue = value.images;
|
|
449
|
-
if (!Array.isArray(imagesValue)) throw new HttpError(400, "Invalid image list");
|
|
450
|
-
if (!text.trim() && imagesValue.length === 0) throw new HttpError(400, "Message cannot be empty");
|
|
821
|
+
const draftRevision = numberField(value, "draftRevision");
|
|
451
822
|
const delivery = value.delivery;
|
|
452
823
|
if (delivery !== "next" && delivery !== "steer")
|
|
453
824
|
throw new HttpError(400, "Invalid delivery mode");
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
825
|
+
return { requestId, draftRevision, delivery };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function readBytes(request: IncomingMessage, limit: number): Promise<Buffer> {
|
|
829
|
+
const length = Number(request.headers["content-length"] ?? "0");
|
|
830
|
+
if (Number.isFinite(length) && length > limit) {
|
|
831
|
+
throw new HttpError(413, "Request body is too large", true);
|
|
832
|
+
}
|
|
833
|
+
const chunks: Buffer[] = [];
|
|
834
|
+
let total = 0;
|
|
835
|
+
for await (const chunk of request) {
|
|
836
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
837
|
+
total += bytes.byteLength;
|
|
838
|
+
if (total > limit) throw new HttpError(413, "Request body is too large", true);
|
|
839
|
+
chunks.push(bytes);
|
|
840
|
+
}
|
|
841
|
+
return Buffer.concat(chunks);
|
|
464
842
|
}
|
|
465
843
|
|
|
466
844
|
async function readJson(request: IncomingMessage, limit: number): Promise<unknown> {
|
|
@@ -479,12 +857,94 @@ async function readJson(request: IncomingMessage, limit: number): Promise<unknow
|
|
|
479
857
|
}
|
|
480
858
|
}
|
|
481
859
|
|
|
860
|
+
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
861
|
+
if (!isRecord(value)) throw new HttpError(400, `Invalid ${label}`);
|
|
862
|
+
return value;
|
|
863
|
+
}
|
|
864
|
+
|
|
482
865
|
function stringField(value: Record<string, unknown>, key: string): string {
|
|
483
866
|
const field = value[key];
|
|
484
867
|
if (typeof field !== "string") throw new HttpError(400, `Invalid ${key}`);
|
|
485
868
|
return field;
|
|
486
869
|
}
|
|
487
870
|
|
|
871
|
+
function numberField(value: Record<string, unknown>, key: string): number {
|
|
872
|
+
const field = value[key];
|
|
873
|
+
if (!Number.isSafeInteger(field) || (field as number) < 0) {
|
|
874
|
+
throw new HttpError(400, `Invalid ${key}`);
|
|
875
|
+
}
|
|
876
|
+
return field as number;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function stringArrayField(value: Record<string, unknown>, key: string): string[] {
|
|
880
|
+
const field = value[key];
|
|
881
|
+
if (!Array.isArray(field) || field.some((item) => typeof item !== "string")) {
|
|
882
|
+
throw new HttpError(400, `Invalid ${key}`);
|
|
883
|
+
}
|
|
884
|
+
return field;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function parseAttachmentReservations(value: unknown): Array<{
|
|
888
|
+
id: string;
|
|
889
|
+
name: string;
|
|
890
|
+
size: number;
|
|
891
|
+
mimeType?: string;
|
|
892
|
+
}> {
|
|
893
|
+
if (!Array.isArray(value)) throw new HttpError(400, "Invalid attachment list");
|
|
894
|
+
return value.map((entry) => {
|
|
895
|
+
const item = requireRecord(entry, "attachment");
|
|
896
|
+
return {
|
|
897
|
+
id: stringField(item, "id"),
|
|
898
|
+
name: stringField(item, "name"),
|
|
899
|
+
size: numberField(item, "size"),
|
|
900
|
+
...(typeof item.mimeType === "string" ? { mimeType: item.mimeType } : {}),
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function parseSentImageReattachments(value: unknown): Array<{
|
|
906
|
+
retainedId: string;
|
|
907
|
+
id: string;
|
|
908
|
+
}> {
|
|
909
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
910
|
+
throw new HttpError(400, "Invalid sent-image reattach list");
|
|
911
|
+
}
|
|
912
|
+
return value.map((entry) => {
|
|
913
|
+
const item = requireRecord(entry, "sent-image reattach item");
|
|
914
|
+
return {
|
|
915
|
+
retainedId: stringField(item, "retainedId"),
|
|
916
|
+
id: stringField(item, "id"),
|
|
917
|
+
};
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function revisionParameter(url: URL): number {
|
|
922
|
+
const value = url.searchParams.get("revision");
|
|
923
|
+
if (!value || !/^\d+$/.test(value)) throw new HttpError(400, "Invalid attachment revision");
|
|
924
|
+
const revision = Number(value);
|
|
925
|
+
if (!Number.isSafeInteger(revision)) throw new HttpError(400, "Invalid attachment revision");
|
|
926
|
+
return revision;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function attachmentPathId(
|
|
930
|
+
pathname: string,
|
|
931
|
+
action?: "upload" | "retry" | "preview",
|
|
932
|
+
): string | undefined {
|
|
933
|
+
const suffix = action ? `/${action}` : "";
|
|
934
|
+
const match = new RegExp(`^/api/attachments/([A-Za-z0-9_-]{1,128})${suffix}$`).exec(pathname);
|
|
935
|
+
return match?.[1];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function sentImagePathId(pathname: string, action?: "preview"): string | undefined {
|
|
939
|
+
const suffix = action ? `/${action}` : "";
|
|
940
|
+
const match = new RegExp(`^/api/sent-images/([A-Za-z0-9_-]{1,128})${suffix}$`).exec(pathname);
|
|
941
|
+
return match?.[1];
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
|
945
|
+
return left.length === right.length && left.every((id, index) => id === right[index]);
|
|
946
|
+
}
|
|
947
|
+
|
|
488
948
|
function token(): string {
|
|
489
949
|
return randomBytes(32).toString("base64url");
|
|
490
950
|
}
|