@narumitw/pi-image-drop 0.18.0 → 0.20.1
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/package.json +1 -1
- package/src/batch.ts +29 -0
- package/src/runtime.ts +34 -19
- package/src/server.ts +38 -6
- package/src/web/app.js +15 -10
- package/src/web/state.js +8 -0
package/package.json
CHANGED
package/src/batch.ts
CHANGED
|
@@ -30,6 +30,7 @@ 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
|
|
|
@@ -142,6 +143,9 @@ export class BatchStore {
|
|
|
142
143
|
this.assertOpen();
|
|
143
144
|
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
144
145
|
const item = this.item(id);
|
|
146
|
+
if (item.status !== "uploading" && item.status !== "error") {
|
|
147
|
+
throw new BatchError("Image is already processing or ready", "not-ready");
|
|
148
|
+
}
|
|
145
149
|
if (source.byteLength !== item.size) {
|
|
146
150
|
throw new BatchError("Uploaded size does not match the reservation", "invalid");
|
|
147
151
|
}
|
|
@@ -150,6 +154,7 @@ export class BatchStore {
|
|
|
150
154
|
item.processedAutoResize = undefined;
|
|
151
155
|
item.error = undefined;
|
|
152
156
|
item.status = "processing";
|
|
157
|
+
item.processingOwner = "browser";
|
|
153
158
|
return this.bump();
|
|
154
159
|
}
|
|
155
160
|
|
|
@@ -173,6 +178,7 @@ export class BatchStore {
|
|
|
173
178
|
item.processed = cloneProcessed(processed);
|
|
174
179
|
item.processedAutoResize = autoResize;
|
|
175
180
|
item.status = "ready";
|
|
181
|
+
item.processingOwner = undefined;
|
|
176
182
|
item.error = undefined;
|
|
177
183
|
if (duplicates.length > 0) {
|
|
178
184
|
const laterIds = new Set(duplicates.map(({ candidate }) => candidate.id));
|
|
@@ -192,6 +198,7 @@ export class BatchStore {
|
|
|
192
198
|
item.error = sanitizeError(error);
|
|
193
199
|
item.processed = undefined;
|
|
194
200
|
item.processedAutoResize = undefined;
|
|
201
|
+
item.processingOwner = undefined;
|
|
195
202
|
return this.bump();
|
|
196
203
|
}
|
|
197
204
|
|
|
@@ -207,6 +214,26 @@ export class BatchStore {
|
|
|
207
214
|
return this.bump();
|
|
208
215
|
}
|
|
209
216
|
|
|
217
|
+
cancelInFlight(error: string): boolean {
|
|
218
|
+
this.assertOpen();
|
|
219
|
+
if (this.reservation) return false;
|
|
220
|
+
const inFlight = this.items.filter(
|
|
221
|
+
(item) =>
|
|
222
|
+
item.status === "uploading" ||
|
|
223
|
+
(item.status === "processing" && item.processingOwner === "browser"),
|
|
224
|
+
);
|
|
225
|
+
if (inFlight.length === 0) return false;
|
|
226
|
+
for (const item of inFlight) {
|
|
227
|
+
item.status = "error";
|
|
228
|
+
item.error = sanitizeError(error);
|
|
229
|
+
item.processed = undefined;
|
|
230
|
+
item.processedAutoResize = undefined;
|
|
231
|
+
item.processingOwner = undefined;
|
|
232
|
+
}
|
|
233
|
+
this.bump();
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
210
237
|
beginAutoResizeReprocessing(autoResize: boolean): Array<{ id: string; source: Buffer }> {
|
|
211
238
|
this.assertOpen();
|
|
212
239
|
if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
|
|
@@ -225,6 +252,7 @@ export class BatchStore {
|
|
|
225
252
|
item.status = "processing";
|
|
226
253
|
item.processed = undefined;
|
|
227
254
|
item.processedAutoResize = undefined;
|
|
255
|
+
item.processingOwner = "runtime";
|
|
228
256
|
item.error = undefined;
|
|
229
257
|
}
|
|
230
258
|
this.bump();
|
|
@@ -239,6 +267,7 @@ export class BatchStore {
|
|
|
239
267
|
throw new BatchError("Image cannot be retried without uploaded source bytes", "not-ready");
|
|
240
268
|
}
|
|
241
269
|
item.status = "processing";
|
|
270
|
+
item.processingOwner = "browser";
|
|
242
271
|
item.error = undefined;
|
|
243
272
|
item.processedAutoResize = undefined;
|
|
244
273
|
this.bump();
|
package/src/runtime.ts
CHANGED
|
@@ -83,17 +83,21 @@ export class ImageDropRuntime {
|
|
|
83
83
|
this.pi.on("before_agent_start", async () => this.batch?.markPreflightStarted());
|
|
84
84
|
this.pi.on("message_start", async (event, ctx) => this.handleMessageStart(event, ctx));
|
|
85
85
|
(this.pi as LatestExtensionAPI).on("agent_settled", async (_event, ctx) => {
|
|
86
|
+
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
86
87
|
await this.recoverReservation(ctx, "Queued image message was not delivered; restored it.");
|
|
87
88
|
});
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
async start(ctx: ExtensionContext): Promise<void> {
|
|
91
|
-
this.generation
|
|
92
|
+
const generation = ++this.generation;
|
|
93
|
+
const previousBatch = this.batch;
|
|
92
94
|
this.closed = true;
|
|
93
95
|
this.sessionAbort.abort();
|
|
94
96
|
await this.releaseServer();
|
|
95
|
-
|
|
97
|
+
previousBatch?.close();
|
|
98
|
+
if (generation !== this.generation) return;
|
|
96
99
|
const result = await this.dependencies.loadSettings();
|
|
100
|
+
if (generation !== this.generation) return;
|
|
97
101
|
this.settings = result.settings;
|
|
98
102
|
this.batch = new BatchStore(result.settings);
|
|
99
103
|
this.processor = this.dependencies.createProcessor();
|
|
@@ -109,11 +113,13 @@ export class ImageDropRuntime {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
async shutdown(ctx: ExtensionContext): Promise<void> {
|
|
112
|
-
this.generation
|
|
116
|
+
const generation = ++this.generation;
|
|
117
|
+
const previousBatch = this.batch;
|
|
113
118
|
this.closed = true;
|
|
114
119
|
this.sessionAbort.abort();
|
|
115
120
|
await this.releaseServer();
|
|
116
|
-
|
|
121
|
+
previousBatch?.close();
|
|
122
|
+
if (generation !== this.generation) return;
|
|
117
123
|
this.batch = undefined;
|
|
118
124
|
this.settings = undefined;
|
|
119
125
|
this.processor = undefined;
|
|
@@ -150,7 +156,12 @@ export class ImageDropRuntime {
|
|
|
150
156
|
ctx.ui.notify("The current model does not support image input.", "warning");
|
|
151
157
|
return { action: "handled" };
|
|
152
158
|
}
|
|
159
|
+
const generation = this.generation;
|
|
160
|
+
const batch = this.batch;
|
|
153
161
|
const piSettings = await this.dependencies.readPiSettings(ctx.cwd, ctx.isProjectTrusted());
|
|
162
|
+
if (generation !== this.generation || batch !== this.batch || this.closed) {
|
|
163
|
+
return { action: "handled" };
|
|
164
|
+
}
|
|
154
165
|
this.notifyPiSettingsWarnings(ctx, piSettings.warnings);
|
|
155
166
|
if (piSettings.blockImages) {
|
|
156
167
|
this.restoreEditor(ctx, event.text);
|
|
@@ -162,7 +173,7 @@ export class ImageDropRuntime {
|
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
try {
|
|
165
|
-
const reservation =
|
|
176
|
+
const reservation = batch.reserveMessage(event.text, event.streamingBehavior);
|
|
166
177
|
this.server?.broadcastState();
|
|
167
178
|
this.updateWidget(ctx);
|
|
168
179
|
return {
|
|
@@ -230,19 +241,18 @@ export class ImageDropRuntime {
|
|
|
230
241
|
if (!(failure instanceof BatchError) || failure.code !== "not-found") throw failure;
|
|
231
242
|
}
|
|
232
243
|
} finally {
|
|
233
|
-
this.server?.broadcastState();
|
|
244
|
+
if (generation === this.generation) this.server?.broadcastState();
|
|
234
245
|
}
|
|
235
246
|
}),
|
|
236
247
|
);
|
|
237
|
-
if (generation !== this.generation
|
|
248
|
+
if (generation !== this.generation) return false;
|
|
249
|
+
if (batch.publicState().phase !== "ready") {
|
|
238
250
|
this.restoreEditor(ctx, text);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
this.updateWidget(ctx);
|
|
245
|
-
}
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
"Images could not be updated for the current auto-resize setting.",
|
|
253
|
+
"warning",
|
|
254
|
+
);
|
|
255
|
+
this.updateWidget(ctx);
|
|
246
256
|
return false;
|
|
247
257
|
}
|
|
248
258
|
this.updateWidget(ctx);
|
|
@@ -300,10 +310,10 @@ export class ImageDropRuntime {
|
|
|
300
310
|
|
|
301
311
|
private async releaseServer(): Promise<void> {
|
|
302
312
|
const server = this.server;
|
|
303
|
-
this.server = undefined;
|
|
304
|
-
if (server) await server.close();
|
|
305
313
|
const starting = this.serverStarting;
|
|
314
|
+
this.server = undefined;
|
|
306
315
|
this.serverStarting = undefined;
|
|
316
|
+
if (server) await server.close();
|
|
307
317
|
if (starting) {
|
|
308
318
|
try {
|
|
309
319
|
await (await starting).close();
|
|
@@ -318,9 +328,7 @@ export class ImageDropRuntime {
|
|
|
318
328
|
const reservation = this.batch?.currentReservation();
|
|
319
329
|
if (!reservation) return;
|
|
320
330
|
const images = userMessageImages(event);
|
|
321
|
-
if (images
|
|
322
|
-
const attached = images.slice(-reservation.images.length);
|
|
323
|
-
if (digestImages(attached) !== reservation.digest) return;
|
|
331
|
+
if (!containsImageSequence(images, reservation.images.length, reservation.digest)) return;
|
|
324
332
|
this.batch?.commitReservation(reservation.digest);
|
|
325
333
|
this.server?.broadcastState();
|
|
326
334
|
this.updateWidget(ctx);
|
|
@@ -398,6 +406,13 @@ function userMessageImages(event: unknown): ImageContent[] {
|
|
|
398
406
|
return content.filter(isImageContent);
|
|
399
407
|
}
|
|
400
408
|
|
|
409
|
+
function containsImageSequence(images: ImageContent[], length: number, digest: string): boolean {
|
|
410
|
+
for (let start = 0; start + length <= images.length; start += 1) {
|
|
411
|
+
if (digestImages(images.slice(start, start + length)) === digest) return true;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
401
416
|
function isImageContent(value: unknown): value is ImageContent {
|
|
402
417
|
return (
|
|
403
418
|
isRecord(value) &&
|
package/src/server.ts
CHANGED
|
@@ -28,6 +28,11 @@ interface SseClient {
|
|
|
28
28
|
response: ServerResponse;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
interface ClientLease {
|
|
32
|
+
clientId: string;
|
|
33
|
+
generation: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
31
36
|
class HttpError extends Error {
|
|
32
37
|
constructor(
|
|
33
38
|
readonly status: number,
|
|
@@ -47,6 +52,7 @@ export class ImageDropServer {
|
|
|
47
52
|
private readonly sockets = new Set<Socket>();
|
|
48
53
|
private readonly sseClients = new Set<SseClient>();
|
|
49
54
|
private activeClientId?: string;
|
|
55
|
+
private activeClientGeneration = 0;
|
|
50
56
|
private closed = false;
|
|
51
57
|
private closePromise?: Promise<void>;
|
|
52
58
|
|
|
@@ -183,13 +189,15 @@ export class ImageDropServer {
|
|
|
183
189
|
throw new HttpError(400, "Invalid client id");
|
|
184
190
|
}
|
|
185
191
|
this.takeLease(clientId);
|
|
192
|
+
this.broadcastState();
|
|
186
193
|
this.json(response, 200, this.statePayload());
|
|
187
194
|
return;
|
|
188
195
|
}
|
|
189
196
|
this.assertMutation(request);
|
|
190
|
-
this.assertActiveClient(request);
|
|
197
|
+
const lease = this.assertActiveClient(request);
|
|
191
198
|
if (request.method === "POST" && url.pathname === "/api/items") {
|
|
192
199
|
const body = await readJson(request, JSON_LIMIT);
|
|
200
|
+
this.assertLease(lease);
|
|
193
201
|
const revision = integerField(body, "revision");
|
|
194
202
|
const inputs = arrayField(body, "items").map((item) => {
|
|
195
203
|
if (!isRecord(item)) throw new HttpError(400, "Invalid item reservation");
|
|
@@ -210,6 +218,7 @@ export class ImageDropServer {
|
|
|
210
218
|
try {
|
|
211
219
|
source = await readBody(request, this.options.settings.maxImageBytes);
|
|
212
220
|
} catch (error) {
|
|
221
|
+
this.assertLease(lease);
|
|
213
222
|
try {
|
|
214
223
|
this.options.batch.failUpload(id, `Upload failed: ${formatError(error)}`);
|
|
215
224
|
} catch (failure) {
|
|
@@ -218,9 +227,10 @@ export class ImageDropServer {
|
|
|
218
227
|
this.broadcastState();
|
|
219
228
|
throw error;
|
|
220
229
|
}
|
|
230
|
+
this.assertLease(lease);
|
|
221
231
|
this.options.batch.startProcessing(id, source);
|
|
222
232
|
this.broadcastState();
|
|
223
|
-
const completed = await this.processItem(id, source, request);
|
|
233
|
+
const completed = await this.processItem(id, source, request, lease);
|
|
224
234
|
this.broadcastState();
|
|
225
235
|
this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
|
|
226
236
|
return;
|
|
@@ -228,6 +238,7 @@ export class ImageDropServer {
|
|
|
228
238
|
const failureMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/fail$/.exec(url.pathname);
|
|
229
239
|
if (request.method === "POST" && failureMatch) {
|
|
230
240
|
const body = await readJson(request, JSON_LIMIT);
|
|
241
|
+
this.assertLease(lease);
|
|
231
242
|
this.options.batch.failUpload(failureMatch[1] as string, stringField(body, "error"));
|
|
232
243
|
this.respondWithState(response);
|
|
233
244
|
return;
|
|
@@ -237,7 +248,7 @@ export class ImageDropServer {
|
|
|
237
248
|
const id = retryMatch[1] as string;
|
|
238
249
|
const source = this.options.batch.retrySource(id);
|
|
239
250
|
this.broadcastState();
|
|
240
|
-
const completed = await this.processItem(id, source, request);
|
|
251
|
+
const completed = await this.processItem(id, source, request, lease);
|
|
241
252
|
this.broadcastState();
|
|
242
253
|
this.json(response, 200, { ...this.statePayload(), duplicateOf: completed.duplicateOf });
|
|
243
254
|
return;
|
|
@@ -250,6 +261,7 @@ export class ImageDropServer {
|
|
|
250
261
|
}
|
|
251
262
|
if (request.method === "PUT" && url.pathname === "/api/order") {
|
|
252
263
|
const body = await readJson(request, JSON_LIMIT);
|
|
264
|
+
this.assertLease(lease);
|
|
253
265
|
const ids = arrayField(body, "ids").map((id) => {
|
|
254
266
|
if (typeof id !== "string") throw new HttpError(400, "Invalid image order");
|
|
255
267
|
return id;
|
|
@@ -260,6 +272,7 @@ export class ImageDropServer {
|
|
|
260
272
|
}
|
|
261
273
|
if (request.method === "POST" && url.pathname === "/api/clear") {
|
|
262
274
|
const body = await readJson(request, JSON_LIMIT);
|
|
275
|
+
this.assertLease(lease);
|
|
263
276
|
this.options.batch.clear(integerField(body, "revision"));
|
|
264
277
|
this.respondWithState(response);
|
|
265
278
|
return;
|
|
@@ -295,17 +308,31 @@ export class ImageDropServer {
|
|
|
295
308
|
if (request.headers.origin !== this.origin) throw new HttpError(403, "Unexpected Origin");
|
|
296
309
|
}
|
|
297
310
|
|
|
298
|
-
private assertActiveClient(request: IncomingMessage):
|
|
299
|
-
const
|
|
300
|
-
if (typeof
|
|
311
|
+
private assertActiveClient(request: IncomingMessage): ClientLease {
|
|
312
|
+
const clientId = request.headers["x-image-drop-client"];
|
|
313
|
+
if (typeof clientId !== "string" || clientId !== this.activeClientId) {
|
|
314
|
+
throw new HttpError(409, "This page no longer owns the editing lease");
|
|
315
|
+
}
|
|
316
|
+
return { clientId, generation: this.activeClientGeneration };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private assertLease(lease: ClientLease): void {
|
|
320
|
+
if (
|
|
321
|
+
lease.clientId !== this.activeClientId ||
|
|
322
|
+
lease.generation !== this.activeClientGeneration
|
|
323
|
+
) {
|
|
301
324
|
throw new HttpError(409, "This page no longer owns the editing lease");
|
|
302
325
|
}
|
|
303
326
|
}
|
|
304
327
|
|
|
305
328
|
private takeLease(clientId: string): void {
|
|
306
329
|
const previous = this.activeClientId;
|
|
330
|
+
if (previous !== clientId) this.activeClientGeneration += 1;
|
|
307
331
|
this.activeClientId = clientId;
|
|
308
332
|
if (previous && previous !== clientId) {
|
|
333
|
+
this.options.batch.cancelInFlight(
|
|
334
|
+
"Upload cancelled because the Image Drop page was replaced.",
|
|
335
|
+
);
|
|
309
336
|
for (const client of [...this.sseClients]) {
|
|
310
337
|
if (client.id !== previous) continue;
|
|
311
338
|
writeSse(client.response, "stale", { message: "Image Drop opened in another tab" });
|
|
@@ -335,21 +362,26 @@ export class ImageDropServer {
|
|
|
335
362
|
id: string,
|
|
336
363
|
source: Buffer,
|
|
337
364
|
request: IncomingMessage,
|
|
365
|
+
lease: ClientLease,
|
|
338
366
|
): Promise<{ duplicateOf?: string }> {
|
|
339
367
|
const requestAbort = new AbortController();
|
|
340
368
|
request.once("aborted", () => requestAbort.abort());
|
|
341
369
|
const signal = AbortSignal.any([this.abortController.signal, requestAbort.signal]);
|
|
342
370
|
try {
|
|
343
371
|
const autoResize = await this.options.getAutoResize();
|
|
372
|
+
this.assertLease(lease);
|
|
344
373
|
const processed = await this.options.process(source, {
|
|
345
374
|
autoResize,
|
|
346
375
|
maxImagePixels: this.options.settings.maxImagePixels,
|
|
347
376
|
signal,
|
|
348
377
|
});
|
|
378
|
+
this.assertLease(lease);
|
|
349
379
|
const completion = this.options.batch.complete(id, processed, autoResize);
|
|
350
380
|
return completion.kind === "duplicate" ? { duplicateOf: completion.existingId } : {};
|
|
351
381
|
} catch (error) {
|
|
382
|
+
if (error instanceof HttpError) throw error;
|
|
352
383
|
if (this.closed || signal.aborted || isDiscardedItemError(error)) return {};
|
|
384
|
+
this.assertLease(lease);
|
|
353
385
|
if (!this.failItem(id, formatError(error))) return {};
|
|
354
386
|
throw new HttpError(422, formatError(error));
|
|
355
387
|
} finally {
|
package/src/web/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
attemptMutation,
|
|
2
3
|
canMutate,
|
|
3
4
|
formatBytes,
|
|
4
5
|
moveItem,
|
|
@@ -198,8 +199,9 @@ async function retry(id) {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
async function remove(id) {
|
|
201
|
-
await mutate(`/api/items/${id}?revision=${state.batch.revision}`, { method: "DELETE" })
|
|
202
|
-
|
|
202
|
+
if (await mutate(`/api/items/${id}?revision=${state.batch.revision}`, { method: "DELETE" })) {
|
|
203
|
+
pendingFiles.delete(id);
|
|
204
|
+
}
|
|
203
205
|
}
|
|
204
206
|
|
|
205
207
|
async function reorder(ids) {
|
|
@@ -208,18 +210,21 @@ async function reorder(ids) {
|
|
|
208
210
|
}
|
|
209
211
|
|
|
210
212
|
async function clearAll() {
|
|
211
|
-
await mutate("/api/clear", { method: "POST", json: { revision: state.batch.revision } })
|
|
212
|
-
|
|
213
|
+
if (await mutate("/api/clear", { method: "POST", json: { revision: state.batch.revision } })) {
|
|
214
|
+
pendingFiles.clear();
|
|
215
|
+
}
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
async function mutate(path, options) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
} catch (error) {
|
|
221
|
-
showError(errorMessage(error));
|
|
219
|
+
const result = await attemptMutation(() => request(path, options));
|
|
220
|
+
if (!result.ok) {
|
|
221
|
+
showError(errorMessage(result.error));
|
|
222
|
+
return false;
|
|
222
223
|
}
|
|
224
|
+
applyState(result.value);
|
|
225
|
+
clearError();
|
|
226
|
+
render();
|
|
227
|
+
return true;
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
function render() {
|
package/src/web/state.js
CHANGED
|
@@ -49,6 +49,14 @@ export function preferNewestState(current, next) {
|
|
|
49
49
|
return current;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export async function attemptMutation(operation) {
|
|
53
|
+
try {
|
|
54
|
+
return { ok: true, value: await operation() };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return { ok: false, error };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
export function formatBytes(value) {
|
|
53
61
|
const bytes = Math.max(0, Number(value) || 0);
|
|
54
62
|
if (bytes < 1024) return `${bytes} B`;
|