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/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
- sendJson(response, 200, { ok: true, options: options ?? [], child, upload });
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, current, assets, sizes] = await Promise.all([
344
+ const [objectInfo, slots, assets, sizes] = await Promise.all([
244
345
  client.objectInfo().catch(() => undefined),
245
- runtime.loadCurrentImage(),
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', 'image'],
254
- ['LoadVideo', 'video', 'video'],
255
- ['LoadAudio', 'audio', 'audio'],
358
+ ['LoadImage', 'image'],
359
+ ['LoadVideo', 'video'],
360
+ ['LoadAudio', 'audio'],
256
361
  ];
257
- for (const [classType, inputKey, kind] of loaderSpecs) {
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
- 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,
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[current.name]?.width,
300
- height: sizes[current.name]?.height,
413
+ width: sizes[slot.name]?.width,
414
+ height: sizes[slot.name]?.height,
301
415
  };
302
- }
303
- sendJson(response, 200, { ok: true, current: currentEntry, files });
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
- 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
- }
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
- 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);
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.saveCurrentImage({ name, kind, source });
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 目录里的文件;**已生成** = 之前工作流产出的图像(output);**全部** = 两者合并。
51
- - 粘贴/上传新图 → 自动上传 → 列表刷新出现该图;点击任意图像 → 选定并关闭窗口,加载区大图更新为该图(像素尺寸在**上传时**自动记录并显示在大图下方;历史旧图若无记录则不显示尺寸、也不参与尺寸匹配)。
52
- - **默认源图**:加载区选中的图像会成为图生图的**默认源图**——你 run 含 \`image\`(LoadImage)参数的工作流时,如果**没显式传 image**,插件自动用加载区选中的文件名(显式传 \`image\` 则优先)。聊天消息里出现 \`[已上传图片: 文件名]\` 标记同理:文件已由"贴图"按钮上传到 ComfyUI 的 input 目录,直接可用;文件名带 \`subfolder/\` 前缀的按原样传。
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 selection (survives restarts so image-to-image keeps its default source image). */
89
- loadCurrentImage(): Promise<CurrentImage | undefined>;
90
- saveCurrentImage(image: CurrentImage): Promise<void>;
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 selection (survives restarts so image-to-image keeps its default source image). */
91
- async loadCurrentImage() {
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 undefined;
95
- if (typeof parsed.name !== 'string' || parsed.name === '')
96
- return undefined;
97
- return {
98
- name: parsed.name,
99
- kind: parsed.kind === 'video' || parsed.kind === 'audio' ? parsed.kind : 'image',
100
- source: parsed.source === 'generated' ? 'generated' : 'imported',
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 saveCurrentImage(image) {
104
- await writeFile(this.currentImagePath, `${JSON.stringify(image, null, 2)}\n`, 'utf8');
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 selection (default source image for image-to-image). */
100
- loadCurrentImage(): Promise<{
101
- name: string;
102
- kind: 'image' | 'video' | 'audio';
103
- source: 'imported' | 'generated';
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.2",
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",