@narumitw/pi-webui 0.28.0 → 0.29.2

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,690 @@
1
+ import {
2
+ acknowledgeDraftText,
3
+ applyAttachments,
4
+ applyConversationEvent,
5
+ applyDraft,
6
+ applyImageLimits,
7
+ applyLease,
8
+ applySentImages,
9
+ applySnapshot,
10
+ busyLabel,
11
+ canSend,
12
+ completeSend,
13
+ deliveryNotice,
14
+ editDraftText,
15
+ failSend,
16
+ followLatest,
17
+ initialState,
18
+ invalidateSendAttempt,
19
+ moveImage,
20
+ moveImageAfter,
21
+ moveImageBefore,
22
+ noteUnseenUpdate,
23
+ prepareSend,
24
+ setNearBottom,
25
+ } from "../state.js";
26
+
27
+ const SUPPORTED_IMAGE_TYPES = new Set([
28
+ "image/png",
29
+ "image/jpeg",
30
+ "image/webp",
31
+ "image/gif",
32
+ "image/bmp",
33
+ "image/x-ms-bmp",
34
+ "image/tiff",
35
+ "image/heic",
36
+ "image/heif",
37
+ "image/avif",
38
+ ]);
39
+
40
+ const clientId = crypto.randomUUID();
41
+ const listeners = new Set();
42
+ const retryFiles = new Map();
43
+ const uploadProgress = new Map();
44
+ let model = initialState();
45
+ let events;
46
+ let reconnectTimer;
47
+ let reconnectDelay = 500;
48
+ let snapshotRefresh;
49
+ let snapshotTarget = 0;
50
+ let draftSaveTimer;
51
+ let draftSaveQueue = Promise.resolve();
52
+ let mutatingAttachments = false;
53
+ let started = false;
54
+ let view = createView();
55
+
56
+ function createView(extra = {}) {
57
+ return {
58
+ model,
59
+ mutatingAttachments,
60
+ uploadProgress: new Map(uploadProgress),
61
+ retryableIds: new Set(retryFiles.keys()),
62
+ transcriptAnnouncement: "",
63
+ focusTarget: "",
64
+ scrollToLatest: false,
65
+ ...extra,
66
+ };
67
+ }
68
+
69
+ function emit(extra) {
70
+ view = createView(extra);
71
+ for (const listener of listeners) listener();
72
+ }
73
+
74
+ export const webClient = {
75
+ subscribe(listener) {
76
+ listeners.add(listener);
77
+ return () => listeners.delete(listener);
78
+ },
79
+ getSnapshot() {
80
+ return view;
81
+ },
82
+ start() {
83
+ if (started) return;
84
+ started = true;
85
+ void initialize();
86
+ },
87
+ editText(text) {
88
+ model = editDraftText(model, text);
89
+ scheduleDraftSave();
90
+ emit();
91
+ },
92
+ send(steer = false) {
93
+ return send(steer);
94
+ },
95
+ addFiles(fileList) {
96
+ return addFiles(fileList);
97
+ },
98
+ retryImage(id) {
99
+ return retryImage(id);
100
+ },
101
+ removeImage(id) {
102
+ return removeImage(id);
103
+ },
104
+ clearAttachments() {
105
+ return clearAttachments();
106
+ },
107
+ reorderImage(id, direction) {
108
+ return reorderImages(moveImage(model.images, id, direction), id);
109
+ },
110
+ dropImage(sourceId, targetId, after) {
111
+ const images = after
112
+ ? moveImageAfter(model.images, sourceId, targetId)
113
+ : moveImageBefore(model.images, sourceId, targetId);
114
+ return reorderImages(images, sourceId);
115
+ },
116
+ reattachSentImage(id) {
117
+ return reattachSentImage(id);
118
+ },
119
+ forgetSentImage(id) {
120
+ return forgetSentImage(id);
121
+ },
122
+ setNearBottom(nearBottom) {
123
+ const next = setNearBottom(model, nearBottom);
124
+ if (next === model) return;
125
+ model = next;
126
+ emit();
127
+ },
128
+ followLatest() {
129
+ model = followLatest(model);
130
+ emit({ scrollToLatest: true });
131
+ },
132
+ };
133
+
134
+ async function initialize() {
135
+ try {
136
+ await refreshSnapshot();
137
+ await claimLease();
138
+ connectEvents();
139
+ } catch (error) {
140
+ model = { ...model, connected: false, error: errorMessage(error) };
141
+ emit();
142
+ scheduleReconnect();
143
+ }
144
+ }
145
+
146
+ async function refreshSnapshot(requiredSequence = 0) {
147
+ snapshotTarget = Math.max(snapshotTarget, requiredSequence);
148
+ if (!snapshotRefresh) {
149
+ snapshotRefresh = (async () => {
150
+ do {
151
+ const response = await fetch("/api/state", { cache: "no-store" });
152
+ if (!response.ok) throw new Error(await responseError(response));
153
+ const snapshot = await response.json();
154
+ model = applySnapshot(model, snapshot);
155
+ if (typeof snapshot.lease?.activeClientId === "string") {
156
+ model = applyLease(model, snapshot.lease, clientId);
157
+ }
158
+ emit({ scrollToLatest: model.following });
159
+ } while (model.sequence < snapshotTarget);
160
+ })().finally(() => {
161
+ snapshotRefresh = undefined;
162
+ });
163
+ }
164
+ return snapshotRefresh;
165
+ }
166
+
167
+ async function claimLease() {
168
+ const response = await fetch("/api/lease", {
169
+ method: "POST",
170
+ headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
171
+ body: JSON.stringify({ clientId }),
172
+ });
173
+ if (!response.ok) throw new Error(await responseError(response));
174
+ model = applyLease(model, await response.json(), clientId, true);
175
+ if (model.textDirty) scheduleDraftSave();
176
+ emit();
177
+ }
178
+
179
+ function connectEvents() {
180
+ clearTimeout(reconnectTimer);
181
+ events?.close();
182
+ events = new EventSource(`/api/events?since=${model.sequence}`);
183
+ events.addEventListener("open", () => {
184
+ reconnectDelay = 500;
185
+ model = { ...model, connected: true, error: "" };
186
+ if (model.textDirty) scheduleDraftSave();
187
+ emit();
188
+ });
189
+ events.addEventListener("conversation", (event) => {
190
+ const conversationEvent = JSON.parse(event.data);
191
+ model = applyConversationEvent(model, conversationEvent);
192
+ if (model.needsSnapshot) {
193
+ void refreshSnapshot(conversationEvent.sequence).catch(connectionFailure);
194
+ return;
195
+ }
196
+ model = noteUnseenUpdate(model, conversationUpdateKey(conversationEvent));
197
+ emit({
198
+ transcriptAnnouncement: conversationAnnouncement(conversationEvent),
199
+ scrollToLatest: model.following,
200
+ });
201
+ });
202
+ events.addEventListener("snapshot", (event) => {
203
+ model = applySnapshot(model, JSON.parse(event.data));
204
+ emit({ scrollToLatest: model.following });
205
+ });
206
+ events.addEventListener("lease", (event) => {
207
+ model = applyLease(model, JSON.parse(event.data), clientId);
208
+ emit();
209
+ });
210
+ events.addEventListener("draft", (event) => {
211
+ model = applyDraft(model, JSON.parse(event.data));
212
+ emit();
213
+ });
214
+ events.addEventListener("attachments", (event) => {
215
+ model = applyAttachments(model, JSON.parse(event.data));
216
+ emit();
217
+ });
218
+ events.addEventListener("image-limits", (event) => {
219
+ model = applyImageLimits(model, JSON.parse(event.data));
220
+ emit();
221
+ });
222
+ events.addEventListener("sent-images", (event) => {
223
+ model = applySentImages(model, JSON.parse(event.data));
224
+ emit();
225
+ });
226
+ events.addEventListener("session-ended", () => {
227
+ model = { ...model, closed: true, activity: "ended", connected: false };
228
+ events?.close();
229
+ emit({ transcriptAnnouncement: "Pi session ended." });
230
+ });
231
+ events.addEventListener("error", () => {
232
+ events?.close();
233
+ if (model.closed) return;
234
+ model = { ...model, connected: false };
235
+ emit();
236
+ scheduleReconnect();
237
+ });
238
+ }
239
+
240
+ function scheduleReconnect() {
241
+ if (model.closed || reconnectTimer) return;
242
+ reconnectTimer = setTimeout(async () => {
243
+ reconnectTimer = undefined;
244
+ try {
245
+ await refreshSnapshot();
246
+ if (!model.leaseClaimed) await claimLease();
247
+ connectEvents();
248
+ } catch (error) {
249
+ connectionFailure(error);
250
+ }
251
+ }, reconnectDelay);
252
+ reconnectDelay = Math.min(reconnectDelay * 2, 5_000);
253
+ }
254
+
255
+ function connectionFailure(error) {
256
+ model = { ...model, connected: false, error: errorMessage(error) };
257
+ emit();
258
+ scheduleReconnect();
259
+ }
260
+
261
+ async function send(steer) {
262
+ if (composerLocked() || !canSend(model)) return;
263
+ try {
264
+ await flushDraftText();
265
+ } catch (error) {
266
+ model = { ...model, error: errorMessage(error) };
267
+ emit();
268
+ return;
269
+ }
270
+ if (composerLocked() || !canSend(model) || model.textDirty) return;
271
+ const prepared = prepareSend(model, crypto.randomUUID(), steer ? "steer" : "next");
272
+ const attempt = prepared.attempt;
273
+ model = prepared.state;
274
+ emit();
275
+ try {
276
+ const response = await fetch("/api/messages", {
277
+ method: "POST",
278
+ headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
279
+ body: JSON.stringify({
280
+ requestId: attempt.requestId,
281
+ draftRevision: attempt.draftRevision,
282
+ delivery: attempt.delivery,
283
+ }),
284
+ });
285
+ if (!response.ok) {
286
+ const message = await responseError(response);
287
+ model = invalidateSendAttempt(model);
288
+ throw new Error(message);
289
+ }
290
+ const accepted = await response.json();
291
+ if (accepted.draft) model = applyDraft(model, accepted.draft);
292
+ if (accepted.attachments) model = applyAttachments(model, accepted.attachments);
293
+ if (accepted.sentImages) model = applySentImages(model, accepted.sentImages);
294
+ for (const id of attempt.attachmentIds) retryFiles.delete(id);
295
+ model = completeSend(model, attempt, accepted.delivery);
296
+ emit({ focusTarget: "input" });
297
+ } catch (error) {
298
+ model = failSend(model, attempt, errorMessage(error));
299
+ emit();
300
+ }
301
+ }
302
+
303
+ async function addFiles(fileList) {
304
+ if (composerLocked() || model.readingImages > 0) return;
305
+ const files = [...(fileList ?? [])];
306
+ if (files.length === 0) return;
307
+ const limits = model.imageLimits;
308
+ if (!limits) return setError("Effective image limits are still loading.");
309
+ if (model.images.length + files.length > limits.maxImages) {
310
+ return setError(`You can attach at most ${limits.maxImages} images.`);
311
+ }
312
+ for (const file of files) {
313
+ if (!isSupportedImageFile(file)) return setError(`${file.name || "Image"} is not supported.`);
314
+ if (file.size > limits.maxImageBytes) {
315
+ return setError(`${file.name || "Image"} is larger than ${formatMib(limits.maxImageBytes)}.`);
316
+ }
317
+ }
318
+ const batchBytes =
319
+ model.images.reduce((total, image) => total + (image.size ?? 0), 0) +
320
+ files.reduce((total, file) => total + file.size, 0);
321
+ if (batchBytes > limits.maxBatchBytes) {
322
+ return setError(`Combined image input is larger than ${formatMib(limits.maxBatchBytes)}.`);
323
+ }
324
+ const pending = files.map((file) => ({ id: crypto.randomUUID(), file }));
325
+ try {
326
+ mutatingAttachments = true;
327
+ emit();
328
+ const response = await attachmentMutation("/api/attachments/reserve", {
329
+ method: "POST",
330
+ body: JSON.stringify({
331
+ revision: model.attachmentRevision,
332
+ items: pending.map(({ id, file }) => ({
333
+ id,
334
+ name: file.name || "Pasted image",
335
+ size: file.size,
336
+ mimeType: file.type,
337
+ })),
338
+ }),
339
+ });
340
+ model = applyAttachments(model, response);
341
+ for (const { id, file } of pending) retryFiles.set(id, file);
342
+ emit();
343
+ for (const { id, file } of pending) await uploadFile(id, file);
344
+ } catch (error) {
345
+ setError(errorMessage(error));
346
+ } finally {
347
+ mutatingAttachments = false;
348
+ emit();
349
+ }
350
+ }
351
+
352
+ async function uploadFile(id, file) {
353
+ uploadProgress.set(id, { loaded: 0, total: file.size });
354
+ emit();
355
+ try {
356
+ const state = await uploadAttachment(
357
+ `/api/attachments/${encodeURIComponent(id)}/upload?revision=${model.attachmentRevision}`,
358
+ file,
359
+ (progress) => {
360
+ uploadProgress.set(id, progress);
361
+ emit();
362
+ },
363
+ );
364
+ retryFiles.delete(id);
365
+ model = applyAttachments(model, state);
366
+ emit();
367
+ } finally {
368
+ uploadProgress.delete(id);
369
+ emit();
370
+ }
371
+ }
372
+
373
+ function uploadAttachment(path, file, onProgress) {
374
+ return new Promise((resolve, reject) => {
375
+ const request = new XMLHttpRequest();
376
+ request.open("POST", path);
377
+ request.responseType = "json";
378
+ request.setRequestHeader("Content-Type", "application/octet-stream");
379
+ request.setRequestHeader("X-Pi-Web-Client", clientId);
380
+ request.upload.addEventListener("progress", (event) => {
381
+ if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
382
+ });
383
+ request.addEventListener("load", () => {
384
+ if (request.status >= 200 && request.status < 300) resolve(request.response);
385
+ else reject(new Error(request.response?.error || `Request failed (${request.status}).`));
386
+ });
387
+ request.addEventListener("error", () => reject(new Error("Image upload failed.")));
388
+ request.addEventListener("abort", () => reject(new Error("Image upload was cancelled.")));
389
+ request.send(file);
390
+ });
391
+ }
392
+
393
+ async function retryImage(id) {
394
+ if (composerLocked()) return;
395
+ mutatingAttachments = true;
396
+ emit();
397
+ try {
398
+ const file = retryFiles.get(id);
399
+ if (file) await uploadFile(id, file);
400
+ else {
401
+ const response = await attachmentMutation(
402
+ `/api/attachments/${encodeURIComponent(id)}/retry`,
403
+ {
404
+ method: "POST",
405
+ body: JSON.stringify({ revision: model.attachmentRevision }),
406
+ },
407
+ );
408
+ model = applyAttachments(model, response);
409
+ }
410
+ emit();
411
+ } catch (error) {
412
+ setError(errorMessage(error));
413
+ } finally {
414
+ mutatingAttachments = false;
415
+ emit();
416
+ }
417
+ }
418
+
419
+ function scheduleDraftSave() {
420
+ clearTimeout(draftSaveTimer);
421
+ if (!model.textDirty || model.closed || model.stale || !model.connected) return;
422
+ draftSaveTimer = setTimeout(() => {
423
+ draftSaveTimer = undefined;
424
+ void flushDraftText().catch((error) => setError(errorMessage(error)));
425
+ }, 180);
426
+ }
427
+
428
+ function flushDraftText() {
429
+ clearTimeout(draftSaveTimer);
430
+ draftSaveTimer = undefined;
431
+ draftSaveQueue = draftSaveQueue
432
+ .catch(() => undefined)
433
+ .then(async () => {
434
+ while (model.textDirty) {
435
+ if (model.closed || model.stale || !model.connected) {
436
+ throw new Error("Reconnect the active tab to save this draft.");
437
+ }
438
+ const submittedText = model.text;
439
+ const response = await fetch("/api/draft", {
440
+ method: "POST",
441
+ headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
442
+ body: JSON.stringify({
443
+ requestId: crypto.randomUUID(),
444
+ revision: model.draftRevision,
445
+ text: submittedText,
446
+ }),
447
+ });
448
+ if (response.status === 409) {
449
+ await refreshSnapshot();
450
+ continue;
451
+ }
452
+ if (!response.ok) throw new Error(await responseError(response));
453
+ model = acknowledgeDraftText(model, await response.json(), submittedText);
454
+ emit();
455
+ }
456
+ });
457
+ return draftSaveQueue;
458
+ }
459
+
460
+ async function reattachSentImage(retainedImageId) {
461
+ if (composerLocked() || !retainedImageId) return;
462
+ if (model.attachmentPhase !== "empty" && model.attachmentPhase !== "ready") {
463
+ return setError("Wait for current images to finish before attaching again.");
464
+ }
465
+ mutatingAttachments = true;
466
+ emit();
467
+ try {
468
+ const response = await attachmentMutation("/api/sent-images/reattach", {
469
+ method: "POST",
470
+ body: JSON.stringify({
471
+ revision: model.attachmentRevision,
472
+ items: [{ retainedId: retainedImageId, id: crypto.randomUUID() }],
473
+ }),
474
+ });
475
+ model = applyDraft(model, response.draft);
476
+ model = applyAttachments(model, response.attachments);
477
+ model = applySentImages(model, response.sentImages);
478
+ model = { ...model, error: "" };
479
+ emit();
480
+ } catch (error) {
481
+ setError(errorMessage(error));
482
+ } finally {
483
+ mutatingAttachments = false;
484
+ emit();
485
+ }
486
+ }
487
+
488
+ async function forgetSentImage(retainedImageId) {
489
+ if (composerLocked() || !retainedImageId) return;
490
+ try {
491
+ const response = await attachmentMutation(
492
+ `/api/sent-images/${encodeURIComponent(retainedImageId)}?revision=${model.sentImages.revision}`,
493
+ { method: "DELETE" },
494
+ );
495
+ model = applySentImages(model, response);
496
+ emit();
497
+ } catch (error) {
498
+ setError(errorMessage(error));
499
+ }
500
+ }
501
+
502
+ async function clearAttachments() {
503
+ if (composerLocked() || model.images.length < 2) return;
504
+ const clearedIds = model.images.map((image) => image.id);
505
+ const state = await mutateAttachmentState("/api/attachments/clear", {
506
+ method: "POST",
507
+ body: JSON.stringify({ revision: model.attachmentRevision }),
508
+ });
509
+ if (!state) return;
510
+ for (const id of clearedIds) retryFiles.delete(id);
511
+ emit({ focusTarget: "input" });
512
+ }
513
+
514
+ async function reorderImages(images, focusId) {
515
+ if (composerLocked()) return;
516
+ if (images.every((image, index) => image.id === model.images[index]?.id)) {
517
+ emit({ focusTarget: focusId });
518
+ return;
519
+ }
520
+ const previousImages = model.images;
521
+ const previousRevision = model.attachmentRevision;
522
+ model = { ...model, images };
523
+ mutatingAttachments = true;
524
+ emit({ focusTarget: focusId });
525
+ try {
526
+ const state = await attachmentMutation("/api/attachments/reorder", {
527
+ method: "POST",
528
+ body: JSON.stringify({ revision: previousRevision, ids: images.map((image) => image.id) }),
529
+ });
530
+ model = applyAttachments(model, state);
531
+ model = { ...model, error: "" };
532
+ } catch (error) {
533
+ if (model.attachmentRevision === previousRevision) model = { ...model, images: previousImages };
534
+ model = { ...model, error: errorMessage(error) };
535
+ } finally {
536
+ mutatingAttachments = false;
537
+ emit({ focusTarget: focusId });
538
+ }
539
+ }
540
+
541
+ async function removeImage(id) {
542
+ if (composerLocked()) return;
543
+ const state = await mutateAttachmentState(
544
+ `/api/attachments/${encodeURIComponent(id)}?revision=${model.attachmentRevision}`,
545
+ { method: "DELETE", headers: { "Content-Type": "application/json" } },
546
+ );
547
+ if (state) {
548
+ retryFiles.delete(id);
549
+ uploadProgress.delete(id);
550
+ }
551
+ }
552
+
553
+ async function mutateAttachmentState(path, options) {
554
+ if (composerLocked()) return;
555
+ mutatingAttachments = true;
556
+ emit();
557
+ try {
558
+ const state = await attachmentMutation(path, options);
559
+ model = applyAttachments(model, state);
560
+ model = { ...model, error: "" };
561
+ emit();
562
+ return state;
563
+ } catch (error) {
564
+ setError(errorMessage(error));
565
+ } finally {
566
+ mutatingAttachments = false;
567
+ emit();
568
+ }
569
+ }
570
+
571
+ async function attachmentMutation(path, options) {
572
+ const response = await fetch(path, {
573
+ ...options,
574
+ headers: {
575
+ "Content-Type": "application/json",
576
+ "X-Pi-Web-Client": clientId,
577
+ ...options.headers,
578
+ },
579
+ });
580
+ if (!response.ok) throw new Error(await responseError(response));
581
+ return response.json();
582
+ }
583
+
584
+ export function composerLocked(state = view) {
585
+ return (
586
+ state.model.closed ||
587
+ state.model.stale ||
588
+ state.model.pending ||
589
+ state.mutatingAttachments ||
590
+ state.model.attachmentPhase === "reserved"
591
+ );
592
+ }
593
+
594
+ export function composerStatus(state = view) {
595
+ const current = state.model;
596
+ if (current.readingImages > 0) return "Staging images on this Pi session…";
597
+ if (current.pending) return "Submitting message…";
598
+ const notice = deliveryNotice(current);
599
+ if (notice) return notice;
600
+ if (!current.connected) return "Connection unavailable · Draft is preserved.";
601
+ if (current.closed) return "This Pi session has ended.";
602
+ if (current.stale) return "Another tab controls this session.";
603
+ if (current.activity === "running") {
604
+ return "Pi is working · Queue waits; Steer redirects the active turn.";
605
+ }
606
+ return "Pi is idle · Messages send immediately.";
607
+ }
608
+
609
+ export function connectionLabel(current = model) {
610
+ if (current.closed) return "Session ended";
611
+ if (!current.connected) return "Reconnecting…";
612
+ if (current.stale) return "Read-only tab";
613
+ return current.activity === "running" ? "Pi is working" : "Connected";
614
+ }
615
+
616
+ export function attachmentPhaseLabel(phase) {
617
+ if (phase === "uploading") return "Uploading";
618
+ if (phase === "processing") return "Processing";
619
+ if (phase === "blocked") return "Needs attention";
620
+ if (phase === "reserved") return "Submitting";
621
+ return "Ready";
622
+ }
623
+
624
+ export function attachmentItemLabel(image, progress) {
625
+ if (image.status === "uploading" && progress?.total > 0) {
626
+ const percent = Math.min(100, Math.round((progress.loaded / progress.total) * 100));
627
+ return `Uploading · ${percent}%`;
628
+ }
629
+ if (image.status === "uploading") return "Uploading…";
630
+ if (image.status === "processing") return "Processing…";
631
+ if (image.status === "error") return image.error || "Needs attention";
632
+ const dimensions =
633
+ Number.isSafeInteger(image.width) && Number.isSafeInteger(image.height)
634
+ ? ` · ${image.width}×${image.height}`
635
+ : "";
636
+ return `Ready${dimensions}`;
637
+ }
638
+
639
+ export function canSubmit(state = view) {
640
+ return !composerLocked(state) && canSend(state.model);
641
+ }
642
+
643
+ export function primaryActionLabel(state = view) {
644
+ return busyLabel(state.model);
645
+ }
646
+
647
+ function isSupportedImageFile(file) {
648
+ return (
649
+ SUPPORTED_IMAGE_TYPES.has(file.type) ||
650
+ /\.(?:bmp|tif|tiff|heic|heif|avif)$/i.test(file.name || "")
651
+ );
652
+ }
653
+
654
+ function conversationUpdateKey(event) {
655
+ if (event.type === "message" && event.payload?.id) return `message:${event.payload.id}`;
656
+ if (event.type === "tool" && event.payload?.id) return `tool:${event.payload.id}`;
657
+ return event.type;
658
+ }
659
+
660
+ function conversationAnnouncement(event) {
661
+ if (event.type === "message" && event.payload?.final && event.payload.role === "assistant") {
662
+ return "New completed message from Pi.";
663
+ }
664
+ if (event.type === "tool" && event.payload?.phase === "end") {
665
+ return event.payload.isError ? "Tool failed." : "Tool completed.";
666
+ }
667
+ return "";
668
+ }
669
+
670
+ function setError(message) {
671
+ model = { ...model, error: message };
672
+ emit();
673
+ }
674
+
675
+ async function responseError(response) {
676
+ try {
677
+ const body = await response.json();
678
+ return body.error || `${response.status} ${response.statusText}`;
679
+ } catch {
680
+ return `${response.status} ${response.statusText}`;
681
+ }
682
+ }
683
+
684
+ function formatMib(bytes) {
685
+ return `${bytes / (1024 * 1024)} MiB`;
686
+ }
687
+
688
+ function errorMessage(error) {
689
+ return error instanceof Error ? error.message : String(error);
690
+ }