dsh-comfyui 0.3.0-beta.2 → 0.3.0-beta.4
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.en.md +28 -12
- package/README.md +29 -13
- package/client/client.js +373 -60
- package/client/client.js.map +1 -1
- package/lib/comfyui.d.ts +33 -1
- package/lib/comfyui.js +38 -4
- package/lib/config.d.ts +12 -0
- package/lib/config.js +5 -0
- package/lib/index.js +7 -4
- package/lib/params.d.ts +29 -1
- package/lib/params.js +133 -10
- package/lib/proxy.js +16 -3
- package/lib/routes.js +277 -44
- package/lib/skill.js +5 -5
- package/lib/store.d.ts +15 -3
- package/lib/store.js +48 -12
- package/lib/tools.d.ts +8 -13
- package/lib/tools.js +23 -2
- package/package.json +5 -1
package/lib/routes.js
CHANGED
|
@@ -1,6 +1,104 @@
|
|
|
1
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';
|
|
2
|
+
import { analyzeWorkflowParameters, comboChildInfo, inputOptions, numberSpecOf, uploadKindOf } from './params.js';
|
|
3
|
+
import { collectMedia, historyErrorMessage, mediaProxyUrl } from './comfyui.js';
|
|
4
|
+
import { unlink } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Rebuild an asset's media URLs from the stored file references.
|
|
8
|
+
*
|
|
9
|
+
* Records written before the media proxy addressed files directly carry
|
|
10
|
+
* prompt/node/index URLs, which only resolve while ComfyUI still has that run
|
|
11
|
+
* in its in-memory /history — a server restart or a "clear history" click
|
|
12
|
+
* makes every one of them 404 ("source file evicted"), even though the files
|
|
13
|
+
* are untouched in the output directory. Healing them on read fixes the whole
|
|
14
|
+
* back catalogue without rewriting the stored index.
|
|
15
|
+
*/
|
|
16
|
+
function healAssetUrls(assets) {
|
|
17
|
+
return assets.map((asset) => ({
|
|
18
|
+
...asset,
|
|
19
|
+
media: asset.media.map((item) => (item.filename === '' ? item : { ...item, url: mediaProxyUrl(item) })),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Where ComfyUI writes its outputs on this machine.
|
|
24
|
+
*
|
|
25
|
+
* ComfyUI has no API that deletes an output file (its own asset routes only
|
|
26
|
+
* soft-delete a database row and keep the content), so removing a generated
|
|
27
|
+
* file means touching the filesystem directly. The configured `outputDir`
|
|
28
|
+
* wins; otherwise it is inferred from the absolute path some nodes report
|
|
29
|
+
* with their results (`fullpath`), by stripping the subfolder/filename tail
|
|
30
|
+
* the record already carries. When neither is available — a ComfyUI on
|
|
31
|
+
* another host, for instance — deletion degrades to dropping the index
|
|
32
|
+
* record and says so.
|
|
33
|
+
*/
|
|
34
|
+
function resolveOutputDir(configured, assets) {
|
|
35
|
+
if (configured !== '' && isAbsolute(configured))
|
|
36
|
+
return resolve(configured);
|
|
37
|
+
for (const asset of assets) {
|
|
38
|
+
for (const item of asset.media) {
|
|
39
|
+
const full = item.fullpath;
|
|
40
|
+
if (typeof full !== 'string' || full === '' || item.filename === '')
|
|
41
|
+
continue;
|
|
42
|
+
const tail = item.subfolder !== '' ? `${item.subfolder}/${item.filename}` : item.filename;
|
|
43
|
+
const normalized = full.replace(/\\/g, '/');
|
|
44
|
+
if (!normalized.endsWith(tail))
|
|
45
|
+
continue;
|
|
46
|
+
const root = normalized.slice(0, normalized.length - tail.length).replace(/[/]+$/, '');
|
|
47
|
+
if (root !== '')
|
|
48
|
+
return resolve(root);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/** Delete one generated file under the output directory. Returns 'deleted',
|
|
54
|
+
* 'missing' (already gone — the record still goes), or 'skipped' when the
|
|
55
|
+
* reference does not resolve inside the output directory. */
|
|
56
|
+
async function deleteOutputFile(outputDir, ref) {
|
|
57
|
+
// 'temp' and 'input' live in sibling directories that this mapping does not
|
|
58
|
+
// cover; only outputs are ours to remove.
|
|
59
|
+
if (ref.filename === '' || (ref.type !== '' && ref.type !== 'output'))
|
|
60
|
+
return 'skipped';
|
|
61
|
+
const target = resolve(join(outputDir, ref.subfolder, ref.filename));
|
|
62
|
+
// Path-traversal guard: a crafted subfolder/filename must not reach outside.
|
|
63
|
+
const rel = relative(outputDir, target);
|
|
64
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('..'))
|
|
65
|
+
return 'skipped';
|
|
66
|
+
try {
|
|
67
|
+
await unlink(target);
|
|
68
|
+
return 'deleted';
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error.code === 'ENOENT')
|
|
72
|
+
return 'missing';
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v)$/i;
|
|
77
|
+
const AUDIO_EXT = /\.(mp3|wav|ogg|flac|m4a|aac|opus)$/i;
|
|
78
|
+
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|avif)$/i;
|
|
79
|
+
/** The media kind of a file name, falling back to the loader's own kind when
|
|
80
|
+
* the extension says nothing. */
|
|
81
|
+
function mediaKindOfName(name, fallback) {
|
|
82
|
+
if (VIDEO_EXT.test(name))
|
|
83
|
+
return 'video';
|
|
84
|
+
if (AUDIO_EXT.test(name))
|
|
85
|
+
return 'audio';
|
|
86
|
+
if (IMAGE_EXT.test(name))
|
|
87
|
+
return 'image';
|
|
88
|
+
return fallback;
|
|
89
|
+
}
|
|
90
|
+
/** The file-picker input key of a loader node class, taken from its
|
|
91
|
+
* object_info upload flag (image_upload / video_upload / audio_upload) so a
|
|
92
|
+
* renamed key does not silently empty the load area. */
|
|
93
|
+
function loaderInputKey(objectInfo, classType) {
|
|
94
|
+
const def = objectInfo?.[classType];
|
|
95
|
+
const inputs = { ...def?.input?.required, ...def?.input?.optional };
|
|
96
|
+
for (const key of Object.keys(inputs)) {
|
|
97
|
+
if (uploadKindOf(objectInfo, classType, key) !== undefined)
|
|
98
|
+
return key;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
4
102
|
function generatedUrlOf(assets, name) {
|
|
5
103
|
for (const asset of assets) {
|
|
6
104
|
for (const item of asset.media) {
|
|
@@ -227,7 +325,10 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
227
325
|
? comboChildInfo(objectInfo, classType, inputKey, body.parentValue)
|
|
228
326
|
: undefined;
|
|
229
327
|
const upload = uploadKindOf(objectInfo, classType, inputKey);
|
|
230
|
-
|
|
328
|
+
// Declared INT/FLOAT type: the editor uses it to allow (or round away)
|
|
329
|
+
// decimals instead of assuming every number input is an integer.
|
|
330
|
+
const number = numberSpecOf(objectInfo, classType, inputKey);
|
|
331
|
+
sendJson(response, 200, { ok: true, options: options ?? [], child, upload, number });
|
|
231
332
|
}),
|
|
232
333
|
}));
|
|
233
334
|
disposers.push(webServer.register({
|
|
@@ -240,29 +341,40 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
240
341
|
}
|
|
241
342
|
try {
|
|
242
343
|
const client = runtime.createClient(await runtime.getApiKey());
|
|
243
|
-
const [objectInfo,
|
|
344
|
+
const [objectInfo, slots, assets, sizes] = await Promise.all([
|
|
244
345
|
client.objectInfo().catch(() => undefined),
|
|
245
|
-
runtime.
|
|
246
|
-
runtime.listAssets(),
|
|
346
|
+
runtime.loadSlots(),
|
|
347
|
+
runtime.listAssets().then(healAssetUrls),
|
|
247
348
|
runtime.listMediaSizes(),
|
|
248
349
|
]);
|
|
249
350
|
const files = [];
|
|
250
351
|
const seen = new Set();
|
|
251
|
-
// imported: files visible to the ComfyUI loader nodes (input dir)
|
|
352
|
+
// imported: files visible to the ComfyUI loader nodes (input dir).
|
|
353
|
+
// The input key is read from object_info rather than hard-coded —
|
|
354
|
+
// LoadImage calls it "image", LoadVideo "file", LoadAudio "audio",
|
|
355
|
+
// and guessing wrong silently drops that whole media type from the
|
|
356
|
+
// load area (which is what happened to video).
|
|
252
357
|
const loaderSpecs = [
|
|
253
|
-
['LoadImage', 'image'
|
|
254
|
-
['LoadVideo', 'video'
|
|
255
|
-
['LoadAudio', 'audio'
|
|
358
|
+
['LoadImage', 'image'],
|
|
359
|
+
['LoadVideo', 'video'],
|
|
360
|
+
['LoadAudio', 'audio'],
|
|
256
361
|
];
|
|
257
|
-
for (const [classType,
|
|
362
|
+
for (const [classType, fallbackKind] of loaderSpecs) {
|
|
363
|
+
const inputKey = loaderInputKey(objectInfo, classType);
|
|
364
|
+
if (inputKey === undefined)
|
|
365
|
+
continue;
|
|
258
366
|
for (const option of inputOptions(objectInfo, classType, inputKey) ?? []) {
|
|
259
367
|
const name = String(option);
|
|
260
368
|
if (seen.has(name))
|
|
261
369
|
continue;
|
|
262
370
|
seen.add(name);
|
|
263
371
|
files.push({
|
|
372
|
+
// Classify by extension first: ComfyUI lists an .mp4 under
|
|
373
|
+
// LoadAudio too (it can pull the audio track), and whichever
|
|
374
|
+
// loader happened to be scanned first would otherwise decide
|
|
375
|
+
// the file's type in the picker.
|
|
264
376
|
name,
|
|
265
|
-
kind,
|
|
377
|
+
kind: mediaKindOfName(name, fallbackKind),
|
|
266
378
|
source: 'imported',
|
|
267
379
|
url: `/comfyui/media?file=${encodeURIComponent(name)}&type=input`,
|
|
268
380
|
width: sizes[name]?.width,
|
|
@@ -286,21 +398,26 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
286
398
|
});
|
|
287
399
|
}
|
|
288
400
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
401
|
+
// Every slot in order; `null` entries are empty slots the user added.
|
|
402
|
+
const slotEntries = slots.map((slot) => {
|
|
403
|
+
if (slot === null)
|
|
404
|
+
return null;
|
|
405
|
+
const url = slot.source === 'generated'
|
|
406
|
+
? generatedUrlOf(assets, slot.name) ?? `/comfyui/media?file=${encodeURIComponent(slot.name)}&type=output`
|
|
407
|
+
: `/comfyui/media?file=${encodeURIComponent(slot.name)}&type=input`;
|
|
408
|
+
return {
|
|
409
|
+
name: slot.name,
|
|
410
|
+
kind: slot.kind,
|
|
411
|
+
source: slot.source,
|
|
298
412
|
url,
|
|
299
|
-
width: sizes[
|
|
300
|
-
height: sizes[
|
|
413
|
+
width: sizes[slot.name]?.width,
|
|
414
|
+
height: sizes[slot.name]?.height,
|
|
301
415
|
};
|
|
302
|
-
}
|
|
303
|
-
|
|
416
|
+
});
|
|
417
|
+
// `current` stays in the payload as the first filled slot: it is the
|
|
418
|
+
// primary source image, and older clients read only this field.
|
|
419
|
+
const currentEntry = slotEntries.find((entry) => entry !== null) ?? null;
|
|
420
|
+
sendJson(response, 200, { ok: true, current: currentEntry, slots: slotEntries, files });
|
|
304
421
|
}
|
|
305
422
|
catch (error) {
|
|
306
423
|
sendJson(response, 500, { error: errorMessage(error) });
|
|
@@ -318,27 +435,77 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
318
435
|
const body = await readSameOriginPost(request, response);
|
|
319
436
|
if (body === undefined)
|
|
320
437
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
438
|
+
// Slot operations of the load area. `pick` (the default, and the body
|
|
439
|
+
// shape older clients send) puts a file into a slot; the others manage
|
|
440
|
+
// the slots themselves.
|
|
441
|
+
const action = typeof body.action === 'string' ? body.action : 'pick';
|
|
442
|
+
const slots = await runtime.loadSlots();
|
|
443
|
+
const index = typeof body.index === 'number' && Number.isInteger(body.index) && body.index >= 0
|
|
444
|
+
? body.index
|
|
445
|
+
: undefined;
|
|
328
446
|
try {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
447
|
+
switch (action) {
|
|
448
|
+
case 'addSlot':
|
|
449
|
+
slots.push(null);
|
|
450
|
+
break;
|
|
451
|
+
case 'clear': {
|
|
452
|
+
// "清除素材": the slot stays, its file is dropped.
|
|
453
|
+
if (index === undefined || index >= slots.length) {
|
|
454
|
+
sendJson(response, 400, { error: 'index is required and must point at an existing slot' });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
slots[index] = null;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
case 'removeSlot': {
|
|
461
|
+
// "删除加载区": the slot itself goes away.
|
|
462
|
+
if (index === undefined || index >= slots.length) {
|
|
463
|
+
sendJson(response, 400, { error: 'index is required and must point at an existing slot' });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
slots.splice(index, 1);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
case 'pick': {
|
|
470
|
+
const name = typeof body.name === 'string' ? body.name : '';
|
|
471
|
+
if (name === '') {
|
|
472
|
+
sendJson(response, 400, { error: 'name is required' });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const kind = body.kind === 'video' ? 'video' : body.kind === 'audio' ? 'audio' : 'image';
|
|
476
|
+
const source = body.source === 'generated' ? 'generated' : 'imported';
|
|
477
|
+
// No index: fill the first empty slot, or open a new one. This is
|
|
478
|
+
// what the picker sends when the user adds material to the load
|
|
479
|
+
// area without targeting a particular slot. Resolve and validate
|
|
480
|
+
// the target before copying anything, so a bad index cannot leave
|
|
481
|
+
// a file copied into ComfyUI with no slot to show for it.
|
|
482
|
+
const firstEmpty = slots.findIndex((slot) => slot === null);
|
|
483
|
+
const target = index ?? (firstEmpty === -1 ? slots.length : firstEmpty);
|
|
484
|
+
if (target > slots.length) {
|
|
485
|
+
sendJson(response, 400, { error: 'index is out of range' });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (source === 'generated') {
|
|
489
|
+
// Generated outputs live in ComfyUI's output dir; copy the
|
|
490
|
+
// selected one into the input dir (same name) so loader nodes
|
|
491
|
+
// can use it.
|
|
492
|
+
const assets = await runtime.listAssets();
|
|
493
|
+
const ref = findOutputRef(assets, name);
|
|
494
|
+
if (ref !== undefined) {
|
|
495
|
+
const client = runtime.createClient(await runtime.getApiKey());
|
|
496
|
+
const { bytes, contentType } = await client.fetchView(ref);
|
|
497
|
+
await client.uploadMedia(bytes, name, contentType);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
slots[target] = { name, kind, source };
|
|
501
|
+
break;
|
|
338
502
|
}
|
|
503
|
+
default:
|
|
504
|
+
sendJson(response, 400, { error: `unknown action "${action}"` });
|
|
505
|
+
return;
|
|
339
506
|
}
|
|
340
|
-
await runtime.
|
|
341
|
-
sendJson(response, 200, { ok: true });
|
|
507
|
+
await runtime.saveSlots(slots);
|
|
508
|
+
sendJson(response, 200, { ok: true, slots: slots.length, loaded: slots.filter((slot) => slot !== null).length });
|
|
342
509
|
}
|
|
343
510
|
catch (error) {
|
|
344
511
|
sendJson(response, 500, { error: errorMessage(error) });
|
|
@@ -586,7 +753,73 @@ export function mountComfyUIRoutes(ctx, runtime) {
|
|
|
586
753
|
// Sweep completed tracked runs into the index before listing, so the
|
|
587
754
|
// panel sees results as soon as the next poll lands.
|
|
588
755
|
await runtime.sweep();
|
|
589
|
-
sendJson(response, 200, { ok: true, assets: await runtime.listAssets() });
|
|
756
|
+
sendJson(response, 200, { ok: true, assets: healAssetUrls(await runtime.listAssets()) });
|
|
757
|
+
}),
|
|
758
|
+
}));
|
|
759
|
+
disposers.push(webServer.register({
|
|
760
|
+
kind: 'exact',
|
|
761
|
+
path: '/comfyui/assets/delete',
|
|
762
|
+
handler: withHint(async (request, response) => {
|
|
763
|
+
if (!methodIs(request, 'POST')) {
|
|
764
|
+
sendJson(response, 405, { error: 'method not allowed' });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const body = await readSameOriginPost(request, response);
|
|
768
|
+
if (body === undefined)
|
|
769
|
+
return;
|
|
770
|
+
const promptId = typeof body.promptId === 'string' ? body.promptId : '';
|
|
771
|
+
if (promptId === '') {
|
|
772
|
+
sendJson(response, 400, { error: 'promptId is required' });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
try {
|
|
776
|
+
// Resolve the output directory from the index *before* the record is
|
|
777
|
+
// removed: the path hint may live on the very record being deleted.
|
|
778
|
+
const assets = await runtime.listAssets();
|
|
779
|
+
const outputDir = resolveOutputDir(runtime.getConfig().outputDir, assets);
|
|
780
|
+
const removed = await runtime.deleteAsset(promptId);
|
|
781
|
+
if (removed === undefined) {
|
|
782
|
+
sendJson(response, 404, { error: `asset ${promptId} not found` });
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
let deleted = 0;
|
|
786
|
+
let missing = 0;
|
|
787
|
+
let skipped = 0;
|
|
788
|
+
const failures = [];
|
|
789
|
+
if (outputDir !== undefined) {
|
|
790
|
+
for (const item of removed.media) {
|
|
791
|
+
try {
|
|
792
|
+
const result = await deleteOutputFile(outputDir, { filename: item.filename, subfolder: item.subfolder, type: item.type });
|
|
793
|
+
if (result === 'deleted')
|
|
794
|
+
deleted += 1;
|
|
795
|
+
else if (result === 'missing')
|
|
796
|
+
missing += 1;
|
|
797
|
+
else
|
|
798
|
+
skipped += 1;
|
|
799
|
+
}
|
|
800
|
+
catch (error) {
|
|
801
|
+
// The record is already gone; report which files survived it.
|
|
802
|
+
failures.push(`${item.filename}: ${errorMessage(error)}`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
skipped = removed.media.length;
|
|
808
|
+
}
|
|
809
|
+
sendJson(response, 200, {
|
|
810
|
+
ok: true,
|
|
811
|
+
promptId,
|
|
812
|
+
files: removed.media.length,
|
|
813
|
+
deleted,
|
|
814
|
+
missing,
|
|
815
|
+
skipped,
|
|
816
|
+
outputDir: outputDir ?? null,
|
|
817
|
+
failures,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
sendJson(response, 500, { error: errorMessage(error) });
|
|
822
|
+
}
|
|
590
823
|
}),
|
|
591
824
|
}));
|
|
592
825
|
disposers.push(webServer.register({
|
package/lib/skill.js
CHANGED
|
@@ -41,15 +41,15 @@ comfyui_workflow 的 \`action: list\` 会同时返回:插件库中的 API 工
|
|
|
41
41
|
## 可调参数(运行传参)
|
|
42
42
|
|
|
43
43
|
- 每个运行工作流带一组**可调参数**:提取时自动识别 提示词(文本输入)、分辨率(EmptyLatentImage 宽高)、步数(KSampler.steps)、种子(KSampler.seed,默认每次随机)、时长(duration/length/frames)、宽高比(aspect_ratio)、加载节点(LoadImage/LoadVideo/LoadAudio 的图片/视频/音频文件,值填服务器上的文件名,用户在面板可拖拽上传)。识别是**通用规则**(对象信息里的 upload 标记/文件列表),不针对具体节点;像 MiniMaxH3 综合加载(media_state JSON)这类通用规则识别不到的输入,让用户在面板"高级参数"里手动添加——值若形如媒体 JSON 数组,会自动拆成 \`media_1/media_2/...\` 参考位(值填子目录文件名,空字符串 = 移除该参考位)。cfg/denoise 与模型选择不在其中,保持工作流原样。用户可在面板"编辑工作流"里改默认值/随机开关,或添加高级参数(任意节点输入)。
|
|
44
|
-
- \`action: list\` 输出每个工作流的 \`parameters\` 字段就是结构化参数清单(\`name\` 是英文参数名、\`label\` 是中文含义、\`default\` 是默认值、\`random\` 表示是否每次随机、\`options\` 是下拉选项列表、\`upload\` 表示加载节点上传类型),用之前先看它。带 \`options\` 的参数必须从选项里取值(加载节点可从文件列表选,也可提示用户拖拽上传新文件)。
|
|
44
|
+
- \`action: list\` 输出每个工作流的 \`parameters\` 字段就是结构化参数清单(\`name\` 是英文参数名、\`label\` 是中文含义、\`default\` 是默认值、\`random\` 表示是否每次随机、\`numberKind\` 是数字参数的类型(\`int\` 只接受整数,传小数会四舍五入;\`float\` 可传小数如 cfg 7.5、denoise 0.6;没有该字段表示类型未知,按小数传即可)、\`options\` 是下拉选项列表、\`upload\` 表示加载节点上传类型),用之前先看它。带 \`options\` 的参数必须从选项里取值(加载节点可从文件列表选,也可提示用户拖拽上传新文件)。
|
|
45
45
|
- 运行时可传 \`parameters\` 覆盖:\`{"prompt": "新提示词", "seed": 42, "width": 1024}\`。显式传值优先于默认;seed 类参数不传时按工作流设置随机(每次不同)。**参数名必须用清单里的英文 \`name\`**(prompt/seed/width/height/steps/duration/aspect_ratio),不要自造;带 \`options\` 的参数传值必须落在选项内,否则会报错。\`aspect_ratio\` 这类联动参数:只传它时 \`size\` 会自动跟随新比例的默认尺寸(如 9:16 → 768×1376),也可显式传 \`size\` 覆盖。
|
|
46
46
|
|
|
47
47
|
## 加载区与上传(给用户)
|
|
48
48
|
|
|
49
|
-
- 工作流库页面(运行主题)**最底部**有一个"**加载区**"(类似 ComfyUI 的 LoadImage
|
|
50
|
-
- **已导入** = ComfyUI input
|
|
51
|
-
- 粘贴/上传新图 → 自动上传 →
|
|
52
|
-
-
|
|
49
|
+
- 工作流库页面(运行主题)**最底部**有一个"**加载区**"(类似 ComfyUI 的 LoadImage 节点):由若干**加载位**组成(只有一个时铺满面板,多个时等宽排列),点击任一加载位打开选择窗口——顶部"全部 / 已导入 / 已生成"导航条 + 右侧粘贴/点击上传区,下面是瀑布流列表。用户可用"添加加载区"增加加载位,鼠标悬停加载位右上角的 × 删除该位,选择窗口第一格的"空"用于清空该位但保留它。
|
|
50
|
+
- **已导入** = ComfyUI input 目录里可被加载节点使用的图像/视频/音频;**已生成** = 之前工作流产出的结果(output,选中时自动复制进 input);**全部** = 两者合并。选择窗口里视频与音频可直接播放试听。
|
|
51
|
+
- 粘贴/上传新图 → 自动上传 → 列表刷新出现该图;点击文件名 → 放入该加载位并关闭窗口(像素尺寸在**上传时**自动记录并显示在大图下方;历史旧图若无记录则不显示尺寸、也不参与尺寸匹配)。
|
|
52
|
+
- **默认源素材**:加载区里已放入素材的加载位会按顺序填进工作流里**未显式传值**的加载参数——第 1 个加载位 → 第 1 个图片参数,第 2 个 → 第 2 个,视频/音频参数各自取同类型的加载位;显式传值永远优先,没有对应加载位的参数保持工作流原值。\`comfyui_workflow action: list\` 的输出里有 \`loadArea\`(\`slots\` 加载位数、\`loaded\` 已放入素材数、\`items\` 文件清单),**要知道用户加载了几个素材就看它**,不要反复问用户。聊天消息里出现 \`[已上传图片: 文件名]\` 标记同理:文件已由"贴图"按钮上传到 ComfyUI 的 input 目录,直接可用;文件名带 \`subfolder/\` 前缀的按原样传。
|
|
53
53
|
- **尺寸自动匹配**:面板上传/加载时插件会记录图片像素尺寸。图生图 run 时如果**没显式传 width/height**,插件自动用源图分辨率作为输出尺寸(显式传值优先)。加载区大图下方会显示选中图的尺寸(如 \`1024×768\`)。
|
|
54
54
|
- **命名与去重**:上传文件按内容哈希命名(\`原名_短哈希.ext\`,如 \`image_3f9a2b1c0d.png\`),同一张图重复上传会直接复用已有文件(不会产生 \`image(1).png\` 之类的重复文件)。
|
|
55
55
|
- 选中"已生成"的图后,插件会自动把它从 output 复制到 input 目录(同名),保证加载节点可用。
|
package/lib/store.d.ts
CHANGED
|
@@ -38,6 +38,8 @@ export interface AssetRecord {
|
|
|
38
38
|
source: string;
|
|
39
39
|
media: AssetMediaRef[];
|
|
40
40
|
}
|
|
41
|
+
/** One load-area slot: a picked file, or `null` for an empty slot. */
|
|
42
|
+
export type LoadSlot = CurrentImage | null;
|
|
41
43
|
/** The load-area selection: the image the user picked for image-to-image.
|
|
42
44
|
* `name` is always a file name ComfyUI can load (generated outputs are copied
|
|
43
45
|
* into the input dir on selection). */
|
|
@@ -85,9 +87,16 @@ export declare class ComfyUIStore {
|
|
|
85
87
|
loadMediaHashes(): Promise<Record<string, string>>;
|
|
86
88
|
lookupMediaHash(hash: string): Promise<string | undefined>;
|
|
87
89
|
saveMediaHash(hash: string, name: string): Promise<void>;
|
|
88
|
-
/** The load-area
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
/** The load-area slots: an ordered list where each entry is either a picked
|
|
91
|
+
* file or `null` for an empty slot the user added but has not filled yet.
|
|
92
|
+
* Slot 0 is the primary source (the big preview, and the default source
|
|
93
|
+
* image for image-to-image).
|
|
94
|
+
*
|
|
95
|
+
* The file held a single `{name,kind,source}` object before the load area
|
|
96
|
+
* had more than one slot; that shape is still read and lifted into a
|
|
97
|
+
* one-slot list, so an existing selection survives the upgrade. */
|
|
98
|
+
loadSlots(): Promise<LoadSlot[]>;
|
|
99
|
+
saveSlots(slots: LoadSlot[]): Promise<void>;
|
|
91
100
|
listWorkflows(): Promise<StoredWorkflow[]>;
|
|
92
101
|
getWorkflow(id: string): Promise<StoredWorkflow | undefined>;
|
|
93
102
|
private writeWorkflows;
|
|
@@ -112,6 +121,9 @@ export declare class ComfyUIStore {
|
|
|
112
121
|
deleteWorkflow(id: string): Promise<boolean>;
|
|
113
122
|
listAssets(): Promise<AssetRecord[]>;
|
|
114
123
|
/** Prepend an asset record (deduplicated by promptId, capped at maxAssets). */
|
|
124
|
+
/** Drop one asset record from the index; returns the record that was
|
|
125
|
+
* removed so the caller can delete the files it referenced. */
|
|
126
|
+
deleteAsset(promptId: string): Promise<AssetRecord | undefined>;
|
|
115
127
|
appendAsset(record: AssetRecord): Promise<void>;
|
|
116
128
|
/** Load the persisted queue-tracker state (empty when absent/corrupt). */
|
|
117
129
|
loadTracked(): Promise<TrackedState>;
|
package/lib/store.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
9
9
|
import { dirname, join } from 'node:path';
|
|
10
|
+
/** Trailing newline for the JSON files this store writes. */
|
|
11
|
+
const NEWLINE = '\n';
|
|
10
12
|
/** Validate a workflow-shaped value; returns an error message or undefined. */
|
|
11
13
|
export function validateWorkflow(value) {
|
|
12
14
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -87,21 +89,44 @@ export class ComfyUIStore {
|
|
|
87
89
|
hashes[hash] = name;
|
|
88
90
|
await writeFile(this.mediaHashesPath, `${JSON.stringify(hashes, null, 2)}\n`, 'utf8');
|
|
89
91
|
}
|
|
90
|
-
/** The load-area
|
|
91
|
-
|
|
92
|
+
/** The load-area slots: an ordered list where each entry is either a picked
|
|
93
|
+
* file or `null` for an empty slot the user added but has not filled yet.
|
|
94
|
+
* Slot 0 is the primary source (the big preview, and the default source
|
|
95
|
+
* image for image-to-image).
|
|
96
|
+
*
|
|
97
|
+
* The file held a single `{name,kind,source}` object before the load area
|
|
98
|
+
* had more than one slot; that shape is still read and lifted into a
|
|
99
|
+
* one-slot list, so an existing selection survives the upgrade. */
|
|
100
|
+
async loadSlots() {
|
|
92
101
|
const parsed = await readJsonFile(this.currentImagePath, undefined);
|
|
93
102
|
if (typeof parsed !== 'object' || parsed === null)
|
|
94
|
-
return
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
return [];
|
|
104
|
+
const raw = Array.isArray(parsed.items)
|
|
105
|
+
? parsed.items
|
|
106
|
+
: [parsed];
|
|
107
|
+
const slots = [];
|
|
108
|
+
for (const entry of raw) {
|
|
109
|
+
if (entry === null) {
|
|
110
|
+
slots.push(null);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (typeof entry !== 'object')
|
|
114
|
+
continue;
|
|
115
|
+
const { name, kind, source } = entry;
|
|
116
|
+
if (typeof name !== 'string' || name === '') {
|
|
117
|
+
slots.push(null);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
slots.push({
|
|
121
|
+
name,
|
|
122
|
+
kind: kind === 'video' || kind === 'audio' ? kind : 'image',
|
|
123
|
+
source: source === 'generated' ? 'generated' : 'imported',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return slots;
|
|
102
127
|
}
|
|
103
|
-
async
|
|
104
|
-
await writeFile(this.currentImagePath,
|
|
128
|
+
async saveSlots(slots) {
|
|
129
|
+
await writeFile(this.currentImagePath, JSON.stringify({ items: slots }, null, 2) + NEWLINE, 'utf8');
|
|
105
130
|
}
|
|
106
131
|
async listWorkflows() {
|
|
107
132
|
const list = await readJsonFile(this.workflowsPath, []);
|
|
@@ -169,6 +194,17 @@ export class ComfyUIStore {
|
|
|
169
194
|
return Array.isArray(list) ? list : [];
|
|
170
195
|
}
|
|
171
196
|
/** Prepend an asset record (deduplicated by promptId, capped at maxAssets). */
|
|
197
|
+
/** Drop one asset record from the index; returns the record that was
|
|
198
|
+
* removed so the caller can delete the files it referenced. */
|
|
199
|
+
async deleteAsset(promptId) {
|
|
200
|
+
const list = await this.listAssets();
|
|
201
|
+
const index = list.findIndex((record) => record.promptId === promptId);
|
|
202
|
+
if (index === -1)
|
|
203
|
+
return undefined;
|
|
204
|
+
const [removed] = list.splice(index, 1);
|
|
205
|
+
await writeFile(this.assetsPath, JSON.stringify(list, null, 2) + NEWLINE, 'utf8');
|
|
206
|
+
return removed;
|
|
207
|
+
}
|
|
172
208
|
async appendAsset(record) {
|
|
173
209
|
const list = await this.listAssets();
|
|
174
210
|
if (list.some((entry) => entry.promptId === record.promptId))
|
package/lib/tools.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import type { Config } from './config.js';
|
|
10
10
|
import { ComfyUIClient } from './comfyui.js';
|
|
11
|
-
import type { AssetRecord, StoredWorkflow } from './store.js';
|
|
11
|
+
import type { AssetRecord, LoadSlot, StoredWorkflow } from './store.js';
|
|
12
12
|
import type { GraphAnalysis } from './analyze.js';
|
|
13
13
|
import type { RunProgress } from './progress.js';
|
|
14
14
|
import type { QueuedRun } from './queue.js';
|
|
@@ -96,20 +96,15 @@ export interface ComfyUIRuntime {
|
|
|
96
96
|
lookupMediaHash(hash: string): Promise<string | undefined>;
|
|
97
97
|
/** Record a content hash → file name pair for dedup. */
|
|
98
98
|
saveMediaHash(hash: string, name: string): Promise<void>;
|
|
99
|
-
/** The load-area
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
} | undefined>;
|
|
105
|
-
/** Persist the load-area selection. */
|
|
106
|
-
saveCurrentImage(image: {
|
|
107
|
-
name: string;
|
|
108
|
-
kind: 'image' | 'video' | 'audio';
|
|
109
|
-
source: 'imported' | 'generated';
|
|
110
|
-
}): Promise<void>;
|
|
99
|
+
/** The load-area slots, in order; `null` is an empty slot the user added.
|
|
100
|
+
* Filled slots are the default source media for loader parameters. */
|
|
101
|
+
loadSlots(): Promise<Array<LoadSlot>>;
|
|
102
|
+
/** Persist the load-area slots. */
|
|
103
|
+
saveSlots(slots: Array<LoadSlot>): Promise<void>;
|
|
111
104
|
/** The asset index (newest first). */
|
|
112
105
|
listAssets(): Promise<AssetRecord[]>;
|
|
106
|
+
/** Remove one asset record from the index, returning what was removed. */
|
|
107
|
+
deleteAsset(promptId: string): Promise<AssetRecord | undefined>;
|
|
113
108
|
/** Move completed tracked runs into the asset index. */
|
|
114
109
|
sweep(): Promise<AssetRecord[]>;
|
|
115
110
|
/** Workflows the user saved on the ComfyUI server, with extract status. */
|
package/lib/tools.js
CHANGED
|
@@ -338,6 +338,13 @@ function workflowDefinition(runtime, ctx) {
|
|
|
338
338
|
lines.push(` ${param.name}(${param.label}${param.random === true ? ',随机' : ''},默认 ${def}${options}${upload})`);
|
|
339
339
|
}
|
|
340
340
|
}
|
|
341
|
+
const loadArea = data.loadArea;
|
|
342
|
+
if (loadArea !== undefined && loadArea.slots > 0) {
|
|
343
|
+
lines.push(`用户加载区(${loadArea.slots} 个加载位,已放入 ${loadArea.loaded} 个素材,未显式传值的加载参数按顺序取用):`);
|
|
344
|
+
for (const [index, item] of loadArea.items.entries()) {
|
|
345
|
+
lines.push(` ${index + 1}. ${item.name}(${item.kind})`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
341
348
|
const comfyui = data.comfyuiWorkflows ?? [];
|
|
342
349
|
if (comfyui.length > 0) {
|
|
343
350
|
lines.push(`ComfyUI 端保存的图工作流(${comfyui.length} 个,UI 图格式,不能直接运行):`);
|
|
@@ -371,22 +378,36 @@ function workflowDefinition(runtime, ctx) {
|
|
|
371
378
|
throw new Error(`comfyui_workflow: action must be list, run, or get, got ${String(action)}`);
|
|
372
379
|
}
|
|
373
380
|
if (action === 'list') {
|
|
374
|
-
const [workflows, comfyui] = await Promise.all([
|
|
381
|
+
const [workflows, comfyui, slots] = await Promise.all([
|
|
375
382
|
runtime.listWorkflows(),
|
|
376
383
|
runtime.listComfyWorkflows().catch(() => []),
|
|
384
|
+
runtime.loadSlots().catch(() => []),
|
|
377
385
|
]);
|
|
386
|
+
// What the user currently has loaded in the panel's load area. Unset
|
|
387
|
+
// loader parameters take these files in slot order, so the model can
|
|
388
|
+
// see how many references a run will pick up without asking.
|
|
389
|
+
const loaded = slots.filter((slot) => slot !== null);
|
|
378
390
|
return {
|
|
379
391
|
action: 'list',
|
|
392
|
+
loadArea: {
|
|
393
|
+
slots: slots.length,
|
|
394
|
+
loaded: loaded.length,
|
|
395
|
+
items: loaded.map(({ name, kind, source }) => ({ name, kind, source })),
|
|
396
|
+
},
|
|
380
397
|
workflows: workflows.map(({ id, name, description, parameters, updatedAt }) => ({
|
|
381
398
|
id,
|
|
382
399
|
name,
|
|
383
400
|
description,
|
|
384
|
-
parameters: (parameters ?? []).map(({ name: pname, label, type, default: def, random, options, upload }) => {
|
|
401
|
+
parameters: (parameters ?? []).map(({ name: pname, label, type, default: def, random, numberKind, options, upload }) => {
|
|
385
402
|
// DSH validates tool output as lossless JSON: JSON.stringify drops
|
|
386
403
|
// undefined keys, so omit optional fields instead of passing undefined.
|
|
387
404
|
const entry = { name: pname, label, type, default: def };
|
|
388
405
|
if (random !== undefined)
|
|
389
406
|
entry.random = random;
|
|
407
|
+
// Tells the model whether decimals are accepted; absent means the
|
|
408
|
+
// node's declared type is unknown and a float is safe.
|
|
409
|
+
if (numberKind !== undefined)
|
|
410
|
+
entry.numberKind = numberKind;
|
|
390
411
|
if (options !== undefined)
|
|
391
412
|
entry.options = options;
|
|
392
413
|
if (upload !== undefined)
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-comfyui",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.4",
|
|
4
4
|
"description": "Drive ComfyUI from DeepSeek Harness: generate and process images and videos through agent tools, with a workflow/asset/queue panel, in-chat results and a settings page. / 让 DeepSeek Harness 的 Agent 直接驱动 ComfyUI 生成与处理图像、视频,带工作流/资产/队列面板。",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/fandc520/dsh-comfyui.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "module",
|
|
6
10
|
"main": "lib/index.js",
|
|
7
11
|
"types": "lib/index.d.ts",
|