@narumitw/pi-webui 0.20.2 → 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.
@@ -0,0 +1,715 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export type AttachmentStatus = "uploading" | "processing" | "ready" | "error";
4
+ export type AttachmentPhase =
5
+ | "empty"
6
+ | "uploading"
7
+ | "processing"
8
+ | "blocked"
9
+ | "ready"
10
+ | "reserved"
11
+ | "closed";
12
+
13
+ export interface AttachmentLimits {
14
+ maxImages: number;
15
+ maxImageBytes: number;
16
+ maxPromptBytes: number;
17
+ maxPreparedImageBytes?: number;
18
+ }
19
+
20
+ export interface AttachmentReservationInput {
21
+ id: string;
22
+ name: string;
23
+ size: number;
24
+ mimeType?: string;
25
+ }
26
+
27
+ export interface PreparedAttachment {
28
+ bytes: Buffer;
29
+ mimeType: string;
30
+ width?: number;
31
+ height?: number;
32
+ originalWidth?: number;
33
+ originalHeight?: number;
34
+ sourceFormat?: string;
35
+ outputFormat?: string;
36
+ resized?: boolean;
37
+ notes?: string[];
38
+ }
39
+
40
+ export interface PublicAttachment {
41
+ id: string;
42
+ name: string;
43
+ size: number;
44
+ status: AttachmentStatus;
45
+ mimeType?: string;
46
+ width?: number;
47
+ height?: number;
48
+ originalWidth?: number;
49
+ originalHeight?: number;
50
+ sourceFormat?: string;
51
+ outputFormat?: string;
52
+ resized?: boolean;
53
+ notes: string[];
54
+ retryable: boolean;
55
+ error?: string;
56
+ }
57
+
58
+ export interface PublicAttachmentState {
59
+ revision: number;
60
+ phase: AttachmentPhase;
61
+ items: PublicAttachment[];
62
+ totalSourceBytes: number;
63
+ totalResidentBytes: number;
64
+ }
65
+
66
+ export interface StagedBrowserImage {
67
+ name: string;
68
+ mimeType: string;
69
+ data: string;
70
+ }
71
+
72
+ export interface SendReservation {
73
+ token: string;
74
+ images: StagedBrowserImage[];
75
+ }
76
+
77
+ export interface PreparedAttachmentInput {
78
+ id: string;
79
+ name: string;
80
+ prepared: PreparedAttachment;
81
+ }
82
+
83
+ interface AttachmentItem extends AttachmentReservationInput {
84
+ status: AttachmentStatus;
85
+ reservedRevision: number;
86
+ source?: Buffer;
87
+ prepared?: PreparedAttachment;
88
+ error?: string;
89
+ operation?: symbol;
90
+ controller?: AbortController;
91
+ }
92
+
93
+ interface ProcessingJob {
94
+ item: AttachmentItem;
95
+ operation: symbol;
96
+ controller: AbortController;
97
+ removeExternalAbort?: () => void;
98
+ }
99
+
100
+ export interface AttachmentStoreOptions {
101
+ limits: AttachmentLimits;
102
+ process: (source: Uint8Array, signal?: AbortSignal) => Promise<PreparedAttachment>;
103
+ concurrency?: number;
104
+ onChange?: (state: PublicAttachmentState) => void;
105
+ }
106
+
107
+ export class AttachmentError extends Error {
108
+ constructor(
109
+ message: string,
110
+ readonly status = 409,
111
+ ) {
112
+ super(message);
113
+ this.name = "AttachmentError";
114
+ }
115
+ }
116
+
117
+ export class AttachmentStore {
118
+ private readonly items: AttachmentItem[] = [];
119
+ private readonly queue: ProcessingJob[] = [];
120
+ private readonly idleWaiters = new Set<() => void>();
121
+ private readonly concurrency: number;
122
+ private revision = 0;
123
+ private active = 0;
124
+ private closed = false;
125
+ private sendToken?: string;
126
+
127
+ constructor(private readonly options: AttachmentStoreOptions) {
128
+ validateLimits(options.limits);
129
+ this.concurrency = options.concurrency ?? 2;
130
+ if (!Number.isSafeInteger(this.concurrency) || this.concurrency <= 0) {
131
+ throw new Error("Attachment processing concurrency must be a positive integer.");
132
+ }
133
+ }
134
+
135
+ publicState(): PublicAttachmentState {
136
+ return {
137
+ revision: this.revision,
138
+ phase: this.phase(),
139
+ items: this.items.map((item) => ({
140
+ id: item.id,
141
+ name: item.name,
142
+ size: item.size,
143
+ status: item.status,
144
+ mimeType: item.prepared?.mimeType,
145
+ width: item.prepared?.width,
146
+ height: item.prepared?.height,
147
+ originalWidth: item.prepared?.originalWidth,
148
+ originalHeight: item.prepared?.originalHeight,
149
+ sourceFormat: item.prepared?.sourceFormat,
150
+ outputFormat: item.prepared?.outputFormat,
151
+ resized: item.prepared?.resized,
152
+ notes: [...(item.prepared?.notes ?? [])],
153
+ retryable: item.status === "error" && Boolean(item.source),
154
+ error: item.error,
155
+ })),
156
+ totalSourceBytes: this.reservedSourceBytes(),
157
+ totalResidentBytes: this.residentBytes(),
158
+ };
159
+ }
160
+
161
+ residentBytes(): number {
162
+ return this.items.reduce(
163
+ (total, item) =>
164
+ total + (item.source?.byteLength ?? 0) + (item.prepared?.bytes.byteLength ?? 0),
165
+ 0,
166
+ );
167
+ }
168
+
169
+ reservedSourceBytes(): number {
170
+ return this.items.reduce((total, item) => total + item.size, 0);
171
+ }
172
+
173
+ waitForIdle(): Promise<void> {
174
+ if (this.active === 0 && this.queue.length === 0) return Promise.resolve();
175
+ return new Promise((resolve) => this.idleWaiters.add(resolve));
176
+ }
177
+
178
+ reserve(inputs: AttachmentReservationInput[], expectedRevision: number): PublicAttachmentState {
179
+ this.assertMutable(expectedRevision);
180
+ if (!Array.isArray(inputs) || inputs.length === 0) {
181
+ throw new AttachmentError("At least one attachment is required.", 400);
182
+ }
183
+ const normalized = inputs.map((input) => normalizeReservation(input, this.options.limits));
184
+ const ids = new Set(this.items.map((item) => item.id));
185
+ for (const item of normalized) {
186
+ if (ids.has(item.id)) throw new AttachmentError("Attachment id is duplicate.", 400);
187
+ ids.add(item.id);
188
+ }
189
+ if (this.items.length + normalized.length > this.options.limits.maxImages) {
190
+ throw new AttachmentError(
191
+ `Attachment count exceeds the maximum of ${this.options.limits.maxImages}.`,
192
+ 413,
193
+ );
194
+ }
195
+ const total = this.reservedSourceBytes() + normalized.reduce((sum, item) => sum + item.size, 0);
196
+ if (total > this.options.limits.maxPromptBytes) {
197
+ throw new AttachmentError("Combined attachment input is too large.", 413);
198
+ }
199
+ const reservedRevision = this.revision + 1;
200
+ this.items.push(
201
+ ...normalized.map((item) => ({
202
+ ...item,
203
+ status: "uploading" as const,
204
+ reservedRevision,
205
+ })),
206
+ );
207
+ this.changed();
208
+ return this.publicState();
209
+ }
210
+
211
+ attachPrepared(
212
+ inputs: readonly PreparedAttachmentInput[],
213
+ expectedRevision: number,
214
+ ): PublicAttachmentState {
215
+ this.assertMutable(expectedRevision);
216
+ if (!Array.isArray(inputs) || inputs.length === 0) {
217
+ throw new AttachmentError("At least one prepared attachment is required.", 400);
218
+ }
219
+ if (this.items.some((item) => item.status !== "ready")) {
220
+ throw new AttachmentError(
221
+ "Current attachments must be ready before attaching retained images.",
222
+ );
223
+ }
224
+ if (this.items.length + inputs.length > this.options.limits.maxImages) {
225
+ throw new AttachmentError(
226
+ `Attachment count exceeds the maximum of ${this.options.limits.maxImages}.`,
227
+ 413,
228
+ );
229
+ }
230
+ const ids = new Set(this.items.map((item) => item.id));
231
+ const existingBytes = this.items.flatMap((item) =>
232
+ item.prepared ? [item.prepared.bytes] : [],
233
+ );
234
+ const additions = inputs.map((input) => {
235
+ const prepared = validatePrepared(input.prepared, this.options.limits);
236
+ const reservation = normalizeReservation(
237
+ { id: input.id, name: input.name, size: prepared.bytes.byteLength },
238
+ this.options.limits,
239
+ preparedImageLimit(this.options.limits),
240
+ );
241
+ if (ids.has(reservation.id)) throw new AttachmentError("Attachment id is duplicate.", 400);
242
+ ids.add(reservation.id);
243
+ if (
244
+ existingBytes.some((bytes) => bytes.equals(prepared.bytes)) ||
245
+ additionsContainBytes(inputs, input, prepared.bytes)
246
+ ) {
247
+ throw new AttachmentError("Prepared attachment is a duplicate of the draft.");
248
+ }
249
+ return { reservation, prepared };
250
+ });
251
+ const total =
252
+ this.reservedSourceBytes() +
253
+ additions.reduce((sum, item) => sum + item.prepared.bytes.byteLength, 0);
254
+ if (total > this.options.limits.maxPromptBytes) {
255
+ throw new AttachmentError("Combined prepared attachments are too large.", 413);
256
+ }
257
+ const reservedRevision = this.revision + 1;
258
+ this.items.push(
259
+ ...additions.map(({ reservation, prepared }) => ({
260
+ ...reservation,
261
+ status: "ready" as const,
262
+ reservedRevision,
263
+ prepared,
264
+ })),
265
+ );
266
+ this.changed();
267
+ return this.publicState();
268
+ }
269
+
270
+ upload(
271
+ id: string,
272
+ source: Uint8Array,
273
+ expectedRevision: number,
274
+ signal?: AbortSignal,
275
+ ): PublicAttachmentState {
276
+ this.assertOpen();
277
+ if (this.sendToken) throw new AttachmentError("Attachments are reserved for sending.");
278
+ const item = this.requireItem(id);
279
+ this.assertUploadRevision(item, expectedRevision);
280
+ if (item.status !== "uploading" && !(item.status === "error" && !item.source)) {
281
+ throw new AttachmentError("Attachment is not waiting for an upload.");
282
+ }
283
+ if (source.byteLength !== item.size) {
284
+ throw new AttachmentError("Attachment upload size does not match its reservation.", 400);
285
+ }
286
+ if (source.byteLength === 0 || source.byteLength > this.options.limits.maxImageBytes) {
287
+ throw new AttachmentError("Attachment upload is outside the allowed size.", 413);
288
+ }
289
+ item.source = Buffer.from(source);
290
+ this.schedule(item, signal);
291
+ return this.publicState();
292
+ }
293
+
294
+ failUpload(id: string, error: unknown): PublicAttachmentState {
295
+ this.assertOpen();
296
+ if (this.sendToken) throw new AttachmentError("Attachments are reserved for sending.");
297
+ const item = this.requireItem(id);
298
+ if (item.status !== "uploading") {
299
+ throw new AttachmentError("Attachment upload is no longer pending.");
300
+ }
301
+ item.status = "error";
302
+ item.error = publicError(error);
303
+ this.changed();
304
+ return this.publicState();
305
+ }
306
+
307
+ retry(id: string, expectedRevision: number, signal?: AbortSignal): PublicAttachmentState {
308
+ this.assertMutable(expectedRevision);
309
+ const item = this.requireItem(id);
310
+ if (item.status !== "error" || !item.source) {
311
+ throw new AttachmentError("Attachment is not available for retry.");
312
+ }
313
+ this.schedule(item, signal);
314
+ return this.publicState();
315
+ }
316
+
317
+ cancelInFlight(message: string): boolean {
318
+ this.assertOpen();
319
+ if (this.sendToken) return false;
320
+ let changed = false;
321
+ for (const item of this.items) {
322
+ if (item.status === "uploading") {
323
+ item.status = "error";
324
+ item.error = publicError(message);
325
+ changed = true;
326
+ continue;
327
+ }
328
+ if (item.status === "processing") {
329
+ item.controller?.abort();
330
+ item.operation = undefined;
331
+ item.controller = undefined;
332
+ item.status = "error";
333
+ item.error = publicError(message);
334
+ item.prepared = undefined;
335
+ changed = true;
336
+ }
337
+ }
338
+ if (changed) this.changed();
339
+ return changed;
340
+ }
341
+
342
+ reorder(ids: string[], expectedRevision: number): PublicAttachmentState {
343
+ this.assertMutable(expectedRevision);
344
+ if (
345
+ !Array.isArray(ids) ||
346
+ ids.length !== this.items.length ||
347
+ new Set(ids).size !== ids.length ||
348
+ ids.some((id) => typeof id !== "string")
349
+ ) {
350
+ throw new AttachmentError("Attachment order must contain every id exactly once.", 400);
351
+ }
352
+ const byId = new Map(this.items.map((item) => [item.id, item]));
353
+ const ordered = ids.map((id) => byId.get(id));
354
+ if (ordered.some((item) => !item)) {
355
+ throw new AttachmentError("Attachment order contains an unknown id.", 400);
356
+ }
357
+ this.items.splice(0, this.items.length, ...(ordered as AttachmentItem[]));
358
+ this.changed();
359
+ return this.publicState();
360
+ }
361
+
362
+ remove(id: string, expectedRevision: number): PublicAttachmentState {
363
+ this.assertMutable(expectedRevision);
364
+ const index = this.items.findIndex((item) => item.id === id);
365
+ if (index === -1) throw new AttachmentError("Attachment was not found.", 404);
366
+ const [removed] = this.items.splice(index, 1);
367
+ this.releaseItem(removed);
368
+ this.changed();
369
+ return this.publicState();
370
+ }
371
+
372
+ clear(expectedRevision: number): PublicAttachmentState {
373
+ this.assertMutable(expectedRevision);
374
+ this.releaseItems();
375
+ this.changed();
376
+ return this.publicState();
377
+ }
378
+
379
+ preview(id: string): PreparedAttachment {
380
+ this.assertOpen();
381
+ const item = this.requireItem(id);
382
+ if (item.status !== "ready" || !item.prepared) {
383
+ throw new AttachmentError("Attachment preview is not ready.", 404);
384
+ }
385
+ return clonePrepared(item.prepared);
386
+ }
387
+
388
+ beginSend(ids: string[], expectedRevision: number): SendReservation {
389
+ this.assertMutable(expectedRevision);
390
+ if (
391
+ ids.length !== this.items.length ||
392
+ ids.some((id, index) => id !== this.items[index]?.id) ||
393
+ this.items.some((item) => item.status !== "ready" || !item.prepared)
394
+ ) {
395
+ throw new AttachmentError("Every ordered attachment must be ready before sending.");
396
+ }
397
+ const token = randomUUID();
398
+ this.sendToken = token;
399
+ this.notify();
400
+ return {
401
+ token,
402
+ images: this.items.map((item) => ({
403
+ name: item.name,
404
+ mimeType: item.prepared?.mimeType ?? "",
405
+ data: item.prepared?.bytes.toString("base64") ?? "",
406
+ })),
407
+ };
408
+ }
409
+
410
+ finishSend(token: string, committed: boolean): PublicAttachmentState {
411
+ this.assertOpen();
412
+ if (!this.sendToken || token !== this.sendToken) {
413
+ throw new AttachmentError("Attachment send reservation is stale.");
414
+ }
415
+ this.sendToken = undefined;
416
+ if (committed) {
417
+ this.releaseItems();
418
+ this.changed();
419
+ } else {
420
+ this.notify();
421
+ }
422
+ return this.publicState();
423
+ }
424
+
425
+ close(): void {
426
+ if (this.closed) return;
427
+ this.closed = true;
428
+ this.sendToken = undefined;
429
+ this.releaseItems();
430
+ this.revision += 1;
431
+ this.notify();
432
+ }
433
+
434
+ private schedule(item: AttachmentItem, externalSignal?: AbortSignal): void {
435
+ const operation = Symbol(item.id);
436
+ const controller = new AbortController();
437
+ let removeExternalAbort: (() => void) | undefined;
438
+ if (externalSignal) {
439
+ const abort = () => controller.abort();
440
+ if (externalSignal.aborted) controller.abort();
441
+ else {
442
+ externalSignal.addEventListener("abort", abort, { once: true });
443
+ removeExternalAbort = () => externalSignal.removeEventListener("abort", abort);
444
+ }
445
+ }
446
+ item.operation = operation;
447
+ item.controller = controller;
448
+ item.status = "processing";
449
+ item.error = undefined;
450
+ item.prepared = undefined;
451
+ this.queue.push({ item, operation, controller, removeExternalAbort });
452
+ this.changed();
453
+ this.pump();
454
+ }
455
+
456
+ private pump(): void {
457
+ while (this.active < this.concurrency && this.queue.length > 0) {
458
+ const job = this.queue.shift();
459
+ if (!job) break;
460
+ if (job.controller.signal.aborted) {
461
+ this.finishProcessing(job, undefined, abortError());
462
+ continue;
463
+ }
464
+ this.active += 1;
465
+ void this.options
466
+ .process(Buffer.from(job.item.source ?? []), job.controller.signal)
467
+ .then(
468
+ (prepared) => this.finishProcessing(job, prepared),
469
+ (error) => this.finishProcessing(job, undefined, error),
470
+ )
471
+ .finally(() => {
472
+ this.active -= 1;
473
+ this.pump();
474
+ this.resolveIdle();
475
+ });
476
+ }
477
+ this.resolveIdle();
478
+ }
479
+
480
+ private finishProcessing(
481
+ job: ProcessingJob,
482
+ prepared?: PreparedAttachment,
483
+ error?: unknown,
484
+ ): void {
485
+ job.removeExternalAbort?.();
486
+ if (!this.isCurrent(job.item, job.operation)) return;
487
+ job.item.operation = undefined;
488
+ job.item.controller = undefined;
489
+ if (error || job.controller.signal.aborted) {
490
+ job.item.status = "error";
491
+ job.item.error = publicError(job.controller.signal.aborted ? abortError() : error);
492
+ job.item.prepared = undefined;
493
+ this.changed();
494
+ return;
495
+ }
496
+ try {
497
+ if (!prepared?.bytes.length || !prepared.mimeType.startsWith("image/")) {
498
+ throw new Error("Image processing returned an invalid result.");
499
+ }
500
+ const otherPreparedBytes = this.items.reduce(
501
+ (total, item) => total + (item === job.item ? 0 : (item.prepared?.bytes.byteLength ?? 0)),
502
+ 0,
503
+ );
504
+ if (otherPreparedBytes + prepared.bytes.byteLength > this.options.limits.maxPromptBytes) {
505
+ throw new Error("Combined processed attachments are too large.");
506
+ }
507
+ if (prepared.bytes.byteLength > preparedImageLimit(this.options.limits)) {
508
+ throw new Error("Processed attachment exceeds Pi's provider-ready byte limit.");
509
+ }
510
+ const maximumResident =
511
+ this.options.limits.maxPromptBytes +
512
+ Math.max(this.options.limits.maxImageBytes, preparedImageLimit(this.options.limits)) *
513
+ this.concurrency;
514
+ if (this.residentBytes() + prepared.bytes.byteLength > maximumResident) {
515
+ throw new Error("Attachment memory limit exceeded.");
516
+ }
517
+ job.item.prepared = clonePrepared(prepared);
518
+ job.item.source = undefined;
519
+ job.item.status = "ready";
520
+ job.item.error = undefined;
521
+ } catch (processingError) {
522
+ job.item.status = "error";
523
+ job.item.error = publicError(processingError);
524
+ job.item.prepared = undefined;
525
+ }
526
+ this.changed();
527
+ }
528
+
529
+ private isCurrent(item: AttachmentItem, operation: symbol): boolean {
530
+ return !this.closed && item.operation === operation && this.items.includes(item);
531
+ }
532
+
533
+ private requireItem(id: string): AttachmentItem {
534
+ const item = this.items.find((candidate) => candidate.id === id);
535
+ if (!item) throw new AttachmentError("Attachment was not found.", 404);
536
+ return item;
537
+ }
538
+
539
+ private assertUploadRevision(item: AttachmentItem, expectedRevision: number): void {
540
+ if (
541
+ !Number.isSafeInteger(expectedRevision) ||
542
+ expectedRevision < item.reservedRevision ||
543
+ expectedRevision > this.revision
544
+ ) {
545
+ throw new AttachmentError(
546
+ `Attachment revision mismatch; current revision is ${this.revision}.`,
547
+ );
548
+ }
549
+ }
550
+
551
+ private assertMutable(expectedRevision: number): void {
552
+ this.assertOpen();
553
+ if (this.sendToken) throw new AttachmentError("Attachments are reserved for sending.");
554
+ if (!Number.isSafeInteger(expectedRevision) || expectedRevision !== this.revision) {
555
+ throw new AttachmentError(
556
+ `Attachment revision mismatch; current revision is ${this.revision}.`,
557
+ );
558
+ }
559
+ }
560
+
561
+ private assertOpen(): void {
562
+ if (this.closed) throw new AttachmentError("Attachment store is closed.", 410);
563
+ }
564
+
565
+ private changed(): void {
566
+ this.revision += 1;
567
+ this.notify();
568
+ }
569
+
570
+ private notify(): void {
571
+ this.options.onChange?.(this.publicState());
572
+ }
573
+
574
+ private releaseItem(item: AttachmentItem): void {
575
+ item.controller?.abort();
576
+ item.operation = undefined;
577
+ item.controller = undefined;
578
+ item.source = undefined;
579
+ item.prepared = undefined;
580
+ }
581
+
582
+ private releaseItems(): void {
583
+ for (const item of this.items) this.releaseItem(item);
584
+ this.items.length = 0;
585
+ }
586
+
587
+ private resolveIdle(): void {
588
+ if (this.active !== 0 || this.queue.length !== 0) return;
589
+ for (const resolve of this.idleWaiters) resolve();
590
+ this.idleWaiters.clear();
591
+ }
592
+
593
+ private phase(): AttachmentPhase {
594
+ if (this.closed) return "closed";
595
+ if (this.sendToken) return "reserved";
596
+ if (this.items.length === 0) return "empty";
597
+ if (this.items.some((item) => item.status === "error")) return "blocked";
598
+ if (this.items.some((item) => item.status === "processing")) return "processing";
599
+ if (this.items.some((item) => item.status === "uploading")) return "uploading";
600
+ return "ready";
601
+ }
602
+ }
603
+
604
+ function normalizeReservation(
605
+ input: AttachmentReservationInput,
606
+ limits: AttachmentLimits,
607
+ maxImageBytes = limits.maxImageBytes,
608
+ ): AttachmentReservationInput {
609
+ if (!input || typeof input.id !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(input.id)) {
610
+ throw new AttachmentError("Attachment id is invalid.", 400);
611
+ }
612
+ if (
613
+ typeof input.name !== "string" ||
614
+ input.name.length === 0 ||
615
+ input.name.length > 255 ||
616
+ hasControlCharacter(input.name)
617
+ ) {
618
+ throw new AttachmentError("Attachment name is invalid.", 400);
619
+ }
620
+ if (!Number.isSafeInteger(input.size) || input.size <= 0) {
621
+ throw new AttachmentError("Attachment size is invalid.", 400);
622
+ }
623
+ if (input.size > maxImageBytes) {
624
+ throw new AttachmentError("Attachment exceeds the per-image maximum.", 413);
625
+ }
626
+ if (
627
+ input.mimeType !== undefined &&
628
+ (typeof input.mimeType !== "string" || input.mimeType.length > 128)
629
+ ) {
630
+ throw new AttachmentError("Attachment MIME type is invalid.", 400);
631
+ }
632
+ return { id: input.id, name: input.name, size: input.size, mimeType: input.mimeType };
633
+ }
634
+
635
+ function validatePrepared(
636
+ prepared: PreparedAttachment,
637
+ limits: AttachmentLimits,
638
+ ): PreparedAttachment {
639
+ if (
640
+ !prepared ||
641
+ !Buffer.isBuffer(prepared.bytes) ||
642
+ prepared.bytes.length === 0 ||
643
+ prepared.bytes.length > preparedImageLimit(limits) ||
644
+ typeof prepared.mimeType !== "string" ||
645
+ !/^image\/[a-z0-9.+-]{1,64}$/i.test(prepared.mimeType)
646
+ ) {
647
+ throw new AttachmentError("Prepared attachment is invalid.", 400);
648
+ }
649
+ return clonePrepared(prepared);
650
+ }
651
+
652
+ function additionsContainBytes(
653
+ inputs: readonly PreparedAttachmentInput[],
654
+ current: PreparedAttachmentInput,
655
+ bytes: Buffer,
656
+ ): boolean {
657
+ const currentIndex = inputs.indexOf(current);
658
+ return inputs
659
+ .slice(0, currentIndex)
660
+ .some((input) => Buffer.isBuffer(input.prepared?.bytes) && input.prepared.bytes.equals(bytes));
661
+ }
662
+
663
+ function preparedImageLimit(limits: AttachmentLimits): number {
664
+ return limits.maxPreparedImageBytes ?? limits.maxImageBytes;
665
+ }
666
+
667
+ function validateLimits(limits: AttachmentLimits): void {
668
+ for (const value of [
669
+ limits.maxImages,
670
+ limits.maxImageBytes,
671
+ limits.maxPromptBytes,
672
+ preparedImageLimit(limits),
673
+ ]) {
674
+ if (!Number.isSafeInteger(value) || value <= 0) {
675
+ throw new Error("Attachment limits must be positive integers.");
676
+ }
677
+ }
678
+ if (limits.maxImageBytes > limits.maxPromptBytes) {
679
+ throw new Error("Attachment per-image limit cannot exceed the combined limit.");
680
+ }
681
+ }
682
+
683
+ function hasControlCharacter(value: string): boolean {
684
+ return [...value].some((character) => {
685
+ const code = character.charCodeAt(0);
686
+ return code < 32 || code === 127;
687
+ });
688
+ }
689
+
690
+ function clonePrepared(prepared: PreparedAttachment): PreparedAttachment {
691
+ return {
692
+ ...prepared,
693
+ bytes: Buffer.from(prepared.bytes),
694
+ notes: [...(prepared.notes ?? [])],
695
+ };
696
+ }
697
+
698
+ function publicError(error: unknown): string {
699
+ const message = error instanceof Error ? error.message : String(error);
700
+ const cleaned = [...message]
701
+ .map((character) => {
702
+ const code = character.charCodeAt(0);
703
+ return code < 32 || code === 127 ? " " : character;
704
+ })
705
+ .join("")
706
+ .replace(/\s+/g, " ")
707
+ .trim();
708
+ return (cleaned || "Image processing failed.").slice(0, 240);
709
+ }
710
+
711
+ function abortError(): Error {
712
+ const error = new Error("Image processing was cancelled.");
713
+ error.name = "AbortError";
714
+ return error;
715
+ }