@narumitw/pi-webui 0.21.0 → 0.25.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 +59 -27
- 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/web/app.js
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
|
+
import { createImageDragController } from "./image-drag.js";
|
|
1
2
|
import {
|
|
3
|
+
acknowledgeDraftText,
|
|
4
|
+
applyAttachments,
|
|
2
5
|
applyConversationEvent,
|
|
6
|
+
applyDraft,
|
|
7
|
+
applyImageLimits,
|
|
3
8
|
applyLease,
|
|
9
|
+
applySentImages,
|
|
4
10
|
applySnapshot,
|
|
5
11
|
busyLabel,
|
|
6
12
|
canSend,
|
|
7
13
|
completeSend,
|
|
8
14
|
deliveryNotice,
|
|
15
|
+
editDraftText,
|
|
9
16
|
failSend,
|
|
10
17
|
followLatest,
|
|
11
18
|
initialState,
|
|
12
19
|
invalidateSendAttempt,
|
|
20
|
+
moveImage,
|
|
21
|
+
moveImageAfter,
|
|
22
|
+
moveImageBefore,
|
|
13
23
|
noteUnseenUpdate,
|
|
14
24
|
prepareSend,
|
|
15
25
|
setNearBottom,
|
|
@@ -25,8 +35,27 @@ let snapshotRefresh;
|
|
|
25
35
|
let snapshotTarget = 0;
|
|
26
36
|
let transcriptFrame;
|
|
27
37
|
let transcriptAnnouncement = "";
|
|
38
|
+
let lastAttachmentAnnouncement = "";
|
|
28
39
|
let dragDepth = 0;
|
|
29
40
|
let previewReturnFocus;
|
|
41
|
+
let mutatingAttachments = false;
|
|
42
|
+
let draftSaveTimer;
|
|
43
|
+
let draftSaveQueue = Promise.resolve();
|
|
44
|
+
const retryFiles = new Map();
|
|
45
|
+
const uploadProgress = new Map();
|
|
46
|
+
|
|
47
|
+
const SUPPORTED_IMAGE_TYPES = new Set([
|
|
48
|
+
"image/png",
|
|
49
|
+
"image/jpeg",
|
|
50
|
+
"image/webp",
|
|
51
|
+
"image/gif",
|
|
52
|
+
"image/bmp",
|
|
53
|
+
"image/x-ms-bmp",
|
|
54
|
+
"image/tiff",
|
|
55
|
+
"image/heic",
|
|
56
|
+
"image/heif",
|
|
57
|
+
"image/avif",
|
|
58
|
+
]);
|
|
30
59
|
|
|
31
60
|
const ui = {
|
|
32
61
|
project: document.querySelector("#project-name"),
|
|
@@ -44,8 +73,12 @@ const ui = {
|
|
|
44
73
|
input: document.querySelector("#message-input"),
|
|
45
74
|
imageInput: document.querySelector("#image-input"),
|
|
46
75
|
addImages: document.querySelector('label[for="image-input"]'),
|
|
76
|
+
clearAttachments: document.querySelector("#clear-attachments"),
|
|
77
|
+
clearDialog: document.querySelector("#clear-attachments-dialog"),
|
|
78
|
+
clearMessage: document.querySelector("#clear-attachments-message"),
|
|
47
79
|
previews: document.querySelector("#image-previews"),
|
|
48
|
-
|
|
80
|
+
attachmentSummary: document.querySelector("#attachment-summary"),
|
|
81
|
+
attachmentAnnouncement: document.querySelector("#attachment-announcement"),
|
|
49
82
|
status: document.querySelector("#composer-status"),
|
|
50
83
|
error: document.querySelector("#composer-error"),
|
|
51
84
|
send: document.querySelector("#send-next"),
|
|
@@ -57,10 +90,26 @@ const ui = {
|
|
|
57
90
|
previewDismiss: document.querySelector("#image-preview-dismiss"),
|
|
58
91
|
};
|
|
59
92
|
|
|
60
|
-
const
|
|
93
|
+
const imageDrag = createImageDragController(ui.previews, {
|
|
94
|
+
isLocked: composerLocked,
|
|
95
|
+
onDrop: ({ sourceId, targetId, after }) => {
|
|
96
|
+
const images = after
|
|
97
|
+
? moveImageAfter(model.images, sourceId, targetId)
|
|
98
|
+
: moveImageBefore(model.images, sourceId, targetId);
|
|
99
|
+
void reorderImages(images, sourceId);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const transcriptRenderer = createTranscriptRenderer({
|
|
104
|
+
documentRef: document,
|
|
105
|
+
list: ui.transcript,
|
|
106
|
+
onReattach: (id) => void reattachSentImage(id),
|
|
107
|
+
onForget: (id) => void forgetSentImage(id),
|
|
108
|
+
});
|
|
61
109
|
|
|
62
110
|
ui.input.addEventListener("input", () => {
|
|
63
|
-
model =
|
|
111
|
+
model = editDraftText(model, ui.input.value);
|
|
112
|
+
scheduleDraftSave();
|
|
64
113
|
resizeInput();
|
|
65
114
|
renderComposer();
|
|
66
115
|
});
|
|
@@ -73,6 +122,19 @@ ui.imageInput.addEventListener("change", () => {
|
|
|
73
122
|
void addFiles(ui.imageInput.files);
|
|
74
123
|
ui.imageInput.value = "";
|
|
75
124
|
});
|
|
125
|
+
ui.clearAttachments.addEventListener("click", () => {
|
|
126
|
+
if (composerLocked() || model.images.length < 2) return;
|
|
127
|
+
ui.clearDialog.returnValue = "cancel";
|
|
128
|
+
ui.clearMessage.textContent = `Remove all ${model.images.length} unsent image attachments? Message text will be kept.`;
|
|
129
|
+
ui.clearDialog.showModal();
|
|
130
|
+
});
|
|
131
|
+
ui.clearDialog.addEventListener("close", () => {
|
|
132
|
+
if (ui.clearDialog.returnValue !== "confirm") {
|
|
133
|
+
requestAnimationFrame(() => ui.clearAttachments.focus());
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
void clearAttachments();
|
|
137
|
+
});
|
|
76
138
|
ui.input.addEventListener("keydown", (event) => {
|
|
77
139
|
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
78
140
|
event.preventDefault();
|
|
@@ -95,9 +157,7 @@ window.addEventListener(
|
|
|
95
157
|
{ passive: true },
|
|
96
158
|
);
|
|
97
159
|
document.addEventListener("paste", (event) => {
|
|
98
|
-
const files = [...(event.clipboardData?.files ?? [])].filter(
|
|
99
|
-
file.type.startsWith("image/"),
|
|
100
|
-
);
|
|
160
|
+
const files = [...(event.clipboardData?.files ?? [])].filter(isSupportedImageFile);
|
|
101
161
|
if (files.length === 0 || composerLocked()) return;
|
|
102
162
|
event.preventDefault();
|
|
103
163
|
void addFiles(files);
|
|
@@ -119,9 +179,7 @@ ui.composer.addEventListener("dragleave", () => {
|
|
|
119
179
|
ui.composer.addEventListener("drop", (event) => {
|
|
120
180
|
dragDepth = 0;
|
|
121
181
|
ui.composer.classList.remove("drag-active");
|
|
122
|
-
const files = [...(event.dataTransfer?.files ?? [])].filter(
|
|
123
|
-
file.type.startsWith("image/"),
|
|
124
|
-
);
|
|
182
|
+
const files = [...(event.dataTransfer?.files ?? [])].filter(isSupportedImageFile);
|
|
125
183
|
if (files.length === 0 || composerLocked()) return;
|
|
126
184
|
event.preventDefault();
|
|
127
185
|
void addFiles(files);
|
|
@@ -130,6 +188,7 @@ ui.previewClose.addEventListener("click", () => ui.previewDialog.close());
|
|
|
130
188
|
ui.previewDismiss.addEventListener("click", () => ui.previewDialog.close());
|
|
131
189
|
ui.previewDialog.addEventListener("close", () => {
|
|
132
190
|
ui.previewImage.removeAttribute("src");
|
|
191
|
+
delete ui.previewImage.dataset.imageId;
|
|
133
192
|
ui.previewImage.alt = "";
|
|
134
193
|
if (previewReturnFocus?.isConnected) previewReturnFocus.focus();
|
|
135
194
|
previewReturnFocus = undefined;
|
|
@@ -178,6 +237,7 @@ async function claimLease() {
|
|
|
178
237
|
});
|
|
179
238
|
if (!response.ok) throw new Error(await responseError(response));
|
|
180
239
|
model = applyLease(model, await response.json(), clientId, true);
|
|
240
|
+
if (model.textDirty) scheduleDraftSave();
|
|
181
241
|
render();
|
|
182
242
|
}
|
|
183
243
|
|
|
@@ -188,6 +248,7 @@ function connectEvents() {
|
|
|
188
248
|
events.addEventListener("open", () => {
|
|
189
249
|
reconnectDelay = 500;
|
|
190
250
|
model = { ...model, connected: true, error: "" };
|
|
251
|
+
if (model.textDirty) scheduleDraftSave();
|
|
191
252
|
render();
|
|
192
253
|
});
|
|
193
254
|
events.addEventListener("conversation", (event) => {
|
|
@@ -210,6 +271,22 @@ function connectEvents() {
|
|
|
210
271
|
model = applyLease(model, JSON.parse(event.data), clientId);
|
|
211
272
|
render();
|
|
212
273
|
});
|
|
274
|
+
events.addEventListener("draft", (event) => {
|
|
275
|
+
model = applyDraft(model, JSON.parse(event.data));
|
|
276
|
+
renderComposer();
|
|
277
|
+
});
|
|
278
|
+
events.addEventListener("attachments", (event) => {
|
|
279
|
+
model = applyAttachments(model, JSON.parse(event.data));
|
|
280
|
+
render();
|
|
281
|
+
});
|
|
282
|
+
events.addEventListener("image-limits", (event) => {
|
|
283
|
+
model = applyImageLimits(model, JSON.parse(event.data));
|
|
284
|
+
renderComposer();
|
|
285
|
+
});
|
|
286
|
+
events.addEventListener("sent-images", (event) => {
|
|
287
|
+
model = applySentImages(model, JSON.parse(event.data));
|
|
288
|
+
render({ updateKey: "sent-images" });
|
|
289
|
+
});
|
|
213
290
|
events.addEventListener("session-ended", () => {
|
|
214
291
|
model = { ...model, closed: true, activity: "ended", connected: false };
|
|
215
292
|
events?.close();
|
|
@@ -246,15 +323,22 @@ function connectionFailure(error) {
|
|
|
246
323
|
}
|
|
247
324
|
|
|
248
325
|
async function send(steer) {
|
|
249
|
-
if (!canSend(model)) return;
|
|
326
|
+
if (composerLocked() || !canSend(model)) return;
|
|
327
|
+
try {
|
|
328
|
+
await flushDraftText();
|
|
329
|
+
} catch (error) {
|
|
330
|
+
model = { ...model, error: errorMessage(error) };
|
|
331
|
+
render();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (composerLocked() || !canSend(model) || model.textDirty) return;
|
|
250
335
|
const prepared = prepareSend(model, crypto.randomUUID(), steer ? "steer" : "next");
|
|
251
336
|
const attempt = prepared.attempt;
|
|
252
337
|
model = prepared.state;
|
|
253
338
|
renderComposer();
|
|
254
339
|
const payload = {
|
|
255
340
|
requestId: attempt.requestId,
|
|
256
|
-
|
|
257
|
-
images: attempt.images.map(({ name, mimeType, data }) => ({ name, mimeType, data })),
|
|
341
|
+
draftRevision: attempt.draftRevision,
|
|
258
342
|
delivery: attempt.delivery,
|
|
259
343
|
};
|
|
260
344
|
try {
|
|
@@ -269,10 +353,13 @@ async function send(steer) {
|
|
|
269
353
|
throw new Error(message);
|
|
270
354
|
}
|
|
271
355
|
const accepted = await response.json();
|
|
272
|
-
|
|
273
|
-
if (
|
|
356
|
+
if (accepted.draft) model = applyDraft(model, accepted.draft);
|
|
357
|
+
if (accepted.attachments) model = applyAttachments(model, accepted.attachments);
|
|
358
|
+
if (accepted.sentImages) model = applySentImages(model, accepted.sentImages);
|
|
359
|
+
if (attempt.attachmentIds.includes(ui.previewImage.dataset.imageId)) {
|
|
274
360
|
ui.previewDialog.close();
|
|
275
361
|
}
|
|
362
|
+
for (const id of attempt.attachmentIds) retryFiles.delete(id);
|
|
276
363
|
model = completeSend(model, attempt, accepted.delivery);
|
|
277
364
|
render();
|
|
278
365
|
ui.input.focus();
|
|
@@ -283,94 +370,350 @@ async function send(steer) {
|
|
|
283
370
|
}
|
|
284
371
|
|
|
285
372
|
async function addFiles(fileList) {
|
|
286
|
-
if (composerLocked()) return;
|
|
287
|
-
const supported = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
373
|
+
if (composerLocked() || model.readingImages > 0) return;
|
|
288
374
|
const files = [...(fileList ?? [])];
|
|
289
|
-
if (
|
|
290
|
-
|
|
375
|
+
if (files.length === 0) return;
|
|
376
|
+
const limits = model.imageLimits;
|
|
377
|
+
if (!limits) {
|
|
378
|
+
model = { ...model, error: "Effective image limits are still loading." };
|
|
379
|
+
render();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (model.images.length + files.length > limits.maxImages) {
|
|
383
|
+
model = { ...model, error: `You can attach at most ${limits.maxImages} images.` };
|
|
291
384
|
render();
|
|
292
385
|
return;
|
|
293
386
|
}
|
|
294
|
-
|
|
387
|
+
for (const file of files) {
|
|
388
|
+
if (!isSupportedImageFile(file)) {
|
|
389
|
+
model = { ...model, error: `${file.name || "Image"} is not a supported image.` };
|
|
390
|
+
render();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (file.size > limits.maxImageBytes) {
|
|
394
|
+
model = {
|
|
395
|
+
...model,
|
|
396
|
+
error: `${file.name || "Image"} is larger than ${formatMib(limits.maxImageBytes)}.`,
|
|
397
|
+
};
|
|
398
|
+
render();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const batchBytes =
|
|
403
|
+
model.images.reduce((total, image) => total + (image.size ?? 0), 0) +
|
|
404
|
+
files.reduce((total, file) => total + file.size, 0);
|
|
405
|
+
if (batchBytes > limits.maxBatchBytes) {
|
|
406
|
+
model = {
|
|
407
|
+
...model,
|
|
408
|
+
error: `Combined image input is larger than ${formatMib(limits.maxBatchBytes)}.`,
|
|
409
|
+
};
|
|
410
|
+
render();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const pending = files.map((file) => ({ id: crypto.randomUUID(), file }));
|
|
414
|
+
try {
|
|
415
|
+
mutatingAttachments = true;
|
|
416
|
+
renderComposer();
|
|
417
|
+
const response = await attachmentMutation("/api/attachments/reserve", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
body: JSON.stringify({
|
|
420
|
+
revision: model.attachmentRevision,
|
|
421
|
+
items: pending.map(({ id, file }) => ({
|
|
422
|
+
id,
|
|
423
|
+
name: file.name || "Pasted image",
|
|
424
|
+
size: file.size,
|
|
425
|
+
mimeType: file.type,
|
|
426
|
+
})),
|
|
427
|
+
}),
|
|
428
|
+
});
|
|
429
|
+
model = applyAttachments(model, response);
|
|
430
|
+
for (const { id, file } of pending) retryFiles.set(id, file);
|
|
431
|
+
render();
|
|
432
|
+
for (const { id, file } of pending) await uploadFile(id, file);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
model = { ...model, error: errorMessage(error) };
|
|
435
|
+
render();
|
|
436
|
+
} finally {
|
|
437
|
+
mutatingAttachments = false;
|
|
438
|
+
renderComposer();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function uploadFile(id, file) {
|
|
443
|
+
uploadProgress.set(id, { loaded: 0, total: file.size });
|
|
295
444
|
renderComposer();
|
|
296
445
|
try {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
446
|
+
const state = await uploadAttachment(
|
|
447
|
+
`/api/attachments/${encodeURIComponent(id)}/upload?revision=${model.attachmentRevision}`,
|
|
448
|
+
file,
|
|
449
|
+
(progress) => {
|
|
450
|
+
uploadProgress.set(id, progress);
|
|
451
|
+
renderComposer();
|
|
452
|
+
},
|
|
453
|
+
);
|
|
454
|
+
retryFiles.delete(id);
|
|
455
|
+
model = applyAttachments(model, state);
|
|
456
|
+
render();
|
|
457
|
+
} finally {
|
|
458
|
+
uploadProgress.delete(id);
|
|
459
|
+
renderComposer();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function uploadAttachment(path, file, onProgress) {
|
|
464
|
+
return new Promise((resolve, reject) => {
|
|
465
|
+
const request = new XMLHttpRequest();
|
|
466
|
+
request.open("POST", path);
|
|
467
|
+
request.responseType = "json";
|
|
468
|
+
request.setRequestHeader("Content-Type", "application/octet-stream");
|
|
469
|
+
request.setRequestHeader("X-Pi-Web-Client", clientId);
|
|
470
|
+
request.upload.addEventListener("progress", (event) => {
|
|
471
|
+
if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
|
|
472
|
+
});
|
|
473
|
+
request.addEventListener("load", () => {
|
|
474
|
+
if (request.status >= 200 && request.status < 300) {
|
|
475
|
+
resolve(request.response);
|
|
476
|
+
return;
|
|
328
477
|
}
|
|
478
|
+
reject(new Error(request.response?.error || `Request failed (${request.status}).`));
|
|
479
|
+
});
|
|
480
|
+
request.addEventListener("error", () => reject(new Error("Image upload failed.")));
|
|
481
|
+
request.addEventListener("abort", () => reject(new Error("Image upload was cancelled.")));
|
|
482
|
+
request.send(file);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function retryImage(id) {
|
|
487
|
+
if (composerLocked()) return;
|
|
488
|
+
mutatingAttachments = true;
|
|
489
|
+
renderComposer();
|
|
490
|
+
try {
|
|
491
|
+
const file = retryFiles.get(id);
|
|
492
|
+
if (file) {
|
|
493
|
+
await uploadFile(id, file);
|
|
494
|
+
} else {
|
|
495
|
+
const response = await attachmentMutation(
|
|
496
|
+
`/api/attachments/${encodeURIComponent(id)}/retry`,
|
|
497
|
+
{
|
|
498
|
+
method: "POST",
|
|
499
|
+
body: JSON.stringify({ revision: model.attachmentRevision }),
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
model = applyAttachments(model, response);
|
|
329
503
|
render();
|
|
330
504
|
}
|
|
505
|
+
} catch (error) {
|
|
506
|
+
model = { ...model, error: errorMessage(error) };
|
|
507
|
+
render();
|
|
508
|
+
} finally {
|
|
509
|
+
mutatingAttachments = false;
|
|
510
|
+
renderComposer();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function scheduleDraftSave() {
|
|
515
|
+
clearTimeout(draftSaveTimer);
|
|
516
|
+
if (!model.textDirty || model.closed || model.stale || !model.connected) return;
|
|
517
|
+
draftSaveTimer = setTimeout(() => {
|
|
518
|
+
draftSaveTimer = undefined;
|
|
519
|
+
void flushDraftText().catch((error) => {
|
|
520
|
+
model = { ...model, error: errorMessage(error) };
|
|
521
|
+
render();
|
|
522
|
+
});
|
|
523
|
+
}, 180);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function flushDraftText() {
|
|
527
|
+
clearTimeout(draftSaveTimer);
|
|
528
|
+
draftSaveTimer = undefined;
|
|
529
|
+
draftSaveQueue = draftSaveQueue
|
|
530
|
+
.catch(() => undefined)
|
|
531
|
+
.then(async () => {
|
|
532
|
+
while (model.textDirty) {
|
|
533
|
+
if (model.closed || model.stale || !model.connected) {
|
|
534
|
+
throw new Error("Reconnect the active tab to save this draft.");
|
|
535
|
+
}
|
|
536
|
+
const submittedText = model.text;
|
|
537
|
+
const response = await fetch("/api/draft", {
|
|
538
|
+
method: "POST",
|
|
539
|
+
headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
|
|
540
|
+
body: JSON.stringify({
|
|
541
|
+
requestId: crypto.randomUUID(),
|
|
542
|
+
revision: model.draftRevision,
|
|
543
|
+
text: submittedText,
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
if (response.status === 409) {
|
|
547
|
+
await refreshSnapshot();
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!response.ok) throw new Error(await responseError(response));
|
|
551
|
+
model = acknowledgeDraftText(model, await response.json(), submittedText);
|
|
552
|
+
renderComposer();
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
return draftSaveQueue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function isSupportedImageFile(file) {
|
|
559
|
+
return (
|
|
560
|
+
SUPPORTED_IMAGE_TYPES.has(file.type) ||
|
|
561
|
+
/\.(?:bmp|tif|tiff|heic|heif|avif)$/i.test(file.name || "")
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function reattachSentImage(retainedImageId) {
|
|
566
|
+
if (composerLocked() || !retainedImageId) return;
|
|
567
|
+
if (model.attachmentPhase !== "empty" && model.attachmentPhase !== "ready") {
|
|
568
|
+
model = { ...model, error: "Wait for current images to finish before attaching again." };
|
|
569
|
+
render();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
mutatingAttachments = true;
|
|
573
|
+
renderComposer();
|
|
574
|
+
try {
|
|
575
|
+
const response = await attachmentMutation("/api/sent-images/reattach", {
|
|
576
|
+
method: "POST",
|
|
577
|
+
body: JSON.stringify({
|
|
578
|
+
revision: model.attachmentRevision,
|
|
579
|
+
items: [{ retainedId: retainedImageId, id: crypto.randomUUID() }],
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
model = applyDraft(model, response.draft);
|
|
583
|
+
model = applyAttachments(model, response.attachments);
|
|
584
|
+
model = applySentImages(model, response.sentImages);
|
|
585
|
+
model = { ...model, error: "" };
|
|
586
|
+
render();
|
|
587
|
+
} catch (error) {
|
|
588
|
+
model = { ...model, error: errorMessage(error) };
|
|
589
|
+
render();
|
|
331
590
|
} finally {
|
|
332
|
-
|
|
591
|
+
mutatingAttachments = false;
|
|
333
592
|
renderComposer();
|
|
334
593
|
}
|
|
335
594
|
}
|
|
336
595
|
|
|
337
|
-
function
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
596
|
+
async function forgetSentImage(retainedImageId) {
|
|
597
|
+
if (composerLocked() || !retainedImageId) return;
|
|
598
|
+
if (!window.confirm("Forget this retained image? This does not retract provider content."))
|
|
599
|
+
return;
|
|
600
|
+
try {
|
|
601
|
+
const response = await attachmentMutation(
|
|
602
|
+
`/api/sent-images/${encodeURIComponent(retainedImageId)}?revision=${model.sentImages.revision}`,
|
|
603
|
+
{ method: "DELETE" },
|
|
604
|
+
);
|
|
605
|
+
model = applySentImages(model, response);
|
|
606
|
+
render({ updateKey: "sent-images" });
|
|
607
|
+
} catch (error) {
|
|
608
|
+
model = { ...model, error: errorMessage(error) };
|
|
609
|
+
render();
|
|
343
610
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function clearAttachments() {
|
|
614
|
+
if (composerLocked() || model.images.length < 2) return;
|
|
615
|
+
const clearedIds = model.images.map((image) => image.id);
|
|
616
|
+
if (clearedIds.includes(ui.previewImage.dataset.imageId)) ui.previewDialog.close();
|
|
617
|
+
const state = await mutateAttachmentState("/api/attachments/clear", {
|
|
618
|
+
method: "POST",
|
|
619
|
+
body: JSON.stringify({ revision: model.attachmentRevision }),
|
|
347
620
|
});
|
|
348
|
-
|
|
621
|
+
if (!state) return;
|
|
622
|
+
for (const id of clearedIds) retryFiles.delete(id);
|
|
623
|
+
ui.input.focus();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function reorderImages(images, focusId) {
|
|
627
|
+
if (composerLocked()) return;
|
|
628
|
+
if (images.every((image, index) => image.id === model.images[index]?.id)) {
|
|
629
|
+
imageDrag.focus(focusId);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const previousImages = model.images;
|
|
633
|
+
const previousRevision = model.attachmentRevision;
|
|
634
|
+
model = { ...model, images };
|
|
635
|
+
mutatingAttachments = true;
|
|
636
|
+
renderComposer();
|
|
637
|
+
imageDrag.focus(focusId);
|
|
638
|
+
ui.previews.setAttribute("aria-busy", "true");
|
|
639
|
+
try {
|
|
640
|
+
const state = await attachmentMutation("/api/attachments/reorder", {
|
|
641
|
+
method: "POST",
|
|
642
|
+
body: JSON.stringify({
|
|
643
|
+
revision: previousRevision,
|
|
644
|
+
ids: images.map((image) => image.id),
|
|
645
|
+
}),
|
|
646
|
+
});
|
|
647
|
+
model = applyAttachments(model, state);
|
|
648
|
+
model = { ...model, error: "" };
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (model.attachmentRevision === previousRevision) {
|
|
651
|
+
model = { ...model, images: previousImages };
|
|
652
|
+
}
|
|
653
|
+
model = { ...model, error: errorMessage(error) };
|
|
654
|
+
} finally {
|
|
655
|
+
mutatingAttachments = false;
|
|
656
|
+
ui.previews.removeAttribute("aria-busy");
|
|
657
|
+
render();
|
|
658
|
+
imageDrag.focus(focusId);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function removeImage(id) {
|
|
663
|
+
if (composerLocked()) return;
|
|
664
|
+
if (ui.previewImage.dataset.imageId === id) ui.previewDialog.close();
|
|
665
|
+
const state = await mutateAttachmentState(
|
|
666
|
+
`/api/attachments/${encodeURIComponent(id)}?revision=${model.attachmentRevision}`,
|
|
667
|
+
{ method: "DELETE", headers: { "Content-Type": "application/json" } },
|
|
668
|
+
);
|
|
669
|
+
if (state) {
|
|
670
|
+
retryFiles.delete(id);
|
|
671
|
+
uploadProgress.delete(id);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function mutateAttachmentState(path, options) {
|
|
676
|
+
if (composerLocked()) return;
|
|
677
|
+
mutatingAttachments = true;
|
|
678
|
+
renderComposer();
|
|
679
|
+
try {
|
|
680
|
+
const state = await attachmentMutation(path, options);
|
|
681
|
+
model = applyAttachments(model, state);
|
|
682
|
+
model = { ...model, error: "" };
|
|
683
|
+
render();
|
|
684
|
+
return state;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
model = { ...model, error: errorMessage(error) };
|
|
687
|
+
render();
|
|
688
|
+
} finally {
|
|
689
|
+
mutatingAttachments = false;
|
|
690
|
+
renderComposer();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function attachmentMutation(path, options) {
|
|
695
|
+
const response = await fetch(path, {
|
|
696
|
+
...options,
|
|
697
|
+
headers: {
|
|
698
|
+
"Content-Type": "application/json",
|
|
699
|
+
"X-Pi-Web-Client": clientId,
|
|
700
|
+
...options.headers,
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
if (!response.ok) throw new Error(await responseError(response));
|
|
704
|
+
return response.json();
|
|
349
705
|
}
|
|
350
706
|
|
|
351
707
|
function openImagePreview(image) {
|
|
708
|
+
if (image.status !== "ready") return;
|
|
352
709
|
previewReturnFocus = document.activeElement;
|
|
353
710
|
ui.previewTitle.textContent = image.name;
|
|
354
|
-
ui.previewImage.src = image.
|
|
711
|
+
ui.previewImage.src = `/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`;
|
|
712
|
+
ui.previewImage.dataset.imageId = image.id;
|
|
355
713
|
ui.previewImage.alt = image.name;
|
|
356
714
|
ui.previewDialog.showModal();
|
|
357
715
|
}
|
|
358
716
|
|
|
359
|
-
function readBase64(file) {
|
|
360
|
-
return new Promise((resolve, reject) => {
|
|
361
|
-
const reader = new FileReader();
|
|
362
|
-
reader.addEventListener("error", () => reject(new Error(`Could not read ${file.name}.`)));
|
|
363
|
-
reader.addEventListener("load", () => {
|
|
364
|
-
if (typeof reader.result !== "string") {
|
|
365
|
-
reject(new Error(`Could not read ${file.name}.`));
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
resolve(reader.result.slice(reader.result.indexOf(",") + 1));
|
|
369
|
-
});
|
|
370
|
-
reader.readAsDataURL(file);
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
717
|
function render(options = {}) {
|
|
375
718
|
renderHeader();
|
|
376
719
|
queueTranscriptRender(options.updateKey, options.announcement);
|
|
@@ -395,7 +738,7 @@ function queueTranscriptRender(updateKey, announcement) {
|
|
|
395
738
|
if (transcriptFrame) return;
|
|
396
739
|
transcriptFrame = requestAnimationFrame(() => {
|
|
397
740
|
transcriptFrame = undefined;
|
|
398
|
-
transcriptRenderer.render(model.messages, model.tools);
|
|
741
|
+
transcriptRenderer.render(model.messages, model.tools, model.sentImages);
|
|
399
742
|
ui.empty.hidden = model.messages.length > 0;
|
|
400
743
|
if (transcriptAnnouncement) {
|
|
401
744
|
ui.transcriptStatus.textContent = transcriptAnnouncement;
|
|
@@ -412,53 +755,135 @@ function renderComposer() {
|
|
|
412
755
|
const locked = composerLocked();
|
|
413
756
|
ui.composer.classList.toggle("locked", locked);
|
|
414
757
|
ui.send.textContent = busyLabel(model);
|
|
415
|
-
ui.send.disabled = !canSend(model);
|
|
758
|
+
ui.send.disabled = locked || !canSend(model);
|
|
416
759
|
ui.steer.hidden = model.activity !== "running";
|
|
417
|
-
ui.steer.disabled = !canSend(model);
|
|
418
|
-
ui.input.disabled =
|
|
419
|
-
|
|
420
|
-
ui.
|
|
421
|
-
ui.addImages.
|
|
760
|
+
ui.steer.disabled = locked || !canSend(model);
|
|
761
|
+
ui.input.disabled = model.closed || model.stale;
|
|
762
|
+
const admissionLocked = locked || model.readingImages > 0;
|
|
763
|
+
ui.imageInput.disabled = admissionLocked;
|
|
764
|
+
ui.addImages.classList.toggle("disabled", admissionLocked);
|
|
765
|
+
ui.addImages.setAttribute("aria-disabled", String(admissionLocked));
|
|
766
|
+
ui.clearAttachments.hidden = model.images.length < 2;
|
|
767
|
+
ui.clearAttachments.disabled = locked;
|
|
422
768
|
ui.status.textContent = composerStatus();
|
|
423
769
|
ui.error.hidden = !model.error;
|
|
424
770
|
ui.error.textContent = model.error;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
771
|
+
const maximum = model.imageLimits?.maxImages ?? model.images.length;
|
|
772
|
+
ui.attachmentSummary.hidden = model.images.length === 0;
|
|
773
|
+
ui.attachmentSummary.textContent = model.images.length
|
|
774
|
+
? `${model.images.length}/${maximum} images attached · Sensitive metadata is removed before sending.`
|
|
775
|
+
: "";
|
|
776
|
+
const attachmentAnnouncement = model.images.length
|
|
777
|
+
? `Attachment state: ${attachmentPhaseLabel(model.attachmentPhase)}.`
|
|
778
|
+
: "";
|
|
779
|
+
if (attachmentAnnouncement !== lastAttachmentAnnouncement) {
|
|
780
|
+
lastAttachmentAnnouncement = attachmentAnnouncement;
|
|
781
|
+
ui.attachmentAnnouncement.textContent = attachmentAnnouncement;
|
|
432
782
|
}
|
|
433
783
|
ui.previews.replaceChildren();
|
|
434
|
-
for (const image of model.images) {
|
|
784
|
+
for (const [index, image] of model.images.entries()) {
|
|
435
785
|
const item = document.createElement("li");
|
|
436
786
|
item.className = "image-preview-item";
|
|
787
|
+
item.dataset.imageId = image.id;
|
|
788
|
+
const retryable = image.status === "error" && (image.retryable || retryFiles.has(image.id));
|
|
789
|
+
const orderingLocked = locked || model.attachmentPhase !== "ready";
|
|
790
|
+
const reorderable = model.images.length > 1 && image.status === "ready";
|
|
791
|
+
item.draggable = model.images.length > 1 && image.status === "ready" && !orderingLocked;
|
|
792
|
+
if (reorderable) {
|
|
793
|
+
item.tabIndex = 0;
|
|
794
|
+
item.setAttribute(
|
|
795
|
+
"aria-label",
|
|
796
|
+
`${image.name}, image ${index + 1} of ${model.images.length}. Drag to reorder or press Alt plus Up or Down Arrow.`,
|
|
797
|
+
);
|
|
798
|
+
item.setAttribute("aria-keyshortcuts", "Alt+ArrowUp Alt+ArrowDown");
|
|
799
|
+
}
|
|
800
|
+
item.addEventListener("keydown", (event) => {
|
|
801
|
+
if (
|
|
802
|
+
event.target !== item ||
|
|
803
|
+
composerLocked() ||
|
|
804
|
+
orderingLocked ||
|
|
805
|
+
!event.altKey ||
|
|
806
|
+
event.ctrlKey ||
|
|
807
|
+
event.metaKey
|
|
808
|
+
)
|
|
809
|
+
return;
|
|
810
|
+
const direction = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
|
|
811
|
+
if (direction === 0 || index + direction < 0 || index + direction >= model.images.length)
|
|
812
|
+
return;
|
|
813
|
+
event.preventDefault();
|
|
814
|
+
void reorderImages(moveImage(model.images, image.id, direction), image.id);
|
|
815
|
+
});
|
|
816
|
+
imageDrag.bind(item, { id: image.id, orderingLocked });
|
|
437
817
|
const previewButton = document.createElement("button");
|
|
438
818
|
previewButton.type = "button";
|
|
439
819
|
previewButton.className = "attachment-preview";
|
|
440
820
|
previewButton.setAttribute("aria-label", `Preview image ${image.name}`);
|
|
441
|
-
previewButton.disabled =
|
|
821
|
+
previewButton.disabled = locked || image.status !== "ready";
|
|
442
822
|
previewButton.addEventListener("click", () => openImagePreview(image));
|
|
443
823
|
const preview = document.createElement("img");
|
|
444
|
-
|
|
824
|
+
if (image.status === "ready") {
|
|
825
|
+
preview.src = `/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`;
|
|
826
|
+
}
|
|
445
827
|
preview.alt = "";
|
|
828
|
+
preview.draggable = false;
|
|
446
829
|
previewButton.append(preview);
|
|
830
|
+
const details = document.createElement("span");
|
|
831
|
+
details.className = "attachment-details";
|
|
447
832
|
const name = document.createElement("span");
|
|
448
833
|
name.textContent = image.name;
|
|
834
|
+
const itemStatus = document.createElement("span");
|
|
835
|
+
itemStatus.className = `attachment-item-status ${image.status}`;
|
|
836
|
+
itemStatus.textContent = attachmentItemLabel(image, uploadProgress.get(image.id));
|
|
837
|
+
details.append(name, itemStatus);
|
|
838
|
+
if (Array.isArray(image.notes) && image.notes.length > 0) {
|
|
839
|
+
const summary = document.createElement("span");
|
|
840
|
+
summary.className = "attachment-conversion-summary";
|
|
841
|
+
summary.textContent = image.notes.join(" · ");
|
|
842
|
+
details.append(summary);
|
|
843
|
+
}
|
|
844
|
+
if (model.images.length > 1) {
|
|
845
|
+
const order = document.createElement("span");
|
|
846
|
+
order.className = "attachment-order-context";
|
|
847
|
+
order.textContent = `Order ${index + 1} of ${model.images.length}`;
|
|
848
|
+
details.append(order);
|
|
849
|
+
}
|
|
850
|
+
const actions = document.createElement("div");
|
|
851
|
+
actions.className = "image-actions";
|
|
449
852
|
const remove = document.createElement("button");
|
|
450
853
|
remove.type = "button";
|
|
451
854
|
remove.className = "remove-image";
|
|
452
|
-
remove.
|
|
453
|
-
remove.disabled = model.pending;
|
|
855
|
+
remove.disabled = locked;
|
|
454
856
|
remove.setAttribute("aria-label", `Remove image ${image.name}`);
|
|
455
|
-
remove.
|
|
456
|
-
|
|
857
|
+
remove.title = `Remove ${image.name}`;
|
|
858
|
+
remove.append(createTrashIcon());
|
|
859
|
+
remove.addEventListener("click", () => void removeImage(image.id));
|
|
860
|
+
if (retryable) {
|
|
861
|
+
const retry = document.createElement("button");
|
|
862
|
+
retry.type = "button";
|
|
863
|
+
retry.className = "retry-image";
|
|
864
|
+
retry.textContent = "Retry";
|
|
865
|
+
retry.disabled = locked;
|
|
866
|
+
retry.setAttribute("aria-label", `Retry image ${image.name}`);
|
|
867
|
+
retry.addEventListener("click", () => void retryImage(image.id));
|
|
868
|
+
actions.append(retry);
|
|
869
|
+
}
|
|
870
|
+
actions.append(remove);
|
|
871
|
+
item.append(previewButton, details, actions);
|
|
457
872
|
ui.previews.append(item);
|
|
458
873
|
}
|
|
459
874
|
document.documentElement.style.setProperty("--composer-height", `${ui.composer.offsetHeight}px`);
|
|
460
875
|
}
|
|
461
876
|
|
|
877
|
+
function createTrashIcon() {
|
|
878
|
+
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
879
|
+
icon.setAttribute("viewBox", "0 0 24 24");
|
|
880
|
+
icon.setAttribute("aria-hidden", "true");
|
|
881
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
882
|
+
path.setAttribute("d", "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 10v6m4-6v6");
|
|
883
|
+
icon.append(path);
|
|
884
|
+
return icon;
|
|
885
|
+
}
|
|
886
|
+
|
|
462
887
|
function renderBlocking() {
|
|
463
888
|
ui.blocking.hidden = !model.closed && !model.stale;
|
|
464
889
|
if (model.closed) {
|
|
@@ -482,8 +907,31 @@ function resizeInput() {
|
|
|
482
907
|
ui.input.style.height = `${Math.min(ui.input.scrollHeight, window.innerHeight * 0.32)}px`;
|
|
483
908
|
}
|
|
484
909
|
|
|
910
|
+
function attachmentPhaseLabel(phase) {
|
|
911
|
+
if (phase === "uploading") return "Uploading";
|
|
912
|
+
if (phase === "processing") return "Processing";
|
|
913
|
+
if (phase === "blocked") return "Needs attention";
|
|
914
|
+
if (phase === "reserved") return "Submitting";
|
|
915
|
+
return "Ready";
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function attachmentItemLabel(image, progress) {
|
|
919
|
+
if (image.status === "uploading" && progress?.total > 0) {
|
|
920
|
+
const percent = Math.min(100, Math.round((progress.loaded / progress.total) * 100));
|
|
921
|
+
return `Uploading · ${percent}%`;
|
|
922
|
+
}
|
|
923
|
+
if (image.status === "uploading") return "Uploading…";
|
|
924
|
+
if (image.status === "processing") return "Processing…";
|
|
925
|
+
if (image.status === "error") return image.error || "Needs attention";
|
|
926
|
+
const dimensions =
|
|
927
|
+
Number.isSafeInteger(image.width) && Number.isSafeInteger(image.height)
|
|
928
|
+
? ` · ${image.width}×${image.height}`
|
|
929
|
+
: "";
|
|
930
|
+
return `Ready${dimensions}`;
|
|
931
|
+
}
|
|
932
|
+
|
|
485
933
|
function composerStatus() {
|
|
486
|
-
if (model.readingImages > 0) return "
|
|
934
|
+
if (model.readingImages > 0) return "Staging images on this Pi session…";
|
|
487
935
|
if (model.pending) return "Submitting message…";
|
|
488
936
|
const notice = deliveryNotice(model);
|
|
489
937
|
if (notice) return notice;
|
|
@@ -512,7 +960,13 @@ function conversationAnnouncement(event) {
|
|
|
512
960
|
}
|
|
513
961
|
|
|
514
962
|
function composerLocked() {
|
|
515
|
-
return
|
|
963
|
+
return (
|
|
964
|
+
model.closed ||
|
|
965
|
+
model.stale ||
|
|
966
|
+
model.pending ||
|
|
967
|
+
mutatingAttachments ||
|
|
968
|
+
model.attachmentPhase === "reserved"
|
|
969
|
+
);
|
|
516
970
|
}
|
|
517
971
|
|
|
518
972
|
function hasDraggedFile(event) {
|
|
@@ -532,6 +986,10 @@ async function responseError(response) {
|
|
|
532
986
|
}
|
|
533
987
|
}
|
|
534
988
|
|
|
989
|
+
function formatMib(bytes) {
|
|
990
|
+
return `${bytes / (1024 * 1024)} MiB`;
|
|
991
|
+
}
|
|
992
|
+
|
|
535
993
|
function errorMessage(error) {
|
|
536
994
|
return error instanceof Error ? error.message : String(error);
|
|
537
995
|
}
|