@narumitw/pi-image-drop 0.20.0 → 0.20.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.
- package/README.md +24 -10
- package/package.json +1 -1
- package/src/batch.ts +214 -2
- package/src/runtime.ts +19 -8
- package/src/server.ts +41 -3
- package/src/settings.ts +24 -8
- package/src/web/app.js +133 -4
- package/src/web/index.html +50 -9
- package/src/web/state.js +45 -0
- package/src/web/styles.css +152 -11
package/README.md
CHANGED
|
@@ -24,15 +24,16 @@ This package targets the latest Pi release and uses its `agent_settled` lifecycl
|
|
|
24
24
|
|
|
25
25
|
## Workflow
|
|
26
26
|
|
|
27
|
-
1. Run `/image-drop` in an interactive Pi session.
|
|
28
|
-
2. Pi prints and displays a clickable one-time `http://127.0.0.1:<port>/...` link. The extension does **not** open a browser.
|
|
27
|
+
1. Run `/image-drop` in an interactive Pi session. You can instead set `startOnSessionStart: true` to start the service with every Pi session.
|
|
28
|
+
2. Pi prints and displays a clickable one-time `http://127.0.0.1:<port>/...` link. The extension does **not** open a browser, including when session startup is enabled.
|
|
29
29
|
3. Open the link. Paste images anywhere, drop files, or select **Choose images**.
|
|
30
30
|
4. Review previews and processing details. Drag to reorder, use the keyboard-accessible arrow buttons, retry failures, delete individual items, or use confirmed **Clear all**.
|
|
31
31
|
5. Write and submit a non-empty message in Pi. The ready images are appended after any attachments already on that message, in browser order.
|
|
32
|
+
6. After Pi records that user message, the sent images move to **Previously sent**. They are not attached to later prompts unless you explicitly choose **Add again**. You can preview, re-add, delete, or clear retained images from the browser page.
|
|
32
33
|
|
|
33
34
|
The `🖼️` widget above Pi's editor reports ready, uploading, error, and queued counts. Uploading or failed items block the whole batch and preserve the Pi editor text. Image-only messages are not supported.
|
|
34
35
|
|
|
35
|
-
Each `/image-drop` invocation rotates the unused one-time link. A browser refresh keeps the current in-memory batch. Opening the authenticated page in another tab gives the new tab the editing lease and makes the old tab stale.
|
|
36
|
+
By default, the loopback service starts lazily when you run `/image-drop`. With `startOnSessionStart: true`, it starts after each Pi session initializes and displays the link in Pi automatically. Each later `/image-drop` invocation reuses the service and rotates the unused one-time link. A browser refresh keeps the current in-memory batch and sent-image history. Opening the authenticated page in another tab gives the new tab the editing lease and makes the old tab stale. Reloading, replacing, forking, or shutting down the Pi session releases both the draft and all retained history.
|
|
36
37
|
|
|
37
38
|
## Supported images
|
|
38
39
|
|
|
@@ -63,21 +64,32 @@ Example:
|
|
|
63
64
|
|
|
64
65
|
```json
|
|
65
66
|
{
|
|
67
|
+
"startOnSessionStart": true,
|
|
66
68
|
"maxImages": 8,
|
|
67
69
|
"maxImageBytes": 10485760,
|
|
68
70
|
"maxBatchBytes": 41943040,
|
|
69
|
-
"maxImagePixels": 50000000
|
|
71
|
+
"maxImagePixels": 50000000,
|
|
72
|
+
"maxRetainedImages": 128,
|
|
73
|
+
"maxRetainedBytes": 536870912
|
|
70
74
|
}
|
|
71
75
|
```
|
|
72
76
|
|
|
73
|
-
| Setting |
|
|
77
|
+
| Setting | Default | Behavior |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `startOnSessionStart` | `false` | Start the loopback service and display a link after each Pi session initializes. This never opens a browser. |
|
|
80
|
+
|
|
81
|
+
| Limit setting | Safe default | Hard ceiling |
|
|
74
82
|
| --- | ---: | ---: |
|
|
75
83
|
| `maxImages` | 8 | 32 |
|
|
76
84
|
| `maxImageBytes` | 10 MiB | 50 MiB |
|
|
77
85
|
| `maxBatchBytes` | 40 MiB | 200 MiB |
|
|
78
86
|
| `maxImagePixels` | 50 megapixels | 100 megapixels |
|
|
87
|
+
| `maxRetainedImages` | 128 | 256 |
|
|
88
|
+
| `maxRetainedBytes` | 512 MiB | 1 GiB |
|
|
89
|
+
|
|
90
|
+
`maxRetainedImages` and `maxRetainedBytes` govern how much sent history can coexist with the current draft, using combined image-count and resident-byte accounting. When either limit is reached, Image Drop removes the oldest sent-history entries first until the new draft fits. It never automatically removes the active or queued draft and does not reject a new image merely because retained history is full; the draft remains independently bounded by the batch limits above.
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
Limit values are positive integer counts/bytes/pixels, and `startOnSessionStart` must be a boolean. `maxImageBytes` cannot exceed `maxBatchBytes`. Unknown fields, malformed JSON, invalid values, symlinks, or values above a hard ceiling cause the **whole file** to be ignored with one warning and safe defaults to be used. Limit values above a safe default but within a hard ceiling produce a memory/provider-limit warning.
|
|
81
93
|
|
|
82
94
|
At upload and submission time, the extension also re-reads Pi's documented global and trusted-project `images.autoResize` and `images.blockImages` settings. `blockImages: true` or a text-only current model blocks processing/submission without discarding the draft.
|
|
83
95
|
|
|
@@ -87,8 +99,9 @@ At upload and submission time, the extension also re-reads Pi's documented globa
|
|
|
87
99
|
- A rotating bootstrap token is exchanged once for an HttpOnly, `SameSite=Strict` session cookie, then removed from the URL.
|
|
88
100
|
- Exact Host, mutation Origin, session-cookie, and active-client checks are enforced. No permissive CORS headers are sent.
|
|
89
101
|
- Pages use a restrictive Content Security Policy plus no-store, no-referrer, MIME-sniffing, and frame-denial headers.
|
|
90
|
-
- Raw request bodies, decoded pixels, source bytes, previews, and provider-ready bytes are bounded and stay in the Pi process memory. The extension creates no image cache
|
|
91
|
-
-
|
|
102
|
+
- Raw request bodies, decoded pixels, source bytes, previews, and provider-ready bytes are bounded and stay in the Pi process memory. The extension creates no image cache, temporary image files, browser storage, or session-file entries.
|
|
103
|
+
- After Pi records the matching user message, only sanitized provider-ready bytes and display metadata move to sent history; original uploaded source bytes are released. History remains available for preview and explicit re-adding until you delete it, FIFO retention limits remove it, or the Pi session reaches reload, replacement/fork, or shutdown.
|
|
104
|
+
- Once Pi records a message, normal Pi/provider retention rules apply independently of deleting Image Drop's in-process history.
|
|
92
105
|
|
|
93
106
|
A loopback page is local to your operating-system network namespace. Do not expose the port to a LAN or public interface.
|
|
94
107
|
|
|
@@ -110,14 +123,15 @@ Then open the unchanged `http://127.0.0.1:45678/...` link locally. Image Drop do
|
|
|
110
123
|
- A non-empty interactive Pi message is required. RPC, extension-generated, slash-command, and image-only inputs do not consume the batch.
|
|
111
124
|
- All items must be ready. One uploading or failed item blocks submission until it is retried or deleted.
|
|
112
125
|
- Provider aggregate request limits vary. Raising the defaults to the hard ceilings does not guarantee that a provider accepts the final multi-image request.
|
|
113
|
-
-
|
|
126
|
+
- Sent history exists only for the current live Pi session. It is not reconstructed from the transcript, persisted across sessions, or shared with another Pi process.
|
|
127
|
+
- FIFO retention can remove the oldest sent images automatically at the configured count or memory limit; the browser displays the current usage and limit.
|
|
114
128
|
|
|
115
129
|
## Package layout
|
|
116
130
|
|
|
117
131
|
```text
|
|
118
132
|
src/image-drop.ts Pi extension entrypoint
|
|
119
133
|
src/runtime.ts Pi lifecycle and message orchestration
|
|
120
|
-
src/batch.ts in-memory
|
|
134
|
+
src/batch.ts in-memory draft and sent-history state machine
|
|
121
135
|
src/images.ts bounded image processing
|
|
122
136
|
src/server.ts authenticated loopback HTTP/SSE server
|
|
123
137
|
src/settings.ts extension settings
|
package/package.json
CHANGED
package/src/batch.ts
CHANGED
|
@@ -30,9 +30,17 @@ interface BatchItem extends ItemReservation {
|
|
|
30
30
|
source?: Buffer;
|
|
31
31
|
processed?: ProcessedImage;
|
|
32
32
|
processedAutoResize?: boolean;
|
|
33
|
+
processingOwner?: "browser" | "runtime";
|
|
33
34
|
error?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
interface HistoryItem {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
processed: ProcessedImage;
|
|
41
|
+
processedAutoResize?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
export interface PublicBatchItem extends ItemReservation {
|
|
37
45
|
status: ItemStatus;
|
|
38
46
|
error?: string;
|
|
@@ -54,6 +62,39 @@ export interface PublicBatchState {
|
|
|
54
62
|
totalSourceBytes: number;
|
|
55
63
|
}
|
|
56
64
|
|
|
65
|
+
export interface PublicHistoryItem {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
size: number;
|
|
69
|
+
mimeType: string;
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
originalWidth: number;
|
|
73
|
+
originalHeight: number;
|
|
74
|
+
sourceFormat: string;
|
|
75
|
+
outputFormat: string;
|
|
76
|
+
resized: boolean;
|
|
77
|
+
notes: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PublicHistoryState {
|
|
81
|
+
revision: number;
|
|
82
|
+
items: PublicHistoryItem[];
|
|
83
|
+
totalBytes: number;
|
|
84
|
+
maxImages: number;
|
|
85
|
+
maxBytes: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface HistoryRestageInput {
|
|
89
|
+
historyId: string;
|
|
90
|
+
id: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface HistoryRestageResult {
|
|
94
|
+
addedIds: string[];
|
|
95
|
+
duplicates: Array<{ historyId: string; existingId: string }>;
|
|
96
|
+
}
|
|
97
|
+
|
|
57
98
|
export interface MessageReservation {
|
|
58
99
|
id: string;
|
|
59
100
|
text: string;
|
|
@@ -76,6 +117,7 @@ export class BatchError extends Error {
|
|
|
76
117
|
|
|
77
118
|
export class BatchStore {
|
|
78
119
|
private items: BatchItem[] = [];
|
|
120
|
+
private history: HistoryItem[] = [];
|
|
79
121
|
private revision = 0;
|
|
80
122
|
private reservation?: MessageReservation;
|
|
81
123
|
private closed = false;
|
|
@@ -106,6 +148,29 @@ export class BatchStore {
|
|
|
106
148
|
};
|
|
107
149
|
}
|
|
108
150
|
|
|
151
|
+
publicHistoryState(): PublicHistoryState {
|
|
152
|
+
return {
|
|
153
|
+
revision: this.revision,
|
|
154
|
+
items: this.history.map((item) => ({
|
|
155
|
+
id: item.id,
|
|
156
|
+
name: item.name,
|
|
157
|
+
size: item.processed.bytes.byteLength,
|
|
158
|
+
mimeType: item.processed.mimeType,
|
|
159
|
+
width: item.processed.width,
|
|
160
|
+
height: item.processed.height,
|
|
161
|
+
originalWidth: item.processed.originalWidth,
|
|
162
|
+
originalHeight: item.processed.originalHeight,
|
|
163
|
+
sourceFormat: item.processed.sourceFormat,
|
|
164
|
+
outputFormat: item.processed.outputFormat,
|
|
165
|
+
resized: item.processed.resized,
|
|
166
|
+
notes: [...item.processed.notes],
|
|
167
|
+
})),
|
|
168
|
+
totalBytes: this.historyBytes(),
|
|
169
|
+
maxImages: this.settings.maxRetainedImages,
|
|
170
|
+
maxBytes: this.settings.maxRetainedBytes,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
109
174
|
reserveItems(inputs: readonly ItemReservation[], expectedRevision = this.revision): number {
|
|
110
175
|
this.assertMutable(expectedRevision);
|
|
111
176
|
if (inputs.length === 0) throw new BatchError("No images supplied", "invalid");
|
|
@@ -135,6 +200,7 @@ export class BatchStore {
|
|
|
135
200
|
throw new BatchError("Batch exceeds the byte limit", "limit");
|
|
136
201
|
}
|
|
137
202
|
this.items.push(...inputs.map((input) => ({ ...input, status: "uploading" as const })));
|
|
203
|
+
this.evictHistoryToBudget();
|
|
138
204
|
return this.bump();
|
|
139
205
|
}
|
|
140
206
|
|
|
@@ -153,6 +219,7 @@ export class BatchStore {
|
|
|
153
219
|
item.processedAutoResize = undefined;
|
|
154
220
|
item.error = undefined;
|
|
155
221
|
item.status = "processing";
|
|
222
|
+
item.processingOwner = "browser";
|
|
156
223
|
return this.bump();
|
|
157
224
|
}
|
|
158
225
|
|
|
@@ -170,19 +237,23 @@ export class BatchStore {
|
|
|
170
237
|
const earlier = duplicates.find(({ index }) => index < itemIndex);
|
|
171
238
|
if (earlier) {
|
|
172
239
|
this.items = this.items.filter((candidate) => candidate.id !== id);
|
|
240
|
+
this.evictHistoryToBudget();
|
|
173
241
|
this.bump();
|
|
174
242
|
return { kind: "duplicate", existingId: earlier.candidate.id };
|
|
175
243
|
}
|
|
176
244
|
item.processed = cloneProcessed(processed);
|
|
177
245
|
item.processedAutoResize = autoResize;
|
|
178
246
|
item.status = "ready";
|
|
247
|
+
item.processingOwner = undefined;
|
|
179
248
|
item.error = undefined;
|
|
180
249
|
if (duplicates.length > 0) {
|
|
181
250
|
const laterIds = new Set(duplicates.map(({ candidate }) => candidate.id));
|
|
182
251
|
this.items = this.items.filter((candidate) => !laterIds.has(candidate.id));
|
|
252
|
+
this.evictHistoryToBudget();
|
|
183
253
|
this.bump();
|
|
184
254
|
return { kind: "duplicate", existingId: item.id };
|
|
185
255
|
}
|
|
256
|
+
this.evictHistoryToBudget();
|
|
186
257
|
this.bump();
|
|
187
258
|
return { kind: "ready" };
|
|
188
259
|
}
|
|
@@ -195,6 +266,7 @@ export class BatchStore {
|
|
|
195
266
|
item.error = sanitizeError(error);
|
|
196
267
|
item.processed = undefined;
|
|
197
268
|
item.processedAutoResize = undefined;
|
|
269
|
+
item.processingOwner = undefined;
|
|
198
270
|
return this.bump();
|
|
199
271
|
}
|
|
200
272
|
|
|
@@ -214,7 +286,9 @@ export class BatchStore {
|
|
|
214
286
|
this.assertOpen();
|
|
215
287
|
if (this.reservation) return false;
|
|
216
288
|
const inFlight = this.items.filter(
|
|
217
|
-
(item) =>
|
|
289
|
+
(item) =>
|
|
290
|
+
item.status === "uploading" ||
|
|
291
|
+
(item.status === "processing" && item.processingOwner === "browser"),
|
|
218
292
|
);
|
|
219
293
|
if (inFlight.length === 0) return false;
|
|
220
294
|
for (const item of inFlight) {
|
|
@@ -222,6 +296,7 @@ export class BatchStore {
|
|
|
222
296
|
item.error = sanitizeError(error);
|
|
223
297
|
item.processed = undefined;
|
|
224
298
|
item.processedAutoResize = undefined;
|
|
299
|
+
item.processingOwner = undefined;
|
|
225
300
|
}
|
|
226
301
|
this.bump();
|
|
227
302
|
return true;
|
|
@@ -245,6 +320,7 @@ export class BatchStore {
|
|
|
245
320
|
item.status = "processing";
|
|
246
321
|
item.processed = undefined;
|
|
247
322
|
item.processedAutoResize = undefined;
|
|
323
|
+
item.processingOwner = "runtime";
|
|
248
324
|
item.error = undefined;
|
|
249
325
|
}
|
|
250
326
|
this.bump();
|
|
@@ -259,6 +335,7 @@ export class BatchStore {
|
|
|
259
335
|
throw new BatchError("Image cannot be retried without uploaded source bytes", "not-ready");
|
|
260
336
|
}
|
|
261
337
|
item.status = "processing";
|
|
338
|
+
item.processingOwner = "browser";
|
|
262
339
|
item.error = undefined;
|
|
263
340
|
item.processedAutoResize = undefined;
|
|
264
341
|
this.bump();
|
|
@@ -314,6 +391,7 @@ export class BatchStore {
|
|
|
314
391
|
preflightStarted: false,
|
|
315
392
|
};
|
|
316
393
|
this.reservation = reservation;
|
|
394
|
+
this.evictHistoryToBudget();
|
|
317
395
|
this.bump();
|
|
318
396
|
return cloneReservation(reservation);
|
|
319
397
|
}
|
|
@@ -333,10 +411,101 @@ export class BatchStore {
|
|
|
333
411
|
return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
|
|
334
412
|
}
|
|
335
413
|
|
|
414
|
+
historyPreview(id: string): { bytes: Buffer; mimeType: string } {
|
|
415
|
+
this.assertOpen();
|
|
416
|
+
const processed = this.historyItem(id).processed;
|
|
417
|
+
return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
restageHistory(
|
|
421
|
+
inputs: readonly HistoryRestageInput[],
|
|
422
|
+
expectedRevision = this.revision,
|
|
423
|
+
): HistoryRestageResult {
|
|
424
|
+
this.assertMutable(expectedRevision);
|
|
425
|
+
if (inputs.length === 0) throw new BatchError("No history images supplied", "invalid");
|
|
426
|
+
if (new Set(inputs.map((input) => input.historyId)).size !== inputs.length) {
|
|
427
|
+
throw new BatchError("History selection contains duplicates", "invalid");
|
|
428
|
+
}
|
|
429
|
+
const draftIds = new Set(this.items.map((item) => item.id));
|
|
430
|
+
const draftHashes = new Map(
|
|
431
|
+
this.items.flatMap((item) =>
|
|
432
|
+
item.processed ? [[item.processed.hash, item.id] as const] : [],
|
|
433
|
+
),
|
|
434
|
+
);
|
|
435
|
+
const additions: BatchItem[] = [];
|
|
436
|
+
const duplicates: HistoryRestageResult["duplicates"] = [];
|
|
437
|
+
for (const input of inputs) {
|
|
438
|
+
if (!isSafeIdentifier(input.id) || draftIds.has(input.id)) {
|
|
439
|
+
throw new BatchError("Image id is invalid or duplicated", "invalid");
|
|
440
|
+
}
|
|
441
|
+
draftIds.add(input.id);
|
|
442
|
+
const history = this.historyItem(input.historyId);
|
|
443
|
+
const existingId = draftHashes.get(history.processed.hash);
|
|
444
|
+
if (existingId) {
|
|
445
|
+
duplicates.push({ historyId: input.historyId, existingId });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const processed = cloneProcessed(history.processed);
|
|
449
|
+
additions.push({
|
|
450
|
+
id: input.id,
|
|
451
|
+
name: history.name,
|
|
452
|
+
size: processed.bytes.byteLength,
|
|
453
|
+
status: "ready",
|
|
454
|
+
source: Buffer.from(processed.bytes),
|
|
455
|
+
processed,
|
|
456
|
+
processedAutoResize: history.processedAutoResize,
|
|
457
|
+
});
|
|
458
|
+
draftHashes.set(processed.hash, input.id);
|
|
459
|
+
}
|
|
460
|
+
if (this.items.length + additions.length > this.settings.maxImages) {
|
|
461
|
+
throw new BatchError("Batch exceeds the image-count limit", "limit");
|
|
462
|
+
}
|
|
463
|
+
const currentBytes = this.items.reduce((sum, item) => sum + item.size, 0);
|
|
464
|
+
const addedBytes = additions.reduce((sum, item) => sum + item.size, 0);
|
|
465
|
+
if (currentBytes + addedBytes > this.settings.maxBatchBytes) {
|
|
466
|
+
throw new BatchError("Batch exceeds the byte limit", "limit");
|
|
467
|
+
}
|
|
468
|
+
if (additions.length > 0) {
|
|
469
|
+
this.items.push(...additions);
|
|
470
|
+
this.evictHistoryToBudget();
|
|
471
|
+
this.bump();
|
|
472
|
+
}
|
|
473
|
+
return { addedIds: additions.map((item) => item.id), duplicates };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
deleteHistory(id: string, expectedRevision = this.revision): number {
|
|
477
|
+
this.assertHistoryMutation(expectedRevision);
|
|
478
|
+
const before = this.history.length;
|
|
479
|
+
this.history = this.history.filter((item) => item.id !== id);
|
|
480
|
+
if (this.history.length === before)
|
|
481
|
+
throw new BatchError("History image not found", "not-found");
|
|
482
|
+
return this.bump();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
clearHistory(expectedRevision = this.revision): number {
|
|
486
|
+
this.assertHistoryMutation(expectedRevision);
|
|
487
|
+
if (this.history.length === 0) return this.revision;
|
|
488
|
+
this.history = [];
|
|
489
|
+
return this.bump();
|
|
490
|
+
}
|
|
491
|
+
|
|
336
492
|
commitReservation(digest: string): boolean {
|
|
337
493
|
if (!this.reservation || this.reservation.digest !== digest) return false;
|
|
494
|
+
for (const [index, item] of this.items.entries()) {
|
|
495
|
+
if (!item.processed) continue;
|
|
496
|
+
this.history = this.history.filter(
|
|
497
|
+
(history) => history.processed.hash !== item.processed?.hash,
|
|
498
|
+
);
|
|
499
|
+
this.history.push({
|
|
500
|
+
id: historyId(this.revision, index, item.processed.hash),
|
|
501
|
+
name: item.name,
|
|
502
|
+
processed: cloneProcessed(item.processed),
|
|
503
|
+
processedAutoResize: item.processedAutoResize,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
338
506
|
this.reservation = undefined;
|
|
339
507
|
this.items = [];
|
|
508
|
+
this.evictHistoryToBudget();
|
|
340
509
|
this.bump();
|
|
341
510
|
return true;
|
|
342
511
|
}
|
|
@@ -354,6 +523,7 @@ export class BatchStore {
|
|
|
354
523
|
this.closed = true;
|
|
355
524
|
this.reservation = undefined;
|
|
356
525
|
this.items = [];
|
|
526
|
+
this.history = [];
|
|
357
527
|
this.bump();
|
|
358
528
|
}
|
|
359
529
|
|
|
@@ -371,8 +541,12 @@ export class BatchStore {
|
|
|
371
541
|
}
|
|
372
542
|
|
|
373
543
|
private assertMutable(expectedRevision: number): void {
|
|
374
|
-
this.
|
|
544
|
+
this.assertHistoryMutation(expectedRevision);
|
|
375
545
|
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private assertHistoryMutation(expectedRevision: number): void {
|
|
549
|
+
this.assertOpen();
|
|
376
550
|
if (expectedRevision !== this.revision)
|
|
377
551
|
throw new BatchError("Batch revision is stale", "stale");
|
|
378
552
|
}
|
|
@@ -383,6 +557,37 @@ export class BatchStore {
|
|
|
383
557
|
return item;
|
|
384
558
|
}
|
|
385
559
|
|
|
560
|
+
private historyItem(id: string): HistoryItem {
|
|
561
|
+
const item = this.history.find((candidate) => candidate.id === id);
|
|
562
|
+
if (!item) throw new BatchError("History image not found", "not-found");
|
|
563
|
+
return item;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
private historyBytes(): number {
|
|
567
|
+
return this.history.reduce((sum, item) => sum + item.processed.bytes.byteLength, 0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private draftResidentBytes(): number {
|
|
571
|
+
const itemBytes = this.items.reduce(
|
|
572
|
+
(sum, item) =>
|
|
573
|
+
sum + (item.source?.byteLength ?? item.size) + (item.processed?.bytes.byteLength ?? 0),
|
|
574
|
+
0,
|
|
575
|
+
);
|
|
576
|
+
const queuedBase64Bytes =
|
|
577
|
+
this.reservation?.images.reduce((sum, image) => sum + Buffer.byteLength(image.data), 0) ?? 0;
|
|
578
|
+
return itemBytes + queuedBase64Bytes;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private evictHistoryToBudget(): void {
|
|
582
|
+
while (
|
|
583
|
+
this.history.length > 0 &&
|
|
584
|
+
(this.history.length + this.items.length > this.settings.maxRetainedImages ||
|
|
585
|
+
this.historyBytes() + this.draftResidentBytes() > this.settings.maxRetainedBytes)
|
|
586
|
+
) {
|
|
587
|
+
this.history.shift();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
386
591
|
private bump(): number {
|
|
387
592
|
this.revision += 1;
|
|
388
593
|
return this.revision;
|
|
@@ -430,6 +635,13 @@ function isSafeIdentifier(id: string): boolean {
|
|
|
430
635
|
return /^[A-Za-z0-9_-]{1,80}$/.test(id);
|
|
431
636
|
}
|
|
432
637
|
|
|
638
|
+
function historyId(revision: number, index: number, hash: string): string {
|
|
639
|
+
return createHash("sha256")
|
|
640
|
+
.update(`history\0${revision}\0${index}\0${hash}`)
|
|
641
|
+
.digest("hex")
|
|
642
|
+
.slice(0, 24);
|
|
643
|
+
}
|
|
644
|
+
|
|
433
645
|
function cryptoId(revision: number, images: readonly ImageContent[]): string {
|
|
434
646
|
return createHash("sha256")
|
|
435
647
|
.update(`${revision}\0${Date.now()}\0${digestImages(images)}`)
|
package/src/runtime.ts
CHANGED
|
@@ -63,14 +63,7 @@ export class ImageDropRuntime {
|
|
|
63
63
|
this.context = ctx;
|
|
64
64
|
await this.recoverOrphanedReservation(ctx);
|
|
65
65
|
try {
|
|
66
|
-
|
|
67
|
-
const link = server.issueLink();
|
|
68
|
-
if (this.batch?.publicState().phase === "empty") {
|
|
69
|
-
ctx.ui.setWidget(WIDGET_KEY, [`🖼️ Image Drop: ${link}`]);
|
|
70
|
-
} else {
|
|
71
|
-
this.updateWidget(ctx);
|
|
72
|
-
}
|
|
73
|
-
ctx.ui.notify(`Image Drop: ${link}`, "info");
|
|
66
|
+
await this.presentLink(ctx);
|
|
74
67
|
} catch (error) {
|
|
75
68
|
ctx.ui.notify(`Image Drop could not start: ${formatError(error)}`, "error");
|
|
76
69
|
}
|
|
@@ -110,6 +103,13 @@ export class ImageDropRuntime {
|
|
|
110
103
|
ctx.ui.notify(warning ?? "Image Drop settings ignored.", "warning");
|
|
111
104
|
}
|
|
112
105
|
this.updateWidget(ctx);
|
|
106
|
+
if (!result.settings.startOnSessionStart) return;
|
|
107
|
+
try {
|
|
108
|
+
await this.presentLink(ctx);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (generation !== this.generation || this.closed) return;
|
|
111
|
+
ctx.ui.notify(`Image Drop could not start: ${formatError(error)}`, "error");
|
|
112
|
+
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
@@ -259,6 +259,17 @@ export class ImageDropRuntime {
|
|
|
259
259
|
return true;
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
private async presentLink(ctx: ExtensionContext): Promise<void> {
|
|
263
|
+
const server = await this.ensureServer(ctx);
|
|
264
|
+
const link = server.issueLink();
|
|
265
|
+
if (this.batch?.publicState().phase === "empty") {
|
|
266
|
+
ctx.ui.setWidget(WIDGET_KEY, [`🖼️ Image Drop: ${link}`]);
|
|
267
|
+
} else {
|
|
268
|
+
this.updateWidget(ctx);
|
|
269
|
+
}
|
|
270
|
+
ctx.ui.notify(`Image Drop: ${link}`, "info");
|
|
271
|
+
}
|
|
272
|
+
|
|
262
273
|
private async ensureServer(ctx: ExtensionContext): Promise<ServerControl> {
|
|
263
274
|
if (this.closed || !this.batch || !this.settings || !this.processor) {
|
|
264
275
|
throw new Error("the Pi session is not ready");
|
package/src/server.ts
CHANGED
|
@@ -176,9 +176,15 @@ export class ImageDropServer {
|
|
|
176
176
|
const previewMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/preview$/.exec(url.pathname);
|
|
177
177
|
if (request.method === "GET" && previewMatch) {
|
|
178
178
|
const preview = this.options.batch.preview(previewMatch[1] as string);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
179
|
+
this.image(response, preview);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const historyPreviewMatch = /^\/api\/history\/([A-Za-z0-9_-]{1,80})\/preview$/.exec(
|
|
183
|
+
url.pathname,
|
|
184
|
+
);
|
|
185
|
+
if (request.method === "GET" && historyPreviewMatch) {
|
|
186
|
+
const preview = this.options.batch.historyPreview(historyPreviewMatch[1] as string);
|
|
187
|
+
this.image(response, preview);
|
|
182
188
|
return;
|
|
183
189
|
}
|
|
184
190
|
if (request.method === "POST" && url.pathname === "/api/lease") {
|
|
@@ -195,6 +201,31 @@ export class ImageDropServer {
|
|
|
195
201
|
}
|
|
196
202
|
this.assertMutation(request);
|
|
197
203
|
const lease = this.assertActiveClient(request);
|
|
204
|
+
if (request.method === "POST" && url.pathname === "/api/history/restage") {
|
|
205
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
206
|
+
this.assertLease(lease);
|
|
207
|
+
const items = arrayField(body, "items").map((item) => {
|
|
208
|
+
if (!isRecord(item)) throw new HttpError(400, "Invalid history restage item");
|
|
209
|
+
return { historyId: stringField(item, "historyId"), id: stringField(item, "id") };
|
|
210
|
+
});
|
|
211
|
+
const restage = this.options.batch.restageHistory(items, integerField(body, "revision"));
|
|
212
|
+
this.broadcastState();
|
|
213
|
+
this.json(response, 200, { ...this.statePayload(), restage });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const historyMatch = /^\/api\/history\/([A-Za-z0-9_-]{1,80})$/.exec(url.pathname);
|
|
217
|
+
if (request.method === "DELETE" && historyMatch) {
|
|
218
|
+
this.options.batch.deleteHistory(historyMatch[1] as string, queryInteger(url, "revision"));
|
|
219
|
+
this.respondWithState(response);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (request.method === "POST" && url.pathname === "/api/history/clear") {
|
|
223
|
+
const body = await readJson(request, JSON_LIMIT);
|
|
224
|
+
this.assertLease(lease);
|
|
225
|
+
this.options.batch.clearHistory(integerField(body, "revision"));
|
|
226
|
+
this.respondWithState(response);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
198
229
|
if (request.method === "POST" && url.pathname === "/api/items") {
|
|
199
230
|
const body = await readJson(request, JSON_LIMIT);
|
|
200
231
|
this.assertLease(lease);
|
|
@@ -406,9 +437,16 @@ export class ImageDropServer {
|
|
|
406
437
|
cwd: this.options.cwd,
|
|
407
438
|
activeClientId: this.activeClientId,
|
|
408
439
|
batch: this.options.batch.publicState(),
|
|
440
|
+
history: this.options.batch.publicHistoryState(),
|
|
409
441
|
};
|
|
410
442
|
}
|
|
411
443
|
|
|
444
|
+
private image(response: ServerResponse, image: { bytes: Buffer; mimeType: string }): void {
|
|
445
|
+
response.setHeader("Content-Type", image.mimeType);
|
|
446
|
+
response.setHeader("Content-Length", image.bytes.byteLength);
|
|
447
|
+
response.writeHead(200).end(image.bytes);
|
|
448
|
+
}
|
|
449
|
+
|
|
412
450
|
private respondWithState(response: ServerResponse): void {
|
|
413
451
|
this.broadcastState();
|
|
414
452
|
this.json(response, 200, this.statePayload());
|
package/src/settings.ts
CHANGED
|
@@ -5,11 +5,17 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
export const SETTINGS_FILE = "pi-image-drop.json";
|
|
6
6
|
const MIB = 1024 * 1024;
|
|
7
7
|
|
|
8
|
-
export interface
|
|
8
|
+
export interface ImageDropLimits {
|
|
9
9
|
maxImages: number;
|
|
10
10
|
maxImageBytes: number;
|
|
11
11
|
maxBatchBytes: number;
|
|
12
12
|
maxImagePixels: number;
|
|
13
|
+
maxRetainedImages: number;
|
|
14
|
+
maxRetainedBytes: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ImageDropSettings extends ImageDropLimits {
|
|
18
|
+
startOnSessionStart: boolean;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export const DEFAULT_SETTINGS: Readonly<ImageDropSettings> = Object.freeze({
|
|
@@ -17,21 +23,29 @@ export const DEFAULT_SETTINGS: Readonly<ImageDropSettings> = Object.freeze({
|
|
|
17
23
|
maxImageBytes: 10 * MIB,
|
|
18
24
|
maxBatchBytes: 40 * MIB,
|
|
19
25
|
maxImagePixels: 50_000_000,
|
|
26
|
+
maxRetainedImages: 128,
|
|
27
|
+
maxRetainedBytes: 512 * MIB,
|
|
28
|
+
startOnSessionStart: false,
|
|
20
29
|
});
|
|
21
30
|
|
|
22
|
-
export const HARD_LIMITS: Readonly<
|
|
31
|
+
export const HARD_LIMITS: Readonly<ImageDropLimits> = Object.freeze({
|
|
23
32
|
maxImages: 32,
|
|
24
33
|
maxImageBytes: 50 * MIB,
|
|
25
34
|
maxBatchBytes: 200 * MIB,
|
|
26
35
|
maxImagePixels: 100_000_000,
|
|
36
|
+
maxRetainedImages: 256,
|
|
37
|
+
maxRetainedBytes: 1024 * MIB,
|
|
27
38
|
});
|
|
28
39
|
|
|
29
|
-
const
|
|
40
|
+
const LIMIT_KEYS = new Set<keyof ImageDropLimits>([
|
|
30
41
|
"maxImages",
|
|
31
42
|
"maxImageBytes",
|
|
32
43
|
"maxBatchBytes",
|
|
33
44
|
"maxImagePixels",
|
|
45
|
+
"maxRetainedImages",
|
|
46
|
+
"maxRetainedBytes",
|
|
34
47
|
]);
|
|
48
|
+
const SETTING_KEYS = new Set<keyof ImageDropSettings>([...LIMIT_KEYS, "startOnSessionStart"]);
|
|
35
49
|
|
|
36
50
|
export type SettingsLoadResult =
|
|
37
51
|
| { kind: "missing"; settings: ImageDropSettings }
|
|
@@ -43,7 +57,7 @@ export function normalizeSettings(value: unknown): ImageDropSettings | undefined
|
|
|
43
57
|
return undefined;
|
|
44
58
|
}
|
|
45
59
|
const settings: ImageDropSettings = { ...DEFAULT_SETTINGS };
|
|
46
|
-
for (const key of
|
|
60
|
+
for (const key of LIMIT_KEYS) {
|
|
47
61
|
if (!Object.hasOwn(value, key)) continue;
|
|
48
62
|
const candidate = Reflect.get(value, key);
|
|
49
63
|
if (
|
|
@@ -56,6 +70,10 @@ export function normalizeSettings(value: unknown): ImageDropSettings | undefined
|
|
|
56
70
|
}
|
|
57
71
|
settings[key] = candidate;
|
|
58
72
|
}
|
|
73
|
+
if (Object.hasOwn(value, "startOnSessionStart")) {
|
|
74
|
+
if (typeof value.startOnSessionStart !== "boolean") return undefined;
|
|
75
|
+
settings.startOnSessionStart = value.startOnSessionStart;
|
|
76
|
+
}
|
|
59
77
|
if (settings.maxImageBytes > settings.maxBatchBytes) return undefined;
|
|
60
78
|
return settings;
|
|
61
79
|
}
|
|
@@ -79,15 +97,13 @@ export async function loadSettings(
|
|
|
79
97
|
try {
|
|
80
98
|
const settings = normalizeSettings(JSON.parse(text) as unknown);
|
|
81
99
|
if (!settings) return invalid(path, "invalid settings shape or value");
|
|
82
|
-
const raised =
|
|
83
|
-
(key) => settings[key] > DEFAULT_SETTINGS[key],
|
|
84
|
-
);
|
|
100
|
+
const raised = [...LIMIT_KEYS].filter((key) => settings[key] > DEFAULT_SETTINGS[key]);
|
|
85
101
|
return {
|
|
86
102
|
kind: "loaded",
|
|
87
103
|
settings,
|
|
88
104
|
warning:
|
|
89
105
|
raised.length > 0
|
|
90
|
-
? `${SETTINGS_FILE} raises ${raised.join(", ")} above the safe defaults;
|
|
106
|
+
? `${SETTINGS_FILE} raises ${raised.join(", ")} above the safe defaults; memory use or provider request size may increase.`
|
|
91
107
|
: undefined,
|
|
92
108
|
};
|
|
93
109
|
} catch (error) {
|
package/src/web/app.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
2
|
attemptMutation,
|
|
3
3
|
canMutate,
|
|
4
|
+
draftPresentation,
|
|
4
5
|
formatBytes,
|
|
5
6
|
moveItem,
|
|
6
7
|
moveItemBefore,
|
|
7
8
|
preferNewestState,
|
|
8
9
|
summarizeBatch,
|
|
10
|
+
summarizeHistory,
|
|
11
|
+
visibleItemNotes,
|
|
9
12
|
} from "/state.js";
|
|
10
13
|
|
|
11
14
|
const $ = (selector) => document.querySelector(selector);
|
|
@@ -16,13 +19,23 @@ const ui = {
|
|
|
16
19
|
choose: $("#choose-files"),
|
|
17
20
|
input: $("#file-input"),
|
|
18
21
|
status: $("#status"),
|
|
22
|
+
nextStep: $("#next-step"),
|
|
19
23
|
clear: $("#clear-all"),
|
|
20
24
|
error: $("#error-banner"),
|
|
21
25
|
grid: $("#grid"),
|
|
26
|
+
historyStatus: $("#history-status"),
|
|
27
|
+
historyRetention: $("#history-retention"),
|
|
28
|
+
historyClear: $("#clear-history"),
|
|
29
|
+
historyGrid: $("#history-grid"),
|
|
22
30
|
overlay: $("#connection-overlay"),
|
|
23
31
|
connectionTitle: $("#connection-title"),
|
|
24
32
|
connectionMessage: $("#connection-message"),
|
|
25
33
|
dialog: $("#clear-dialog"),
|
|
34
|
+
historyDialog: $("#clear-history-dialog"),
|
|
35
|
+
previewDialog: $("#image-preview-dialog"),
|
|
36
|
+
previewTitle: $("#image-preview-title"),
|
|
37
|
+
previewDismiss: $("#image-preview-dismiss"),
|
|
38
|
+
previewImage: $("#image-preview"),
|
|
26
39
|
};
|
|
27
40
|
const clientId = crypto.randomUUID();
|
|
28
41
|
const pendingFiles = new Map();
|
|
@@ -79,6 +92,20 @@ function wire() {
|
|
|
79
92
|
ui.dialog.addEventListener("close", () => {
|
|
80
93
|
if (ui.dialog.returnValue === "confirm") void clearAll();
|
|
81
94
|
});
|
|
95
|
+
ui.historyClear.addEventListener("click", () => {
|
|
96
|
+
ui.historyDialog.returnValue = "cancel";
|
|
97
|
+
ui.historyDialog.showModal();
|
|
98
|
+
});
|
|
99
|
+
ui.historyDialog.addEventListener("close", () => {
|
|
100
|
+
if (ui.historyDialog.returnValue === "confirm") void clearHistory();
|
|
101
|
+
});
|
|
102
|
+
ui.previewDismiss.addEventListener("click", closePreview);
|
|
103
|
+
ui.previewDialog.addEventListener("click", (event) => {
|
|
104
|
+
if (event.target === ui.previewDialog) closePreview();
|
|
105
|
+
});
|
|
106
|
+
ui.previewDialog.addEventListener("close", () => {
|
|
107
|
+
ui.previewImage.removeAttribute("src");
|
|
108
|
+
});
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
function connectEvents() {
|
|
@@ -215,6 +242,36 @@ async function clearAll() {
|
|
|
215
242
|
}
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
async function restageHistory(historyId) {
|
|
246
|
+
try {
|
|
247
|
+
const response = await request("/api/history/restage", {
|
|
248
|
+
method: "POST",
|
|
249
|
+
json: {
|
|
250
|
+
revision: state.batch.revision,
|
|
251
|
+
items: [{ historyId, id: crypto.randomUUID() }],
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
applyState(response);
|
|
255
|
+
clearError();
|
|
256
|
+
render();
|
|
257
|
+
const target = response.restage.addedIds[0] ?? response.restage.duplicates[0]?.existingId;
|
|
258
|
+
if (target) highlight(target);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
showError(errorMessage(error));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function deleteHistory(id) {
|
|
265
|
+
await mutate(`/api/history/${id}?revision=${state.batch.revision}`, { method: "DELETE" });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function clearHistory() {
|
|
269
|
+
await mutate("/api/history/clear", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
json: { revision: state.batch.revision },
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
218
275
|
async function mutate(path, options) {
|
|
219
276
|
const result = await attemptMutation(() => request(path, options));
|
|
220
277
|
if (!result.ok) {
|
|
@@ -235,14 +292,26 @@ function render() {
|
|
|
235
292
|
: state.projectName;
|
|
236
293
|
ui.cwd.textContent = state.cwd;
|
|
237
294
|
const summary = summarizeBatch(state.batch);
|
|
238
|
-
|
|
295
|
+
const presentation = draftPresentation(state.batch);
|
|
296
|
+
ui.status.textContent = presentation.status;
|
|
297
|
+
ui.status.hidden = presentation.status === "";
|
|
298
|
+
ui.nextStep.textContent = presentation.guidance;
|
|
239
299
|
const mutable = canMutate(state.batch);
|
|
300
|
+
ui.clear.hidden = summary.total === 0;
|
|
240
301
|
ui.clear.disabled = !mutable || summary.total === 0;
|
|
241
302
|
ui.choose.disabled = !mutable;
|
|
242
303
|
ui.input.disabled = !mutable;
|
|
243
304
|
ui.drop.classList.toggle("disabled", !mutable);
|
|
244
305
|
ui.drop.setAttribute("aria-disabled", String(!mutable));
|
|
245
306
|
ui.grid.replaceChildren(...state.batch.items.map((item, index) => card(item, index, mutable)));
|
|
307
|
+
const history = summarizeHistory(state.history);
|
|
308
|
+
ui.historyStatus.textContent = history.label;
|
|
309
|
+
ui.historyRetention.textContent = history.usage;
|
|
310
|
+
ui.historyClear.hidden = history.total === 0;
|
|
311
|
+
ui.historyClear.disabled = history.total === 0;
|
|
312
|
+
ui.historyGrid.replaceChildren(
|
|
313
|
+
...(state.history?.items ?? []).map((item, index) => historyCard(item, index, mutable)),
|
|
314
|
+
);
|
|
246
315
|
}
|
|
247
316
|
|
|
248
317
|
function card(item, index, mutable) {
|
|
@@ -264,14 +333,19 @@ function card(item, index, mutable) {
|
|
|
264
333
|
draggedId = undefined;
|
|
265
334
|
});
|
|
266
335
|
|
|
267
|
-
|
|
336
|
+
let preview;
|
|
268
337
|
if (item.status === "ready") {
|
|
338
|
+
preview = element("button", "preview preview-button");
|
|
339
|
+
preview.type = "button";
|
|
340
|
+
preview.setAttribute("aria-label", `Enlarge preview of ${item.name}`);
|
|
341
|
+
preview.addEventListener("click", () => openPreview(item, "items"));
|
|
269
342
|
const image = document.createElement("img");
|
|
270
343
|
image.src = `/api/items/${item.id}/preview?revision=${state.batch.revision}`;
|
|
271
344
|
image.alt = `Preview of ${item.name}`;
|
|
272
345
|
image.loading = "lazy";
|
|
273
346
|
preview.append(image);
|
|
274
347
|
} else {
|
|
348
|
+
preview = element("div", "preview");
|
|
275
349
|
const placeholder = element("span", "placeholder", item.status === "error" ? "!" : "…");
|
|
276
350
|
placeholder.setAttribute("aria-hidden", "true");
|
|
277
351
|
preview.append(placeholder);
|
|
@@ -292,7 +366,7 @@ function card(item, index, mutable) {
|
|
|
292
366
|
`${item.sourceFormat.toUpperCase()} → ${item.outputFormat.toUpperCase()}`,
|
|
293
367
|
),
|
|
294
368
|
);
|
|
295
|
-
for (const note of item.notes
|
|
369
|
+
for (const note of visibleItemNotes(item.notes)) body.append(element("p", "note", note));
|
|
296
370
|
if (item.error) body.append(element("p", "item-error", item.error));
|
|
297
371
|
article.append(body);
|
|
298
372
|
|
|
@@ -307,11 +381,66 @@ function card(item, index, mutable) {
|
|
|
307
381
|
);
|
|
308
382
|
if (item.status === "error")
|
|
309
383
|
actions.append(button("Retry", "Retry", !mutable, () => retry(item.id)));
|
|
310
|
-
actions.append(
|
|
384
|
+
actions.append(
|
|
385
|
+
button("Delete", "Delete", !mutable, () => remove(item.id), "danger-secondary"),
|
|
386
|
+
);
|
|
387
|
+
article.append(actions);
|
|
388
|
+
return article;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function historyCard(item, index, mutable) {
|
|
392
|
+
const article = document.createElement("article");
|
|
393
|
+
article.className = "image-card history-card";
|
|
394
|
+
article.tabIndex = 0;
|
|
395
|
+
article.setAttribute("aria-label", `${index + 1}. ${item.name}, sent this session`);
|
|
396
|
+
|
|
397
|
+
const preview = element("button", "preview preview-button");
|
|
398
|
+
preview.type = "button";
|
|
399
|
+
preview.setAttribute("aria-label", `Enlarge preview of ${item.name}`);
|
|
400
|
+
preview.addEventListener("click", () => openPreview(item, "history"));
|
|
401
|
+
const image = document.createElement("img");
|
|
402
|
+
image.src = `/api/history/${item.id}/preview?revision=${state.batch.revision}`;
|
|
403
|
+
image.alt = `Preview of sent image ${item.name}`;
|
|
404
|
+
image.loading = "lazy";
|
|
405
|
+
preview.append(image);
|
|
406
|
+
article.append(preview);
|
|
407
|
+
|
|
408
|
+
const body = element("div", "card-body");
|
|
409
|
+
body.append(element("h3", "", item.name));
|
|
410
|
+
body.append(
|
|
411
|
+
element("p", "meta", `${index + 1} · ${formatBytes(item.size)} · ${item.width}×${item.height}`),
|
|
412
|
+
);
|
|
413
|
+
for (const note of visibleItemNotes(item.notes)) body.append(element("p", "note", note));
|
|
414
|
+
article.append(body);
|
|
415
|
+
|
|
416
|
+
const actions = element("div", "card-actions");
|
|
417
|
+
actions.append(
|
|
418
|
+
button("Add sent image to the next Pi message", "Add again", !mutable, () =>
|
|
419
|
+
restageHistory(item.id),
|
|
420
|
+
),
|
|
421
|
+
button(
|
|
422
|
+
"Delete sent image from session history",
|
|
423
|
+
"Delete",
|
|
424
|
+
false,
|
|
425
|
+
() => deleteHistory(item.id),
|
|
426
|
+
"danger-secondary",
|
|
427
|
+
),
|
|
428
|
+
);
|
|
311
429
|
article.append(actions);
|
|
312
430
|
return article;
|
|
313
431
|
}
|
|
314
432
|
|
|
433
|
+
function openPreview(item, collection) {
|
|
434
|
+
ui.previewTitle.textContent = item.name;
|
|
435
|
+
ui.previewImage.src = `/api/${collection}/${item.id}/preview?revision=${state.batch.revision}`;
|
|
436
|
+
ui.previewImage.alt = `Enlarged preview of ${item.name}`;
|
|
437
|
+
ui.previewDialog.showModal();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function closePreview() {
|
|
441
|
+
ui.previewDialog.close();
|
|
442
|
+
}
|
|
443
|
+
|
|
315
444
|
function button(label, text, disabled, action, className = "") {
|
|
316
445
|
const result = element("button", className, text);
|
|
317
446
|
result.type = "button";
|
package/src/web/index.html
CHANGED
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
<h1>Image Drop</h1>
|
|
15
15
|
<p id="session-label" class="session-label">Connecting to Pi…</p>
|
|
16
16
|
</div>
|
|
17
|
-
<details>
|
|
18
|
-
<summary>
|
|
17
|
+
<details class="session-details">
|
|
18
|
+
<summary>Session details</summary>
|
|
19
19
|
<dl>
|
|
20
20
|
<dt>Working directory</dt>
|
|
21
21
|
<dd id="cwd">—</dd>
|
|
@@ -27,27 +27,44 @@
|
|
|
27
27
|
<section id="drop-zone" class="drop-zone" aria-labelledby="drop-title">
|
|
28
28
|
<div class="drop-copy">
|
|
29
29
|
<span class="drop-icon" aria-hidden="true">🖼️</span>
|
|
30
|
-
<
|
|
31
|
-
|
|
30
|
+
<div>
|
|
31
|
+
<h2 id="drop-title">Add images</h2>
|
|
32
|
+
<p>Paste anywhere, drop files here, or choose images.</p>
|
|
33
|
+
</div>
|
|
32
34
|
<button id="choose-files" class="primary" type="button">Choose images</button>
|
|
33
35
|
<input id="file-input" type="file" multiple accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif" hidden />
|
|
34
36
|
</div>
|
|
35
37
|
</section>
|
|
36
38
|
|
|
37
|
-
<
|
|
39
|
+
<p class="collection-note">Sensitive image metadata removed from processed images.</p>
|
|
40
|
+
|
|
41
|
+
<section class="batch draft" aria-labelledby="batch-title">
|
|
38
42
|
<div class="batch-toolbar">
|
|
39
43
|
<div>
|
|
40
|
-
<h2 id="batch-title">
|
|
41
|
-
<p id="status"
|
|
44
|
+
<h2 id="batch-title">Ready for next message</h2>
|
|
45
|
+
<p id="status" hidden></p>
|
|
46
|
+
<p id="next-step" class="next-step" role="status" aria-live="polite">Choose images to add them to your next Pi message.</p>
|
|
42
47
|
</div>
|
|
43
|
-
<button id="clear-all" class="danger-secondary" type="button" disabled>Clear all</button>
|
|
48
|
+
<button id="clear-all" class="danger-secondary" type="button" hidden disabled>Clear all</button>
|
|
44
49
|
</div>
|
|
45
50
|
<div id="error-banner" class="banner error" role="alert" hidden></div>
|
|
46
51
|
<div id="grid" class="image-grid" aria-live="polite"></div>
|
|
47
52
|
</section>
|
|
48
53
|
|
|
54
|
+
<section class="history" aria-labelledby="history-title">
|
|
55
|
+
<header class="history-toolbar">
|
|
56
|
+
<div class="history-summary">
|
|
57
|
+
<h2 id="history-title">Previously sent</h2>
|
|
58
|
+
<p id="history-status" role="status" aria-live="polite">No images sent yet</p>
|
|
59
|
+
<p class="history-note"><span id="history-retention">0 images retained</span>. Images here are not attached again unless you choose <strong>Add again</strong>. The oldest are removed at the configured memory limit, and all are cleared when this Pi session ends.</p>
|
|
60
|
+
</div>
|
|
61
|
+
<button id="clear-history" class="danger-secondary" type="button" hidden disabled>Clear history</button>
|
|
62
|
+
</header>
|
|
63
|
+
<div id="history-grid" class="image-grid" aria-live="polite"></div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
49
66
|
<p class="privacy">
|
|
50
|
-
|
|
67
|
+
Draft and previously sent images stay in this Pi process until removed, evicted at the retention limit, or the session ends. Sending a Pi message sends its staged images to your configured model provider; deleting local history does not retract images already sent.
|
|
51
68
|
</p>
|
|
52
69
|
</main>
|
|
53
70
|
|
|
@@ -69,6 +86,30 @@
|
|
|
69
86
|
</form>
|
|
70
87
|
</dialog>
|
|
71
88
|
|
|
89
|
+
<dialog id="clear-history-dialog">
|
|
90
|
+
<form method="dialog">
|
|
91
|
+
<h2>Clear sent image history?</h2>
|
|
92
|
+
<p>This releases every retained image from this Pi session. Images already sent to Pi or a model provider are unaffected.</p>
|
|
93
|
+
<div class="dialog-actions">
|
|
94
|
+
<button type="submit" value="cancel">Cancel</button>
|
|
95
|
+
<button type="submit" class="danger" value="confirm">Clear history</button>
|
|
96
|
+
</div>
|
|
97
|
+
</form>
|
|
98
|
+
</dialog>
|
|
99
|
+
|
|
100
|
+
<dialog id="image-preview-dialog" class="image-preview-dialog" aria-labelledby="image-preview-title">
|
|
101
|
+
<div class="image-preview-content">
|
|
102
|
+
<header>
|
|
103
|
+
<h2 id="image-preview-title">Image preview</h2>
|
|
104
|
+
</header>
|
|
105
|
+
<div class="image-preview-stage">
|
|
106
|
+
<button id="image-preview-dismiss" class="image-preview-dismiss" type="button" aria-label="Close enlarged image">
|
|
107
|
+
<img id="image-preview" alt="" />
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</dialog>
|
|
112
|
+
|
|
72
113
|
<script type="module" src="/app.js"></script>
|
|
73
114
|
</body>
|
|
74
115
|
</html>
|
package/src/web/state.js
CHANGED
|
@@ -13,6 +13,47 @@ export function summarizeBatch(batch) {
|
|
|
13
13
|
};
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export function summarizeHistory(history) {
|
|
17
|
+
const total = (history?.items ?? []).length;
|
|
18
|
+
const bytes = Number(history?.totalBytes ?? 0);
|
|
19
|
+
const maxImages = Number(history?.maxImages ?? 0);
|
|
20
|
+
const maxBytes = Number(history?.maxBytes ?? 0);
|
|
21
|
+
return {
|
|
22
|
+
total,
|
|
23
|
+
bytes,
|
|
24
|
+
maxImages,
|
|
25
|
+
maxBytes,
|
|
26
|
+
label: total === 0 ? "No images sent yet" : `${total} ${plural(total, "image")} · ${formatBytes(bytes)}`,
|
|
27
|
+
usage: `${total}/${maxImages} images · ${formatBytes(bytes)} of ${formatBytes(maxBytes)}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SHARED_METADATA_NOTE = "Sensitive image metadata removed";
|
|
32
|
+
|
|
33
|
+
export function draftPresentation(batch) {
|
|
34
|
+
const summary = summarizeBatch(batch);
|
|
35
|
+
return {
|
|
36
|
+
status: summary.total === 0 ? "" : `${summary.label} · ${formatBytes(summary.bytes)}`,
|
|
37
|
+
guidance: draftGuidance(batch),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function draftGuidance(batch) {
|
|
42
|
+
const summary = summarizeBatch(batch);
|
|
43
|
+
if (batch.phase === "closed") return "This Pi session is no longer accepting images.";
|
|
44
|
+
if (batch.phase === "reserved") {
|
|
45
|
+
return "Queued with Pi. These images will be attached when Pi sends this message.";
|
|
46
|
+
}
|
|
47
|
+
if (summary.total === 0) return "Choose images to add them to your next Pi message.";
|
|
48
|
+
if (summary.error > 0) {
|
|
49
|
+
return `Fix or delete ${summary.error} ${plural(summary.error, "image")} that ${summary.error === 1 ? "needs" : "need"} attention before sending from Pi.`;
|
|
50
|
+
}
|
|
51
|
+
if (summary.uploading > 0) {
|
|
52
|
+
return `Wait for ${summary.uploading} ${plural(summary.uploading, "image")} to finish processing before sending from Pi.`;
|
|
53
|
+
}
|
|
54
|
+
return `Return to Pi and send a non-empty message. ${summary.ready} ready ${plural(summary.ready, "image")} will be attached automatically.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
16
57
|
export function statusLabel(phase, counts, total) {
|
|
17
58
|
if (phase === "empty" || total === 0) return "No images staged";
|
|
18
59
|
if (phase === "reserved") return `${total} ${plural(total, "image")} queued with Pi`;
|
|
@@ -22,6 +63,10 @@ export function statusLabel(phase, counts, total) {
|
|
|
22
63
|
return parts.join(" · ");
|
|
23
64
|
}
|
|
24
65
|
|
|
66
|
+
export function visibleItemNotes(notes) {
|
|
67
|
+
return Array.isArray(notes) ? notes.filter((note) => note !== SHARED_METADATA_NOTE) : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
25
70
|
export function moveItem(ids, id, direction) {
|
|
26
71
|
const from = ids.indexOf(id);
|
|
27
72
|
if (from === -1) return [...ids];
|
package/src/web/styles.css
CHANGED
|
@@ -61,14 +61,17 @@ button.primary {
|
|
|
61
61
|
button.primary:hover:not(:disabled) {
|
|
62
62
|
background: var(--accent-strong);
|
|
63
63
|
}
|
|
64
|
-
button.danger
|
|
65
|
-
button.delete {
|
|
64
|
+
button.danger {
|
|
66
65
|
border-color: var(--danger);
|
|
67
66
|
color: white;
|
|
68
67
|
background: var(--danger);
|
|
69
68
|
}
|
|
70
69
|
button.danger-secondary {
|
|
70
|
+
border-color: transparent;
|
|
71
|
+
background: transparent;
|
|
71
72
|
color: var(--danger);
|
|
73
|
+
}
|
|
74
|
+
button.danger-secondary:hover:not(:disabled) {
|
|
72
75
|
border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
|
|
73
76
|
}
|
|
74
77
|
|
|
@@ -104,6 +107,9 @@ h1 {
|
|
|
104
107
|
details {
|
|
105
108
|
max-width: min(28rem, 100%);
|
|
106
109
|
}
|
|
110
|
+
.session-details {
|
|
111
|
+
align-self: center;
|
|
112
|
+
}
|
|
107
113
|
summary {
|
|
108
114
|
min-height: 44px;
|
|
109
115
|
padding: 0.6rem;
|
|
@@ -129,14 +135,13 @@ main {
|
|
|
129
135
|
}
|
|
130
136
|
.drop-zone {
|
|
131
137
|
display: grid;
|
|
132
|
-
min-height:
|
|
138
|
+
min-height: 160px;
|
|
133
139
|
place-items: center;
|
|
134
140
|
border: 2px dashed #9da7ba;
|
|
135
141
|
border-radius: 18px;
|
|
136
|
-
padding: 2rem;
|
|
142
|
+
padding: 1.5rem 2rem;
|
|
137
143
|
background: var(--surface);
|
|
138
144
|
box-shadow: var(--shadow);
|
|
139
|
-
text-align: center;
|
|
140
145
|
transition:
|
|
141
146
|
border-color 0.15s,
|
|
142
147
|
background 0.15s,
|
|
@@ -151,21 +156,38 @@ main {
|
|
|
151
156
|
opacity: 0.66;
|
|
152
157
|
}
|
|
153
158
|
.drop-copy {
|
|
154
|
-
|
|
159
|
+
display: grid;
|
|
160
|
+
width: min(48rem, 100%);
|
|
161
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
162
|
+
align-items: center;
|
|
163
|
+
gap: 1rem;
|
|
155
164
|
}
|
|
156
165
|
.drop-copy h2 {
|
|
157
|
-
margin: 0
|
|
166
|
+
margin: 0;
|
|
158
167
|
}
|
|
159
168
|
.drop-copy p {
|
|
160
|
-
margin: 0.
|
|
169
|
+
margin: 0.15rem 0 0;
|
|
161
170
|
color: var(--muted);
|
|
162
171
|
}
|
|
163
172
|
.drop-icon {
|
|
164
|
-
font-size: 2.
|
|
173
|
+
font-size: 2.2rem;
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
.collection-note {
|
|
177
|
+
margin: 1rem 0 0;
|
|
178
|
+
color: var(--muted);
|
|
179
|
+
font-size: 0.9rem;
|
|
180
|
+
}
|
|
167
181
|
.batch {
|
|
168
|
-
margin-top:
|
|
182
|
+
margin-top: 0.75rem;
|
|
183
|
+
}
|
|
184
|
+
.history {
|
|
185
|
+
margin-top: 2rem;
|
|
186
|
+
border-top: 1px solid var(--line);
|
|
187
|
+
padding-top: 1.5rem;
|
|
188
|
+
}
|
|
189
|
+
.history h2 {
|
|
190
|
+
font-size: 1.25rem;
|
|
169
191
|
}
|
|
170
192
|
.batch-toolbar {
|
|
171
193
|
display: flex;
|
|
@@ -181,6 +203,37 @@ main {
|
|
|
181
203
|
.batch-toolbar p {
|
|
182
204
|
color: var(--muted);
|
|
183
205
|
}
|
|
206
|
+
.batch-toolbar .next-step {
|
|
207
|
+
max-width: 48rem;
|
|
208
|
+
margin-top: 0.2rem;
|
|
209
|
+
color: var(--text);
|
|
210
|
+
font-weight: 600;
|
|
211
|
+
}
|
|
212
|
+
.history-toolbar {
|
|
213
|
+
display: flex;
|
|
214
|
+
align-items: start;
|
|
215
|
+
justify-content: flex-start;
|
|
216
|
+
gap: 1rem;
|
|
217
|
+
margin-bottom: 0.8rem;
|
|
218
|
+
}
|
|
219
|
+
.history-summary {
|
|
220
|
+
min-width: 0;
|
|
221
|
+
max-width: 58rem;
|
|
222
|
+
}
|
|
223
|
+
.history-summary h2,
|
|
224
|
+
.history-summary p {
|
|
225
|
+
margin: 0;
|
|
226
|
+
}
|
|
227
|
+
.history-summary > p {
|
|
228
|
+
color: var(--muted);
|
|
229
|
+
}
|
|
230
|
+
.history-note {
|
|
231
|
+
margin-top: 0.35rem;
|
|
232
|
+
font-size: 0.9rem;
|
|
233
|
+
}
|
|
234
|
+
.history .image-card {
|
|
235
|
+
box-shadow: none;
|
|
236
|
+
}
|
|
184
237
|
.banner {
|
|
185
238
|
margin: 0.75rem 0;
|
|
186
239
|
border-radius: 10px;
|
|
@@ -193,7 +246,7 @@ main {
|
|
|
193
246
|
}
|
|
194
247
|
.image-grid {
|
|
195
248
|
display: grid;
|
|
196
|
-
grid-template-columns: repeat(auto-
|
|
249
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 245px), 1fr));
|
|
197
250
|
gap: 1rem;
|
|
198
251
|
}
|
|
199
252
|
.image-card {
|
|
@@ -206,6 +259,9 @@ main {
|
|
|
206
259
|
background: var(--surface);
|
|
207
260
|
box-shadow: 0 4px 16px rgb(19 33 68 / 7%);
|
|
208
261
|
}
|
|
262
|
+
.image-card:only-child {
|
|
263
|
+
max-width: 360px;
|
|
264
|
+
}
|
|
209
265
|
.image-card.status-error {
|
|
210
266
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
|
211
267
|
}
|
|
@@ -223,6 +279,18 @@ main {
|
|
|
223
279
|
height: 100%;
|
|
224
280
|
object-fit: contain;
|
|
225
281
|
}
|
|
282
|
+
.preview-button {
|
|
283
|
+
width: 100%;
|
|
284
|
+
min-height: 0;
|
|
285
|
+
border: 0;
|
|
286
|
+
border-radius: 0;
|
|
287
|
+
padding: 0;
|
|
288
|
+
background: var(--surface-alt);
|
|
289
|
+
cursor: zoom-in;
|
|
290
|
+
}
|
|
291
|
+
.preview-button:hover:not(:disabled) {
|
|
292
|
+
border-color: transparent;
|
|
293
|
+
}
|
|
226
294
|
.placeholder {
|
|
227
295
|
display: grid;
|
|
228
296
|
width: 3rem;
|
|
@@ -312,6 +380,67 @@ dialog {
|
|
|
312
380
|
dialog::backdrop {
|
|
313
381
|
background: rgb(11 17 30 / 60%);
|
|
314
382
|
}
|
|
383
|
+
.image-preview-dialog {
|
|
384
|
+
width: min(96vw, 1400px);
|
|
385
|
+
max-width: none;
|
|
386
|
+
height: 94dvh;
|
|
387
|
+
max-height: none;
|
|
388
|
+
overflow: hidden;
|
|
389
|
+
padding: 0;
|
|
390
|
+
}
|
|
391
|
+
.image-preview-dialog::backdrop {
|
|
392
|
+
background: rgb(11 17 30 / 88%);
|
|
393
|
+
}
|
|
394
|
+
.image-preview-content {
|
|
395
|
+
display: grid;
|
|
396
|
+
height: 100%;
|
|
397
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
398
|
+
}
|
|
399
|
+
.image-preview-content header {
|
|
400
|
+
display: flex;
|
|
401
|
+
align-items: center;
|
|
402
|
+
justify-content: space-between;
|
|
403
|
+
gap: 1rem;
|
|
404
|
+
border-bottom: 1px solid var(--line);
|
|
405
|
+
padding: 0.75rem 1rem;
|
|
406
|
+
}
|
|
407
|
+
.image-preview-content h2 {
|
|
408
|
+
margin: 0;
|
|
409
|
+
overflow: hidden;
|
|
410
|
+
text-overflow: ellipsis;
|
|
411
|
+
white-space: nowrap;
|
|
412
|
+
font-size: 1rem;
|
|
413
|
+
}
|
|
414
|
+
.image-preview-stage {
|
|
415
|
+
display: grid;
|
|
416
|
+
min-height: 0;
|
|
417
|
+
place-items: center;
|
|
418
|
+
padding: 1rem;
|
|
419
|
+
background: var(--surface-alt);
|
|
420
|
+
}
|
|
421
|
+
.image-preview-dismiss {
|
|
422
|
+
display: grid;
|
|
423
|
+
width: 100%;
|
|
424
|
+
height: 100%;
|
|
425
|
+
min-height: 0;
|
|
426
|
+
place-items: center;
|
|
427
|
+
border: 0;
|
|
428
|
+
border-radius: 0;
|
|
429
|
+
padding: 0;
|
|
430
|
+
background: transparent;
|
|
431
|
+
pointer-events: none;
|
|
432
|
+
}
|
|
433
|
+
.image-preview-dismiss:hover:not(:disabled) {
|
|
434
|
+
border-color: transparent;
|
|
435
|
+
}
|
|
436
|
+
.image-preview-dismiss img {
|
|
437
|
+
width: 100%;
|
|
438
|
+
height: 100%;
|
|
439
|
+
min-height: 0;
|
|
440
|
+
cursor: zoom-out;
|
|
441
|
+
object-fit: contain;
|
|
442
|
+
pointer-events: auto;
|
|
443
|
+
}
|
|
315
444
|
.dialog-actions {
|
|
316
445
|
display: flex;
|
|
317
446
|
justify-content: flex-end;
|
|
@@ -365,9 +494,21 @@ dialog::backdrop {
|
|
|
365
494
|
.drop-zone {
|
|
366
495
|
min-height: 210px;
|
|
367
496
|
padding: 1rem;
|
|
497
|
+
text-align: center;
|
|
498
|
+
}
|
|
499
|
+
.drop-copy {
|
|
500
|
+
grid-template-columns: 1fr;
|
|
501
|
+
justify-items: center;
|
|
502
|
+
gap: 0.65rem;
|
|
368
503
|
}
|
|
369
504
|
.batch-toolbar {
|
|
370
505
|
align-items: start;
|
|
506
|
+
flex-direction: column;
|
|
507
|
+
gap: 0.5rem;
|
|
508
|
+
}
|
|
509
|
+
.history-toolbar {
|
|
510
|
+
flex-direction: column;
|
|
511
|
+
gap: 0.5rem;
|
|
371
512
|
}
|
|
372
513
|
.image-grid {
|
|
373
514
|
grid-template-columns: 1fr;
|