dsh-comfyui 0.3.0-beta.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/lib/routes.js ADDED
@@ -0,0 +1,800 @@
1
+ import { errorMessage, readJsonBody, readRawBody, sameOrigin, sendJson } from './http.js';
2
+ import { analyzeWorkflowParameters, comboChildInfo, inputOptions, uploadKindOf } from './params.js';
3
+ import { collectMedia, historyErrorMessage } from './comfyui.js';
4
+ function generatedUrlOf(assets, name) {
5
+ for (const asset of assets) {
6
+ for (const item of asset.media) {
7
+ if (item.filename === name && item.url !== undefined)
8
+ return item.url;
9
+ }
10
+ }
11
+ return undefined;
12
+ }
13
+ function findOutputRef(assets, name) {
14
+ for (const asset of assets) {
15
+ for (const item of asset.media) {
16
+ if (item.filename === name) {
17
+ return { filename: item.filename, subfolder: item.subfolder, type: item.type };
18
+ }
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ function redact(runtime, apiKey) {
24
+ const config = runtime.getConfig();
25
+ return {
26
+ baseUrl: config.baseUrl,
27
+ apiKeyEnv: config.apiKeyEnv,
28
+ hasApiKey: apiKey !== undefined,
29
+ timeoutMs: config.timeoutMs,
30
+ pollIntervalMs: config.pollIntervalMs,
31
+ maxMediaItems: config.maxMediaItems,
32
+ mediaHost: config.mediaHost,
33
+ writable: runtime.settingsWritable(),
34
+ };
35
+ }
36
+ function methodIs(request, method) {
37
+ return request.method === method;
38
+ }
39
+ /**
40
+ * Video/audio previews cannot render as <img> thumbnails. VHS embeds the
41
+ * companion workflow image (same basename, .png) in the video's `workflow`
42
+ * field; prefer it. Without one, drop the preview entirely.
43
+ */
44
+ function previewThumb(preview) {
45
+ if (preview === null || preview.filename === undefined || preview.filename === null)
46
+ return null;
47
+ const isMedia = preview.mediaType === 'gifs' || preview.mediaType === 'video' || preview.mediaType === 'audio'
48
+ || /\.(mp4|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(preview.filename);
49
+ if (!isMedia) {
50
+ return { filename: preview.filename, subfolder: preview.subfolder ?? '', type: preview.type ?? 'output', mediaType: preview.mediaType };
51
+ }
52
+ if (typeof preview.workflow === 'string' && preview.workflow !== '' && /\.(png|jpe?g|webp|gif)$/i.test(preview.workflow)) {
53
+ return { filename: preview.workflow, subfolder: preview.subfolder ?? '', type: preview.type ?? 'output', mediaType: 'image' };
54
+ }
55
+ return null;
56
+ }
57
+ /** Reject non-same-origin requests; returns the parsed body otherwise. */
58
+ async function readSameOriginPost(request, response) {
59
+ if (!sameOrigin(request)) {
60
+ sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
61
+ return undefined;
62
+ }
63
+ const body = await readJsonBody(request);
64
+ return (typeof body === 'object' && body !== null ? body : {});
65
+ }
66
+ /**
67
+ * Mount every dsh-comfyui route on the host web server.
68
+ * @returns the disposer, or undefined when no web server is present.
69
+ */
70
+ export function mountComfyUIRoutes(ctx, runtime) {
71
+ const webServer = ctx.get('webServer');
72
+ if (webServer === undefined)
73
+ return undefined;
74
+ // Record the browser's request origin on every route so media URLs can use
75
+ // the address the browser actually reached (loopback, LAN IP, or domain).
76
+ const withHint = (handler) => {
77
+ return (request, response) => {
78
+ runtime.hostHint.record(request);
79
+ return handler(request, response);
80
+ };
81
+ };
82
+ const disposers = [];
83
+ // Browser self-report: the index.html tap injects a one-line fetch that
84
+ // fires on every page load, so the host hint learns the origin the browser
85
+ // is actually using (e.g. http://100.97.190.89:3080) before any generation
86
+ // — without relying on the panel being opened or media being loaded.
87
+ disposers.push(webServer.register({
88
+ kind: 'exact',
89
+ path: '/comfyui/ping',
90
+ handler: withHint(async (_request, response) => {
91
+ sendJson(response, 200, { ok: true });
92
+ }),
93
+ }));
94
+ disposers.push(webServer.register({
95
+ kind: 'exact',
96
+ path: '/comfyui/config',
97
+ handler: withHint(async (request, response) => {
98
+ if (methodIs(request, 'GET')) {
99
+ const apiKey = await runtime.getApiKey();
100
+ sendJson(response, 200, redact(runtime, apiKey));
101
+ return;
102
+ }
103
+ if (methodIs(request, 'POST')) {
104
+ const body = await readSameOriginPost(request, response);
105
+ if (body === undefined)
106
+ return;
107
+ const patch = body.patch;
108
+ if (patch === undefined || typeof patch !== 'object' || patch === null) {
109
+ sendJson(response, 400, { error: 'a patch object is required' });
110
+ return;
111
+ }
112
+ const result = await runtime.updateConfig(patch);
113
+ if (!result.ok) {
114
+ sendJson(response, 409, { error: result.error });
115
+ return;
116
+ }
117
+ const apiKey = await runtime.getApiKey();
118
+ sendJson(response, 200, { ok: true, config: redact(runtime, apiKey) });
119
+ return;
120
+ }
121
+ sendJson(response, 405, { error: 'method not allowed' });
122
+ }),
123
+ }));
124
+ disposers.push(webServer.register({
125
+ kind: 'exact',
126
+ path: '/comfyui/test',
127
+ handler: withHint(async (request, response) => {
128
+ if (!methodIs(request, 'POST')) {
129
+ sendJson(response, 405, { error: 'method not allowed' });
130
+ return;
131
+ }
132
+ if (!sameOrigin(request)) {
133
+ sendJson(response, 403, { error: 'forbidden: same-origin requests only' });
134
+ return;
135
+ }
136
+ const startedAt = Date.now();
137
+ try {
138
+ const client = runtime.createClient(await runtime.getApiKey());
139
+ const stats = await client.systemStats();
140
+ sendJson(response, 200, {
141
+ ok: true,
142
+ version: stats.system?.comfyui_version ?? 'unknown',
143
+ latencyMs: Date.now() - startedAt,
144
+ });
145
+ }
146
+ catch (error) {
147
+ sendJson(response, 200, { ok: false, error: errorMessage(error), latencyMs: Date.now() - startedAt });
148
+ }
149
+ }),
150
+ }));
151
+ disposers.push(webServer.register({
152
+ kind: 'exact',
153
+ path: '/comfyui/workflows',
154
+ handler: withHint(async (request, response) => {
155
+ if (methodIs(request, 'GET')) {
156
+ sendJson(response, 200, { workflows: await runtime.listWorkflows() });
157
+ return;
158
+ }
159
+ if (methodIs(request, 'POST')) {
160
+ const body = await readSameOriginPost(request, response);
161
+ if (body === undefined)
162
+ return;
163
+ if (body.workflow === undefined) {
164
+ sendJson(response, 400, { error: 'workflow is required' });
165
+ return;
166
+ }
167
+ const result = await runtime.saveWorkflow({
168
+ id: typeof body.id === 'string' ? body.id : undefined,
169
+ name: typeof body.name === 'string' ? body.name : '',
170
+ description: typeof body.description === 'string' ? body.description : '',
171
+ workflow: body.workflow,
172
+ parameters: Array.isArray(body.parameters) ? body.parameters : undefined,
173
+ tags: Array.isArray(body.tags) ? body.tags.filter((tag) => typeof tag === 'string') : undefined,
174
+ });
175
+ if (!result.ok) {
176
+ sendJson(response, 400, { error: result.error });
177
+ return;
178
+ }
179
+ sendJson(response, 200, { ok: true, workflow: result.workflow });
180
+ return;
181
+ }
182
+ sendJson(response, 405, { error: 'method not allowed' });
183
+ }),
184
+ }));
185
+ disposers.push(webServer.register({
186
+ kind: 'exact',
187
+ path: '/comfyui/workflows/recognize',
188
+ handler: withHint(async (request, response) => {
189
+ if (!methodIs(request, 'POST')) {
190
+ sendJson(response, 405, { error: 'method not allowed' });
191
+ return;
192
+ }
193
+ const body = await readSameOriginPost(request, response);
194
+ if (body === undefined)
195
+ return;
196
+ if (body.workflow === undefined || typeof body.workflow !== 'object' || body.workflow === null) {
197
+ sendJson(response, 400, { error: 'workflow is required' });
198
+ return;
199
+ }
200
+ const client = runtime.createClient(await runtime.getApiKey());
201
+ const objectInfo = await client.objectInfo().catch(() => undefined);
202
+ const parameters = analyzeWorkflowParameters(body.workflow, objectInfo);
203
+ sendJson(response, 200, { ok: true, parameters });
204
+ }),
205
+ }));
206
+ disposers.push(webServer.register({
207
+ kind: 'exact',
208
+ path: '/comfyui/workflows/input-options',
209
+ handler: withHint(async (request, response) => {
210
+ if (!methodIs(request, 'POST')) {
211
+ sendJson(response, 405, { error: 'method not allowed' });
212
+ return;
213
+ }
214
+ const body = await readSameOriginPost(request, response);
215
+ if (body === undefined)
216
+ return;
217
+ const classType = typeof body.classType === 'string' ? body.classType : '';
218
+ const inputKey = typeof body.inputKey === 'string' ? body.inputKey : '';
219
+ if (classType === '' || inputKey === '') {
220
+ sendJson(response, 400, { error: 'classType and inputKey are required' });
221
+ return;
222
+ }
223
+ const client = runtime.createClient(await runtime.getApiKey());
224
+ const objectInfo = await client.objectInfo().catch(() => undefined);
225
+ const options = inputOptions(objectInfo, classType, inputKey);
226
+ const child = typeof body.parentValue === 'string'
227
+ ? comboChildInfo(objectInfo, classType, inputKey, body.parentValue)
228
+ : undefined;
229
+ const upload = uploadKindOf(objectInfo, classType, inputKey);
230
+ sendJson(response, 200, { ok: true, options: options ?? [], child, upload });
231
+ }),
232
+ }));
233
+ disposers.push(webServer.register({
234
+ kind: 'exact',
235
+ path: '/comfyui/loadarea',
236
+ handler: withHint(async (request, response) => {
237
+ if (!methodIs(request, 'GET')) {
238
+ sendJson(response, 405, { error: 'method not allowed' });
239
+ return;
240
+ }
241
+ try {
242
+ const client = runtime.createClient(await runtime.getApiKey());
243
+ const [objectInfo, current, assets, sizes] = await Promise.all([
244
+ client.objectInfo().catch(() => undefined),
245
+ runtime.loadCurrentImage(),
246
+ runtime.listAssets(),
247
+ runtime.listMediaSizes(),
248
+ ]);
249
+ const files = [];
250
+ const seen = new Set();
251
+ // imported: files visible to the ComfyUI loader nodes (input dir)
252
+ const loaderSpecs = [
253
+ ['LoadImage', 'image', 'image'],
254
+ ['LoadVideo', 'video', 'video'],
255
+ ['LoadAudio', 'audio', 'audio'],
256
+ ];
257
+ for (const [classType, inputKey, kind] of loaderSpecs) {
258
+ for (const option of inputOptions(objectInfo, classType, inputKey) ?? []) {
259
+ const name = String(option);
260
+ if (seen.has(name))
261
+ continue;
262
+ seen.add(name);
263
+ files.push({
264
+ name,
265
+ kind,
266
+ source: 'imported',
267
+ url: `/comfyui/media?file=${encodeURIComponent(name)}&type=input`,
268
+ width: sizes[name]?.width,
269
+ height: sizes[name]?.height,
270
+ });
271
+ }
272
+ }
273
+ // generated: completed runs collected into the asset index (output dir)
274
+ for (const asset of assets) {
275
+ for (const item of asset.media) {
276
+ if (item.filename === '' || seen.has(item.filename))
277
+ continue;
278
+ seen.add(item.filename);
279
+ files.push({
280
+ name: item.filename,
281
+ kind: item.kind === 'video' ? 'video' : item.kind === 'audio' ? 'audio' : 'image',
282
+ source: 'generated',
283
+ url: item.url,
284
+ ts: asset.ts,
285
+ workflowName: asset.workflowName,
286
+ });
287
+ }
288
+ }
289
+ let currentEntry = null;
290
+ if (current !== undefined) {
291
+ const url = current.source === 'generated'
292
+ ? generatedUrlOf(assets, current.name) ?? `/comfyui/media?file=${encodeURIComponent(current.name)}&type=output`
293
+ : `/comfyui/media?file=${encodeURIComponent(current.name)}&type=input`;
294
+ currentEntry = {
295
+ name: current.name,
296
+ kind: current.kind,
297
+ source: current.source,
298
+ url,
299
+ width: sizes[current.name]?.width,
300
+ height: sizes[current.name]?.height,
301
+ };
302
+ }
303
+ sendJson(response, 200, { ok: true, current: currentEntry, files });
304
+ }
305
+ catch (error) {
306
+ sendJson(response, 500, { error: errorMessage(error) });
307
+ }
308
+ }),
309
+ }));
310
+ disposers.push(webServer.register({
311
+ kind: 'exact',
312
+ path: '/comfyui/current-image',
313
+ handler: withHint(async (request, response) => {
314
+ if (!methodIs(request, 'POST')) {
315
+ sendJson(response, 405, { error: 'method not allowed' });
316
+ return;
317
+ }
318
+ const body = await readSameOriginPost(request, response);
319
+ if (body === undefined)
320
+ return;
321
+ const name = typeof body.name === 'string' && body.name !== '' ? body.name : '';
322
+ const kind = body.kind === 'video' ? 'video' : body.kind === 'audio' ? 'audio' : 'image';
323
+ const source = body.source === 'generated' ? 'generated' : 'imported';
324
+ if (name === '') {
325
+ sendJson(response, 400, { error: 'name is required' });
326
+ return;
327
+ }
328
+ try {
329
+ if (source === 'generated') {
330
+ // Generated outputs live in ComfyUI's output dir; copy the selected
331
+ // one into the input dir (same name) so loader nodes can use it.
332
+ const assets = await runtime.listAssets();
333
+ const ref = findOutputRef(assets, name);
334
+ if (ref !== undefined) {
335
+ const client = runtime.createClient(await runtime.getApiKey());
336
+ const { bytes, contentType } = await client.fetchView(ref);
337
+ await client.uploadFile(bytes, contentType);
338
+ }
339
+ }
340
+ await runtime.saveCurrentImage({ name, kind, source });
341
+ sendJson(response, 200, { ok: true });
342
+ }
343
+ catch (error) {
344
+ sendJson(response, 500, { error: errorMessage(error) });
345
+ }
346
+ }),
347
+ }));
348
+ disposers.push(webServer.register({
349
+ kind: 'exact',
350
+ path: '/comfyui/upload',
351
+ handler: withHint(async (request, response) => {
352
+ if (!methodIs(request, 'POST')) {
353
+ sendJson(response, 405, { error: 'method not allowed' });
354
+ return;
355
+ }
356
+ const contentType = request.headers['content-type'] ?? '';
357
+ if (!contentType.startsWith('multipart/form-data')) {
358
+ sendJson(response, 400, { error: 'multipart/form-data required' });
359
+ return;
360
+ }
361
+ try {
362
+ // Forward the multipart body verbatim (field "image" matches ComfyUI's
363
+ // /upload/image contract); the browser never talks to ComfyUI directly.
364
+ const raw = await readRawBody(request);
365
+ const client = runtime.createClient(await runtime.getApiKey());
366
+ const result = await client.uploadFile(new Uint8Array(raw), contentType);
367
+ sendJson(response, 200, { ok: true, name: result.name ?? '' });
368
+ }
369
+ catch (error) {
370
+ sendJson(response, 502, { error: errorMessage(error) });
371
+ }
372
+ }),
373
+ }));
374
+ disposers.push(webServer.register({
375
+ kind: 'exact',
376
+ path: '/comfyui/media-size',
377
+ handler: withHint(async (request, response) => {
378
+ if (!methodIs(request, 'POST')) {
379
+ sendJson(response, 405, { error: 'method not allowed' });
380
+ return;
381
+ }
382
+ const body = await readSameOriginPost(request, response);
383
+ if (body === undefined)
384
+ return;
385
+ const name = typeof body.name === 'string' && body.name !== '' ? body.name : '';
386
+ const width = typeof body.width === 'number' && Number.isFinite(body.width) && body.width > 0 ? Math.round(body.width) : undefined;
387
+ const height = typeof body.height === 'number' && Number.isFinite(body.height) && body.height > 0 ? Math.round(body.height) : undefined;
388
+ if (name === '' || width === undefined || height === undefined) {
389
+ sendJson(response, 400, { error: 'name, width and height are required' });
390
+ return;
391
+ }
392
+ await runtime.saveMediaSize(name, { width, height });
393
+ sendJson(response, 200, { ok: true });
394
+ }),
395
+ }));
396
+ disposers.push(webServer.register({
397
+ kind: 'exact',
398
+ path: '/comfyui/media-lookup',
399
+ handler: withHint(async (request, response) => {
400
+ if (!methodIs(request, 'POST')) {
401
+ sendJson(response, 405, { error: 'method not allowed' });
402
+ return;
403
+ }
404
+ const body = await readSameOriginPost(request, response);
405
+ if (body === undefined)
406
+ return;
407
+ const hash = typeof body.hash === 'string' && body.hash !== '' ? body.hash : '';
408
+ if (hash === '') {
409
+ sendJson(response, 400, { error: 'hash is required' });
410
+ return;
411
+ }
412
+ const name = await runtime.lookupMediaHash(hash);
413
+ if (name === undefined) {
414
+ sendJson(response, 200, { ok: true, found: false });
415
+ }
416
+ else {
417
+ sendJson(response, 200, { ok: true, found: true, name });
418
+ }
419
+ }),
420
+ }));
421
+ disposers.push(webServer.register({
422
+ kind: 'exact',
423
+ path: '/comfyui/media-hash',
424
+ handler: withHint(async (request, response) => {
425
+ if (!methodIs(request, 'POST')) {
426
+ sendJson(response, 405, { error: 'method not allowed' });
427
+ return;
428
+ }
429
+ const body = await readSameOriginPost(request, response);
430
+ if (body === undefined)
431
+ return;
432
+ const hash = typeof body.hash === 'string' && body.hash !== '' ? body.hash : '';
433
+ const name = typeof body.name === 'string' && body.name !== '' ? body.name : '';
434
+ if (hash === '' || name === '') {
435
+ sendJson(response, 400, { error: 'hash and name are required' });
436
+ return;
437
+ }
438
+ await runtime.saveMediaHash(hash, name);
439
+ sendJson(response, 200, { ok: true });
440
+ }),
441
+ }));
442
+ disposers.push(webServer.register({
443
+ kind: 'exact',
444
+ path: '/comfyui/workflows/delete',
445
+ handler: withHint(async (request, response) => {
446
+ if (!methodIs(request, 'POST')) {
447
+ sendJson(response, 405, { error: 'method not allowed' });
448
+ return;
449
+ }
450
+ const body = await readSameOriginPost(request, response);
451
+ if (body === undefined)
452
+ return;
453
+ const id = typeof body.id === 'string' ? body.id : '';
454
+ if (id === '') {
455
+ sendJson(response, 400, { error: 'id is required' });
456
+ return;
457
+ }
458
+ await runtime.deleteWorkflow(id);
459
+ sendJson(response, 200, { ok: true });
460
+ }),
461
+ }));
462
+ disposers.push(webServer.register({
463
+ kind: 'exact',
464
+ path: '/comfyui/workflows/run',
465
+ handler: withHint(async (request, response) => {
466
+ if (!methodIs(request, 'POST')) {
467
+ sendJson(response, 405, { error: 'method not allowed' });
468
+ return;
469
+ }
470
+ const body = await readSameOriginPost(request, response);
471
+ if (body === undefined)
472
+ return;
473
+ const id = typeof body.id === 'string' ? body.id : '';
474
+ if (id === '') {
475
+ sendJson(response, 400, { error: 'id is required' });
476
+ return;
477
+ }
478
+ const saved = await runtime.getWorkflow(id);
479
+ if (saved === undefined) {
480
+ sendJson(response, 404, { error: `workflow "${id}" not found` });
481
+ return;
482
+ }
483
+ try {
484
+ const values = typeof body.parameters === 'object' && body.parameters !== null
485
+ ? body.parameters
486
+ : {};
487
+ const promptId = await runtime.queue(saved.workflow, {
488
+ workflowName: saved.name,
489
+ workflowId: saved.id,
490
+ source: 'panel',
491
+ parameters: saved.parameters,
492
+ values,
493
+ });
494
+ sendJson(response, 200, { ok: true, promptId, workflowName: saved.name });
495
+ }
496
+ catch (error) {
497
+ sendJson(response, 502, { error: errorMessage(error) });
498
+ }
499
+ }),
500
+ }));
501
+ disposers.push(webServer.register({
502
+ kind: 'exact',
503
+ path: '/comfyui/comfy-workflows',
504
+ handler: withHint(async (request, response) => {
505
+ if (!methodIs(request, 'GET')) {
506
+ sendJson(response, 405, { error: 'method not allowed' });
507
+ return;
508
+ }
509
+ const url = new URL(request.url ?? '/', 'http://localhost');
510
+ const file = url.searchParams.get('file');
511
+ if (file !== null) {
512
+ try {
513
+ const workflow = await runtime.getComfyWorkflow(file);
514
+ sendJson(response, 200, { ok: true, file, workflow });
515
+ }
516
+ catch (error) {
517
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
518
+ }
519
+ return;
520
+ }
521
+ try {
522
+ const workflows = await runtime.listComfyWorkflows();
523
+ sendJson(response, 200, { ok: true, workflows });
524
+ }
525
+ catch (error) {
526
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
527
+ }
528
+ }),
529
+ }));
530
+ disposers.push(webServer.register({
531
+ kind: 'exact',
532
+ path: '/comfyui/comfy-workflows/analyze',
533
+ handler: withHint(async (request, response) => {
534
+ if (!methodIs(request, 'GET')) {
535
+ sendJson(response, 405, { error: 'method not allowed' });
536
+ return;
537
+ }
538
+ const url = new URL(request.url ?? '/', 'http://localhost');
539
+ const file = url.searchParams.get('file');
540
+ if (file === null || file === '') {
541
+ sendJson(response, 400, { error: 'file is required' });
542
+ return;
543
+ }
544
+ try {
545
+ const analysis = await runtime.analyzeComfyWorkflow(file);
546
+ sendJson(response, 200, { ok: true, file, analysis });
547
+ }
548
+ catch (error) {
549
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
550
+ }
551
+ }),
552
+ }));
553
+ disposers.push(webServer.register({
554
+ kind: 'exact',
555
+ path: '/comfyui/comfy-workflows/extract',
556
+ handler: withHint(async (request, response) => {
557
+ if (!methodIs(request, 'POST')) {
558
+ sendJson(response, 405, { error: 'method not allowed' });
559
+ return;
560
+ }
561
+ const body = await readSameOriginPost(request, response);
562
+ if (body === undefined)
563
+ return;
564
+ const file = typeof body.file === 'string' ? body.file : '';
565
+ const mode = body.mode === 'all' || body.mode === 'split' || body.mode === 'main' ? body.mode : undefined;
566
+ if (file === '' || mode === undefined) {
567
+ sendJson(response, 400, { error: 'file and mode (all|split|main) are required' });
568
+ return;
569
+ }
570
+ const result = await runtime.extractComfyWorkflow({ file, mode });
571
+ if (!result.ok) {
572
+ sendJson(response, 422, { error: result.error });
573
+ return;
574
+ }
575
+ sendJson(response, 200, { ok: true, saved: result.saved, analysis: result.analysis, warnings: result.warnings });
576
+ }),
577
+ }));
578
+ disposers.push(webServer.register({
579
+ kind: 'exact',
580
+ path: '/comfyui/assets',
581
+ handler: withHint(async (request, response) => {
582
+ if (!methodIs(request, 'GET')) {
583
+ sendJson(response, 405, { error: 'method not allowed' });
584
+ return;
585
+ }
586
+ // Sweep completed tracked runs into the index before listing, so the
587
+ // panel sees results as soon as the next poll lands.
588
+ await runtime.sweep();
589
+ sendJson(response, 200, { ok: true, assets: await runtime.listAssets() });
590
+ }),
591
+ }));
592
+ disposers.push(webServer.register({
593
+ kind: 'exact',
594
+ path: '/comfyui/queue',
595
+ handler: withHint(async (request, response) => {
596
+ if (!methodIs(request, 'GET')) {
597
+ sendJson(response, 405, { error: 'method not allowed' });
598
+ return;
599
+ }
600
+ try {
601
+ const client = runtime.createClient(await runtime.getApiKey());
602
+ const queue = await client.getQueue();
603
+ const tracked = runtime.trackedRuns();
604
+ const trackedBy = new Map(tracked.map((run) => [run.promptId, run]));
605
+ const mapEntry = (entry) => {
606
+ const ours = trackedBy.get(entry.prompt_id);
607
+ const progress = runtime.queueProgress(entry.prompt_id);
608
+ return {
609
+ promptId: entry.prompt_id,
610
+ ours: ours !== undefined,
611
+ workflowName: ours?.workflowName ?? null,
612
+ progress: progress !== undefined ? { value: progress.value, max: progress.max } : null,
613
+ };
614
+ };
615
+ sendJson(response, 200, {
616
+ ok: true,
617
+ running: (queue.queue_running ?? []).map(mapEntry),
618
+ pending: (queue.queue_pending ?? []).map(mapEntry),
619
+ tracked: tracked.slice(0, 20),
620
+ });
621
+ }
622
+ catch (error) {
623
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
624
+ }
625
+ }),
626
+ }));
627
+ disposers.push(webServer.register({
628
+ kind: 'exact',
629
+ path: '/comfyui/jobs',
630
+ handler: withHint(async (request, response) => {
631
+ if (!methodIs(request, 'GET')) {
632
+ sendJson(response, 405, { error: 'method not allowed' });
633
+ return;
634
+ }
635
+ const url = new URL(request.url ?? '/', 'http://localhost');
636
+ const statusParam = url.searchParams.get('status') ?? 'all';
637
+ const statuses = statusParam === 'all' || statusParam === ''
638
+ ? undefined
639
+ : statusParam.split(',').filter((s) => s === 'pending' || s === 'in_progress' || s === 'completed' || s === 'failed' || s === 'cancelled');
640
+ const parseCount = (raw, fallback) => {
641
+ const n = Number(raw ?? '');
642
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
643
+ };
644
+ try {
645
+ const client = runtime.createClient(await runtime.getApiKey());
646
+ const tracked = runtime.trackedRuns();
647
+ const trackedBy = new Map(tracked.map((run) => [run.promptId, run]));
648
+ // Fallback names from the asset index: survives web-server restarts,
649
+ // which clear the in-memory tracked runs.
650
+ const assets = await runtime.listAssets();
651
+ const assetNameByPrompt = new Map(assets.map((asset) => [asset.promptId, asset.workflowName]));
652
+ const result = await client.getJobs({
653
+ status: statuses,
654
+ limit: parseCount(url.searchParams.get('limit'), 100),
655
+ offset: parseCount(url.searchParams.get('offset'), 0),
656
+ sortBy: 'created_at',
657
+ sortOrder: 'desc',
658
+ });
659
+ const jobs = result.jobs.map((job) => {
660
+ const ours = trackedBy.get(job.id);
661
+ const progress = runtime.queueProgress(job.id);
662
+ return {
663
+ id: job.id,
664
+ status: job.status,
665
+ createTime: job.create_time,
666
+ executionStartTime: job.execution_start_time ?? null,
667
+ executionEndTime: job.execution_end_time ?? null,
668
+ executionError: job.execution_error ?? null,
669
+ outputsCount: job.outputs_count,
670
+ previewOutput: previewThumb(job.preview_output ?? null),
671
+ workflowId: job.workflow_id ?? null,
672
+ workflowName: ours?.workflowName ?? assetNameByPrompt.get(job.id) ?? null,
673
+ ours: ours !== undefined,
674
+ progress: progress !== undefined ? { value: progress.value, max: progress.max } : null,
675
+ };
676
+ });
677
+ sendJson(response, 200, {
678
+ ok: true,
679
+ jobs,
680
+ total: result.pagination.total,
681
+ hasMore: result.pagination.has_more,
682
+ });
683
+ }
684
+ catch (error) {
685
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
686
+ }
687
+ }),
688
+ }));
689
+ disposers.push(webServer.register({
690
+ kind: 'exact',
691
+ path: '/comfyui/jobs/media',
692
+ handler: withHint(async (request, response) => {
693
+ if (!methodIs(request, 'GET')) {
694
+ sendJson(response, 405, { error: 'method not allowed' });
695
+ return;
696
+ }
697
+ const url = new URL(request.url ?? '/', 'http://localhost');
698
+ const promptId = url.searchParams.get('promptId') ?? '';
699
+ if (promptId === '') {
700
+ sendJson(response, 400, { error: 'promptId is required' });
701
+ return;
702
+ }
703
+ try {
704
+ const client = runtime.createClient(await runtime.getApiKey());
705
+ const entry = await client.getHistory(promptId);
706
+ if (entry === undefined) {
707
+ sendJson(response, 200, { ok: true, status: 'unknown' });
708
+ return;
709
+ }
710
+ const statusStr = entry.status?.status_str;
711
+ if (statusStr === 'error') {
712
+ sendJson(response, 200, { ok: true, status: 'failed', error: historyErrorMessage(promptId, entry) });
713
+ return;
714
+ }
715
+ if (statusStr !== 'success' && entry.status?.completed !== true) {
716
+ sendJson(response, 200, { ok: true, status: 'running' });
717
+ return;
718
+ }
719
+ const config = runtime.getConfig();
720
+ const media = collectMedia({ promptId, entry, maxItems: config.maxMediaItems, proxyBase: runtime.proxyBase() });
721
+ sendJson(response, 200, { ok: true, status: 'completed', media });
722
+ }
723
+ catch (error) {
724
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
725
+ }
726
+ }),
727
+ }));
728
+ disposers.push(webServer.register({
729
+ kind: 'exact',
730
+ path: '/comfyui/jobs/actions',
731
+ handler: withHint(async (request, response) => {
732
+ if (!methodIs(request, 'POST')) {
733
+ sendJson(response, 405, { error: 'method not allowed' });
734
+ return;
735
+ }
736
+ const body = await readSameOriginPost(request, response);
737
+ if (body === undefined)
738
+ return;
739
+ const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === 'string') : [];
740
+ try {
741
+ const client = runtime.createClient(await runtime.getApiKey());
742
+ switch (body.action) {
743
+ case 'delete':
744
+ await client.deleteQueueItems(ids);
745
+ break;
746
+ case 'clear':
747
+ await client.clearQueue();
748
+ break;
749
+ case 'interrupt':
750
+ await client.interruptPrompt(typeof body.promptId === 'string' ? body.promptId : undefined);
751
+ break;
752
+ case 'cancel':
753
+ if (typeof body.jobId !== 'string') {
754
+ sendJson(response, 400, { error: 'jobId is required for cancel' });
755
+ return;
756
+ }
757
+ await client.cancelJob(body.jobId);
758
+ break;
759
+ case 'cancelBatch':
760
+ await client.cancelJobs(ids);
761
+ break;
762
+ case 'clearHistory':
763
+ await client.clearHistory();
764
+ break;
765
+ case 'deleteHistory':
766
+ await client.deleteHistory(ids);
767
+ break;
768
+ case 'free':
769
+ await client.freeMemory({ unloadModels: body.unloadModels === true, freeMemory: body.freeMemory === true });
770
+ break;
771
+ case 'rerun': {
772
+ if (typeof body.jobId !== 'string') {
773
+ sendJson(response, 400, { error: 'jobId is required for rerun' });
774
+ return;
775
+ }
776
+ const entry = await client.getHistory(body.jobId);
777
+ const prompt = entry?.prompt;
778
+ if (prompt === undefined) {
779
+ sendJson(response, 404, { error: 'job has no stored workflow to rerun (history may be evicted)' });
780
+ return;
781
+ }
782
+ await runtime.queue(prompt, { workflowName: null, source: 'rerun' });
783
+ break;
784
+ }
785
+ default:
786
+ sendJson(response, 400, { error: `unknown action: ${String(body.action)}` });
787
+ return;
788
+ }
789
+ sendJson(response, 200, { ok: true });
790
+ }
791
+ catch (error) {
792
+ sendJson(response, 200, { ok: false, error: errorMessage(error) });
793
+ }
794
+ }),
795
+ }));
796
+ return () => {
797
+ for (const dispose of disposers)
798
+ dispose();
799
+ };
800
+ }