@goodandready/dsh-image-gen 0.11.38 → 0.11.39

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/client.js CHANGED
@@ -396,6 +396,7 @@ window.__ModuleLoader__.load({
396
396
  // Backward-compat aliases for tests
397
397
  { field: 'localComfyUrl', spec: textField('localComfyUrl'), kind: 'text', labelKey: 'f.localComfyUrl', hintKey: 'f.localComfyUrlHint', placeholderKey: 'p.localComfyUrl', when: ['local'], tab: 'provider' },
398
398
  { field: 'localA1111Url', spec: textField('localA1111Url'), kind: 'text', labelKey: 'f.localA1111Url', hintKey: 'f.localA1111UrlHint', placeholderKey: 'p.localA1111Url', when: ['local'], tab: 'provider' },
399
+ { field: 'comfyTemplate', spec: selectField('comfyTemplate', ['', 'txt2img_sdxl', 'img2img_sdxl', 'face_instantid', 'upscale_esrgan']), kind: 'select', options: ['', 'txt2img_sdxl', 'img2img_sdxl', 'face_instantid', 'upscale_esrgan'], labelKey: 'f.comfyTemplate', hintKey: 'f.comfyTemplateHint', when: ['local'], tab: 'provider' },
399
400
  { field: 'comfyWorkflowJson', spec: jsonField('comfyWorkflowJson'), kind: 'text', labelKey: 'f.comfyWorkflowJson', hintKey: 'f.comfyWorkflowJsonHint', placeholderKey: 'p.comfyWorkflowJson', when: ['local'], tab: 'provider' },
400
401
 
401
402
  // Provider: Codex / Grok
@@ -2875,6 +2876,8 @@ window.__ModuleLoader__.load({
2875
2876
  'vault.search': 'Search prompt or tags...',
2876
2877
  'vault.seed': 'Seed',
2877
2878
  'vault.title': 'Asset Browser & Vault',
2879
+ 'f.comfyTemplate': 'ComfyUI Workflow Template',
2880
+ 'f.comfyTemplateHint': 'Pre-vetted workflow template (e.g. SDXL txt2img, img2img, InstantID face transfer, RealESRGAN upscaler).',
2878
2881
  }
2879
2882
  const zh = {
2880
2883
  'card.actionRemoveBg': '移除背景',
@@ -3182,6 +3185,8 @@ window.__ModuleLoader__.load({
3182
3185
  'vault.search': '搜索提示词或标签...',
3183
3186
  'vault.seed': '种子',
3184
3187
  'vault.title': '图像素材库与浏览器',
3188
+ 'f.comfyTemplate': 'ComfyUI 工作流模板',
3189
+ 'f.comfyTemplateHint': '经过验证的预设工作流模板(例如 SDXL 文生图、图生图、InstantID 人脸迁移、RealESRGAN 放大器)。',
3185
3190
  }
3186
3191
  // Dual compatibility with DSH 0.1.5-rc.3 (settingsScope) and 0.1.6+ (configForms) (#291, #295)
3187
3192
  const inject = ['slots', 'locale', 'sessions']
@@ -0,0 +1,433 @@
1
+ // lib/comfy-catalog.js — Vetted ComfyUI workflow catalog with dependency validation (#433)
2
+ import { interpolateComfyWorkflow, validateComfyWorkflow } from './comfy-workflow-helpers.js'
3
+
4
+ /**
5
+ * Catalog of vetted ComfyUI workflow templates.
6
+ * Based on official Comfy-Org workflow templates under Apache-2.0 license.
7
+ */
8
+ export const COMFY_TEMPLATES = [
9
+ {
10
+ id: 'txt2img_sdxl',
11
+ name: 'SDXL Text to Image',
12
+ description: 'High-quality direct text-to-image synthesis using SDXL Base architecture.',
13
+ version: '1.0.0',
14
+ source: 'Comfy-Org/workflow_templates',
15
+ license: 'Apache-2.0',
16
+ category: 'generation',
17
+ requiredNodes: [
18
+ 'KSampler',
19
+ 'CheckpointLoaderSimple',
20
+ 'EmptyLatentImage',
21
+ 'CLIPTextEncode',
22
+ 'VAEDecode',
23
+ 'SaveImage',
24
+ ],
25
+ requiredModels: [
26
+ 'sd_xl_base_1.0.safetensors',
27
+ ],
28
+ inputs: [
29
+ { name: 'prompt', type: 'string', required: true, description: 'Positive prompt description' },
30
+ { name: 'negativePrompt', type: 'string', default: 'blurry, low quality, artifacts', description: 'Negative prompt' },
31
+ { name: 'width', type: 'number', default: 1024, description: 'Image width in pixels' },
32
+ { name: 'height', type: 'number', default: 1024, description: 'Image height in pixels' },
33
+ { name: 'steps', type: 'number', default: 25, description: 'Sampling steps' },
34
+ { name: 'cfg', type: 'number', default: 7.0, description: 'Classifier-free guidance scale' },
35
+ { name: 'seed', type: 'number', default: 0, description: 'RNG seed' },
36
+ ],
37
+ graph: {
38
+ '3': {
39
+ class_type: 'KSampler',
40
+ inputs: {
41
+ seed: '{{seed}}',
42
+ steps: '{{steps}}',
43
+ cfg: '{{cfg}}',
44
+ sampler_name: 'euler',
45
+ scheduler: 'normal',
46
+ denoise: 1,
47
+ model: ['4', 0],
48
+ positive: ['6', 0],
49
+ negative: ['7', 0],
50
+ latent_image: ['5', 0],
51
+ },
52
+ },
53
+ '4': {
54
+ class_type: 'CheckpointLoaderSimple',
55
+ inputs: { ckpt_name: '{{model}}' },
56
+ },
57
+ '5': {
58
+ class_type: 'EmptyLatentImage',
59
+ inputs: { width: '{{width}}', height: '{{height}}', batch_size: 1 },
60
+ },
61
+ '6': {
62
+ class_type: 'CLIPTextEncode',
63
+ inputs: { text: '{{prompt}}', clip: ['4', 1] },
64
+ },
65
+ '7': {
66
+ class_type: 'CLIPTextEncode',
67
+ inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
68
+ },
69
+ '8': {
70
+ class_type: 'VAEDecode',
71
+ inputs: { samples: ['3', 0], vae: ['4', 2] },
72
+ },
73
+ '9': {
74
+ class_type: 'SaveImage',
75
+ inputs: { filename_prefix: 'dsh_sdxl', images: ['8', 0] },
76
+ },
77
+ },
78
+ },
79
+ {
80
+ id: 'img2img_sdxl',
81
+ name: 'SDXL Image to Image Refinement',
82
+ description: 'Refines and restyles existing images with guided prompt and adjustable denoise strength.',
83
+ version: '1.0.0',
84
+ source: 'Comfy-Org/workflow_templates',
85
+ license: 'Apache-2.0',
86
+ category: 'editing',
87
+ requiredNodes: [
88
+ 'KSampler',
89
+ 'CheckpointLoaderSimple',
90
+ 'LoadImage',
91
+ 'VAEEncode',
92
+ 'CLIPTextEncode',
93
+ 'VAEDecode',
94
+ 'SaveImage',
95
+ ],
96
+ requiredModels: [
97
+ 'sd_xl_base_1.0.safetensors',
98
+ ],
99
+ inputs: [
100
+ { name: 'image', type: 'string', required: true, description: 'Base image path or base64' },
101
+ { name: 'prompt', type: 'string', required: true, description: 'Refinement guidance prompt' },
102
+ { name: 'negativePrompt', type: 'string', default: 'low quality, artifacts' },
103
+ { name: 'denoise', type: 'number', default: 0.65, description: 'Denoising transformation strength' },
104
+ { name: 'steps', type: 'number', default: 25 },
105
+ { name: 'cfg', type: 'number', default: 7.0 },
106
+ { name: 'seed', type: 'number', default: 0 },
107
+ ],
108
+ graph: {
109
+ '1': {
110
+ class_type: 'LoadImage',
111
+ inputs: { image: '{{image}}' },
112
+ },
113
+ '2': {
114
+ class_type: 'VAEEncode',
115
+ inputs: { pixels: ['1', 0], vae: ['4', 2] },
116
+ },
117
+ '3': {
118
+ class_type: 'KSampler',
119
+ inputs: {
120
+ seed: '{{seed}}',
121
+ steps: '{{steps}}',
122
+ cfg: '{{cfg}}',
123
+ sampler_name: 'euler',
124
+ scheduler: 'normal',
125
+ denoise: '{{denoise}}',
126
+ model: ['4', 0],
127
+ positive: ['6', 0],
128
+ negative: ['7', 0],
129
+ latent_image: ['2', 0],
130
+ },
131
+ },
132
+ '4': {
133
+ class_type: 'CheckpointLoaderSimple',
134
+ inputs: { ckpt_name: '{{model}}' },
135
+ },
136
+ '6': {
137
+ class_type: 'CLIPTextEncode',
138
+ inputs: { text: '{{prompt}}', clip: ['4', 1] },
139
+ },
140
+ '7': {
141
+ class_type: 'CLIPTextEncode',
142
+ inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
143
+ },
144
+ '8': {
145
+ class_type: 'VAEDecode',
146
+ inputs: { samples: ['3', 0], vae: ['4', 2] },
147
+ },
148
+ '9': {
149
+ class_type: 'SaveImage',
150
+ inputs: { filename_prefix: 'dsh_img2img', images: ['8', 0] },
151
+ },
152
+ },
153
+ },
154
+ {
155
+ id: 'face_instantid',
156
+ name: 'Facial Identity InstantID Transfer',
157
+ description: 'Transfers facial identity and likeness from reference photo onto synthesized character.',
158
+ version: '1.0.0',
159
+ source: 'InstantID/ComfyUI',
160
+ license: 'Apache-2.0',
161
+ category: 'identity',
162
+ requiredNodes: [
163
+ 'KSampler',
164
+ 'CheckpointLoaderSimple',
165
+ 'LoadImage',
166
+ 'ApplyInstantID',
167
+ 'InstantIDModelLoader',
168
+ 'SaveImage',
169
+ ],
170
+ requiredModels: [
171
+ 'ip-adapter-instantid_sdxl.bin',
172
+ ],
173
+ inputs: [
174
+ { name: 'face_image', type: 'string', required: true, description: 'Source portrait image for identity extraction' },
175
+ { name: 'prompt', type: 'string', required: true, description: 'Scene and clothing prompt' },
176
+ { name: 'face_weight', type: 'number', default: 0.8, description: 'Facial identity similarity weight (0.0 - 1.0)' },
177
+ { name: 'steps', type: 'number', default: 30 },
178
+ { name: 'seed', type: 'number', default: 0 },
179
+ ],
180
+ graph: {
181
+ '1': {
182
+ class_type: 'LoadImage',
183
+ inputs: { image: '{{face_image}}' },
184
+ },
185
+ '2': {
186
+ class_type: 'InstantIDModelLoader',
187
+ inputs: { instantid_file: 'ip-adapter-instantid_sdxl.bin' },
188
+ },
189
+ '3': {
190
+ class_type: 'ApplyInstantID',
191
+ inputs: {
192
+ instantid: ['2', 0],
193
+ image: ['1', 0],
194
+ model: ['4', 0],
195
+ positive: ['6', 0],
196
+ negative: ['7', 0],
197
+ weight: '{{face_weight}}',
198
+ },
199
+ },
200
+ '4': {
201
+ class_type: 'CheckpointLoaderSimple',
202
+ inputs: { ckpt_name: '{{model}}' },
203
+ },
204
+ '5': {
205
+ class_type: 'EmptyLatentImage',
206
+ inputs: { width: 1024, height: 1024, batch_size: 1 },
207
+ },
208
+ '6': {
209
+ class_type: 'CLIPTextEncode',
210
+ inputs: { text: '{{prompt}}', clip: ['4', 1] },
211
+ },
212
+ '7': {
213
+ class_type: 'CLIPTextEncode',
214
+ inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
215
+ },
216
+ '8': {
217
+ class_type: 'KSampler',
218
+ inputs: {
219
+ seed: '{{seed}}',
220
+ steps: '{{steps}}',
221
+ cfg: 7,
222
+ sampler_name: 'euler',
223
+ scheduler: 'normal',
224
+ denoise: 1,
225
+ model: ['3', 0],
226
+ positive: ['3', 1],
227
+ negative: ['3', 2],
228
+ latent_image: ['5', 0],
229
+ },
230
+ },
231
+ '9': {
232
+ class_type: 'VAEDecode',
233
+ inputs: { samples: ['8', 0], vae: ['4', 2] },
234
+ },
235
+ '10': {
236
+ class_type: 'SaveImage',
237
+ inputs: { filename_prefix: 'dsh_faceid', images: ['9', 0] },
238
+ },
239
+ },
240
+ },
241
+ {
242
+ id: 'upscale_esrgan',
243
+ name: 'Real-ESRGAN Model Upscaler',
244
+ description: 'High-fidelity 4x super-resolution restoration via dedicated Real-ESRGAN neural upscaler.',
245
+ version: '1.0.0',
246
+ source: 'Comfy-Org/workflow_templates',
247
+ license: 'Apache-2.0',
248
+ category: 'upscale',
249
+ requiredNodes: [
250
+ 'LoadImage',
251
+ 'UpscaleModelLoader',
252
+ 'ImageUpscaleWithModel',
253
+ 'SaveImage',
254
+ ],
255
+ requiredModels: [
256
+ 'RealESRGAN_x4plus.pth',
257
+ ],
258
+ inputs: [
259
+ { name: 'image', type: 'string', required: true, description: 'Source low-resolution image' },
260
+ { name: 'upscale_model', type: 'string', default: 'RealESRGAN_x4plus.pth', description: 'Upscaler model filename' },
261
+ ],
262
+ graph: {
263
+ '1': {
264
+ class_type: 'LoadImage',
265
+ inputs: { image: '{{image}}' },
266
+ },
267
+ '2': {
268
+ class_type: 'UpscaleModelLoader',
269
+ inputs: { model_name: '{{upscale_model}}' },
270
+ },
271
+ '3': {
272
+ class_type: 'ImageUpscaleWithModel',
273
+ inputs: {
274
+ upscale_model: ['2', 0],
275
+ image: ['1', 0],
276
+ },
277
+ },
278
+ '4': {
279
+ class_type: 'SaveImage',
280
+ inputs: { filename_prefix: 'dsh_upscale', images: ['3', 0] },
281
+ },
282
+ },
283
+ },
284
+ ]
285
+
286
+ /**
287
+ * Returns summary list of all vetted ComfyUI templates.
288
+ */
289
+ export function getComfyTemplates() {
290
+ return COMFY_TEMPLATES.map((t) => ({
291
+ id: t.id,
292
+ name: t.name,
293
+ description: t.description,
294
+ version: t.version,
295
+ source: t.source,
296
+ license: t.license,
297
+ category: t.category,
298
+ requiredNodes: [...t.requiredNodes],
299
+ requiredModels: [...t.requiredModels],
300
+ inputs: t.inputs.map((inp) => ({ ...inp })),
301
+ }))
302
+ }
303
+
304
+ /**
305
+ * Returns full template definition by ID or null.
306
+ */
307
+ export function getComfyTemplate(id) {
308
+ if (!id) return null
309
+ return COMFY_TEMPLATES.find((t) => t.id === id) || null
310
+ }
311
+
312
+ /**
313
+ * Validates whether the ComfyUI server satisfies the dependencies required by a workflow template (#433).
314
+ * Explicitly distinguishes server reachability failures from missing node/model dependencies.
315
+ *
316
+ * @param {string} baseURL ComfyUI server address (e.g. http://127.0.0.1:8188)
317
+ * @param {object} template Template definition from getComfyTemplate
318
+ * @param {object} options
319
+ * @returns {Promise<{ ok: boolean, missingNodes: string[], missingModels: string[], error?: string }>}
320
+ */
321
+ export function createComfyDependencyValidator({ fetchImpl = fetch, timeoutMs = 8000 } = {}) {
322
+ return async function validateComfyServerDependencies(baseURL, template) {
323
+ const base = String(baseURL || '').replace(/\/+$/, '')
324
+ if (!base) {
325
+ throw new Error('ComfyUI server URL is not configured')
326
+ }
327
+ if (!template) {
328
+ throw new Error('Template is required for dependency validation')
329
+ }
330
+
331
+ let objectInfo
332
+ try {
333
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
334
+ const timer = controller ? setTimeout(() => controller.abort(new Error('timeout')), timeoutMs) : null
335
+ const res = await fetchImpl(`${base}/object_info`, {
336
+ signal: controller ? controller.signal : undefined,
337
+ })
338
+ if (timer) clearTimeout(timer)
339
+ if (!res.ok) {
340
+ throw new Error(`HTTP ${res.status} from /object_info`)
341
+ }
342
+ objectInfo = await res.json()
343
+ } catch (err) {
344
+ // Re-throw server reachability error without masking it as a missing model error
345
+ const msg = err?.name === 'AbortError' ? 'connection timed out' : (err?.message || String(err))
346
+ throw new Error(`ComfyUI server is unreachable at ${base} (${msg})`)
347
+ }
348
+
349
+ if (!objectInfo || typeof objectInfo !== 'object') {
350
+ throw new Error(`ComfyUI server at ${base} returned invalid object_info`)
351
+ }
352
+
353
+ const availableNodes = new Set(Object.keys(objectInfo))
354
+ const missingNodes = (template.requiredNodes || []).filter((node) => !availableNodes.has(node))
355
+
356
+ // Model validation: inspect choices provided in CheckpointLoaderSimple or UpscaleModelLoader
357
+ const missingModels = []
358
+ if (template.requiredModels && template.requiredModels.length > 0) {
359
+ const ckptInfo = objectInfo.CheckpointLoaderSimple
360
+ const availableCkpts = Array.isArray(ckptInfo?.input?.required?.ckpt_name?.[0])
361
+ ? new Set(ckptInfo.input.required.ckpt_name[0])
362
+ : null
363
+
364
+ const upscaleInfo = objectInfo.UpscaleModelLoader
365
+ const availableUpscalers = Array.isArray(upscaleInfo?.input?.required?.model_name?.[0])
366
+ ? new Set(upscaleInfo.input.required.model_name[0])
367
+ : null
368
+
369
+ for (const model of template.requiredModels) {
370
+ if (model.endsWith('.pth') || model.endsWith('.pt')) {
371
+ if (availableUpscalers && !availableUpscalers.has(model)) {
372
+ missingModels.push(model)
373
+ }
374
+ } else if (model.endsWith('.safetensors') || model.endsWith('.ckpt')) {
375
+ if (availableCkpts && !availableCkpts.has(model)) {
376
+ missingModels.push(model)
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ if (missingNodes.length > 0 || missingModels.length > 0) {
383
+ const errors = []
384
+ if (missingNodes.length > 0) errors.push(`missing custom nodes: ${missingNodes.join(', ')}`)
385
+ if (missingModels.length > 0) errors.push(`missing model files: ${missingModels.join(', ')}`)
386
+ return {
387
+ ok: false,
388
+ missingNodes,
389
+ missingModels,
390
+ error: `ComfyUI dependencies missing for template "${template.name}": ${errors.join('; ')}`,
391
+ }
392
+ }
393
+
394
+ return {
395
+ ok: true,
396
+ missingNodes: [],
397
+ missingModels: [],
398
+ }
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Prepares an executable ComfyUI workflow graph from a vetted template and user parameters.
404
+ *
405
+ * @param {string} templateId
406
+ * @param {object} params
407
+ * @returns {object} validated, interpolated workflow graph
408
+ */
409
+ export function prepareComfyWorkflowFromTemplate(templateId, params = {}) {
410
+ const template = getComfyTemplate(templateId)
411
+ if (!template) {
412
+ throw new Error(`ComfyUI template "${templateId}" not found`)
413
+ }
414
+
415
+ // Validate required inputs
416
+ for (const input of template.inputs) {
417
+ if (input.required && (params[input.name] === undefined || params[input.name] === null || params[input.name] === '')) {
418
+ throw new Error(`Template "${template.name}" requires input "${input.name}"`)
419
+ }
420
+ }
421
+
422
+ // Apply default inputs if omitted
423
+ const merged = {}
424
+ for (const input of template.inputs) {
425
+ if (input.default !== undefined) {
426
+ merged[input.name] = input.default
427
+ }
428
+ }
429
+ Object.assign(merged, params)
430
+
431
+ const graph = interpolateComfyWorkflow(template.graph, merged)
432
+ return validateComfyWorkflow(graph)
433
+ }
@@ -215,6 +215,11 @@ export const Config = z.object({
215
215
  .description('CFG scale for local generation.')
216
216
  .default(7.0)
217
217
  .volatile(),
218
+ comfyTemplate: z
219
+ .string()
220
+ .description('Vetted ComfyUI workflow template identifier (e.g. "txt2img_sdxl", "img2img_sdxl", "face_instantid", "upscale_esrgan").')
221
+ .default('')
222
+ .volatile(),
218
223
  comfyWorkflowJson: z
219
224
  .string()
220
225
  .description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
package/lib/index.js CHANGED
@@ -48,6 +48,8 @@ import { registerLiveProgressRoutes } from './live-progress.js'
48
48
  import { registerSettingsRoutes } from './settings-route.js'
49
49
  import { clearAllAnchors } from './anchor-helpers.js'
50
50
  import { clearSpendReservations } from './cost-meter.js'
51
+ import { getAllRecipes, getRecipe } from './recipes.js'
52
+ import { getComfyTemplates, getComfyTemplate, createComfyDependencyValidator } from './comfy-catalog.js'
51
53
  import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
52
54
  import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
53
55
  import { resolveSource } from './resolve-source.js'
@@ -434,6 +436,35 @@ export function apply(ctx, rawConfig) {
434
436
  // Vault route (#159)
435
437
  registerVaultRoutes(ctx)
436
438
 
439
+ // Recipes & Comfy templates routes (#432, #433)
440
+ ctx.effect(() => ctx.webServer.register({
441
+ kind: 'exact',
442
+ path: '/dsh-image-gen/recipes',
443
+ handler: async (req, res) => {
444
+ if (req.method !== 'GET') {
445
+ res.writeHead(405, { 'Content-Type': 'application/json' })
446
+ res.end(JSON.stringify({ error: 'GET only' }))
447
+ return
448
+ }
449
+ res.writeHead(200, { 'Content-Type': 'application/json' })
450
+ res.end(JSON.stringify({ ok: true, recipes: getAllRecipes() }))
451
+ },
452
+ }), 'dsh-image-gen: recipes route')
453
+
454
+ ctx.effect(() => ctx.webServer.register({
455
+ kind: 'exact',
456
+ path: '/dsh-image-gen/comfy/templates',
457
+ handler: async (req, res) => {
458
+ if (req.method !== 'GET') {
459
+ res.writeHead(405, { 'Content-Type': 'application/json' })
460
+ res.end(JSON.stringify({ error: 'GET only' }))
461
+ return
462
+ }
463
+ res.writeHead(200, { 'Content-Type': 'application/json' })
464
+ res.end(JSON.stringify({ ok: true, templates: getComfyTemplates() }))
465
+ },
466
+ }), 'dsh-image-gen: comfy templates route')
467
+
437
468
  // Live events route (#379)
438
469
  registerLiveProgressRoutes(ctx)
439
470
 
@@ -9,6 +9,12 @@ import {
9
9
  interpolateComfyWorkflow,
10
10
  buildDefaultComfyWorkflow,
11
11
  } from '../../comfy-workflow-helpers.js'
12
+ import {
13
+ getComfyTemplate,
14
+ getComfyTemplates,
15
+ createComfyDependencyValidator,
16
+ prepareComfyWorkflowFromTemplate,
17
+ } from '../../comfy-catalog.js'
12
18
 
13
19
  function resolveWebSocket() {
14
20
  if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
@@ -108,7 +114,35 @@ export function createLocalGenerator(deps, job) {
108
114
  }
109
115
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
110
116
  let workflowGraph
111
- if (cfg.comfyWorkflowJson) {
117
+ const activeTemplateId = job.comfyTemplate || cfg.comfyTemplate
118
+ if (activeTemplateId) {
119
+ const template = getComfyTemplate(activeTemplateId)
120
+ if (!template) {
121
+ throw new Error(`Unknown ComfyUI template "${activeTemplateId}". Available templates: ${getComfyTemplates().map((t) => t.id).join(', ')}`)
122
+ }
123
+ const validator = createComfyDependencyValidator({ fetchImpl, timeoutMs: 8000 })
124
+ const depCheck = await validator(base, template)
125
+ if (!depCheck.ok) {
126
+ throw new Error(depCheck.error)
127
+ }
128
+ const faceData = job.faceReference && job.faceReference.bytes
129
+ ? Buffer.from(job.faceReference.bytes).toString('base64')
130
+ : (job.faceReference?.url || '')
131
+ workflowGraph = prepareComfyWorkflowFromTemplate(activeTemplateId, {
132
+ prompt: promptArg,
133
+ negativePrompt: negativePrompt || '',
134
+ seed: seedArg ?? 0,
135
+ width,
136
+ height,
137
+ steps: cfg.localSteps ?? 20,
138
+ cfg: cfg.localCfg ?? 7,
139
+ model: cfg.localModel,
140
+ image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
141
+ face_image: faceData,
142
+ face_weight: job.faceStrength ?? 0.75,
143
+ ...(job.templateParams || {}),
144
+ })
145
+ } else if (cfg.comfyWorkflowJson) {
112
146
  const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
113
147
  const faceData = job.faceReference && job.faceReference.bytes
114
148
  ? Buffer.from(job.faceReference.bytes).toString('base64')
package/lib/recipes.js ADDED
@@ -0,0 +1,410 @@
1
+ // lib/recipes.js — Sequential image recipe pipelines with step tracking & cost safety (#432)
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ /**
7
+ * Built-in sequential recipe pipelines.
8
+ */
9
+ export const BUILTIN_RECIPES = [
10
+ {
11
+ id: 'product_card',
12
+ name: 'Product Card Suite',
13
+ description: 'Generates product photography, cleanly removes background, and upscales for print or web presentation.',
14
+ version: '1.0.0',
15
+ category: 'e-commerce',
16
+ inputs: [
17
+ { name: 'prompt', type: 'string', required: true, description: 'Product and materials description' },
18
+ { name: 'size', type: 'string', default: '1024x1024', description: 'Initial generation dimensions' },
19
+ { name: 'scale', type: 'number', default: 2, description: 'Upscaling factor (2 or 4)' },
20
+ ],
21
+ steps: [
22
+ {
23
+ id: 'generate',
24
+ tool: 'generate_image',
25
+ description: 'Generate product visual',
26
+ params: {
27
+ prompt: '{{inputs.prompt}}, studio lighting, clean product shot',
28
+ size: '{{inputs.size}}',
29
+ },
30
+ estimatedCost: 0.02,
31
+ },
32
+ {
33
+ id: 'remove_bg',
34
+ tool: 'remove_background',
35
+ description: 'Remove background to transparent alpha',
36
+ params: {
37
+ image: '{{steps.generate.path}}',
38
+ },
39
+ estimatedCost: 0.01,
40
+ },
41
+ {
42
+ id: 'upscale',
43
+ tool: 'upscale_image',
44
+ description: 'Upscale transparent asset',
45
+ params: {
46
+ image: '{{steps.remove_bg.path}}',
47
+ scale: '{{inputs.scale}}',
48
+ },
49
+ estimatedCost: 0.02,
50
+ },
51
+ ],
52
+ },
53
+ {
54
+ id: 'social_promo',
55
+ name: 'Social Media Promo Banner',
56
+ description: 'Synthesizes theme graphic and smart crops to social widescreen (16:9) format.',
57
+ version: '1.0.0',
58
+ category: 'marketing',
59
+ inputs: [
60
+ { name: 'prompt', type: 'string', required: true, description: 'Promo announcement concept' },
61
+ { name: 'aspect', type: 'string', default: '16:9', description: 'Social crop aspect ratio' },
62
+ ],
63
+ steps: [
64
+ {
65
+ id: 'generate',
66
+ tool: 'generate_image',
67
+ description: 'Synthesize promo visual',
68
+ params: {
69
+ prompt: '{{inputs.prompt}}',
70
+ size: '1024x1024',
71
+ },
72
+ estimatedCost: 0.02,
73
+ },
74
+ {
75
+ id: 'crop',
76
+ tool: 'smart_crop_image',
77
+ description: 'Smart crop to destination ratio',
78
+ params: {
79
+ image: '{{steps.generate.path}}',
80
+ aspect: '{{inputs.aspect}}',
81
+ },
82
+ estimatedCost: 0.00,
83
+ },
84
+ ],
85
+ },
86
+ {
87
+ id: 'vector_asset',
88
+ name: 'Vector Asset Pipeline',
89
+ description: 'Generates clean icon concept, traces raster to vector paths, and produces scalable SVG.',
90
+ version: '1.0.0',
91
+ category: 'graphics',
92
+ inputs: [
93
+ { name: 'prompt', type: 'string', required: true, description: 'Icon concept prompt' },
94
+ { name: 'colors', type: 'number', default: 8, description: 'Palette color count for tracing' },
95
+ ],
96
+ steps: [
97
+ {
98
+ id: 'generate',
99
+ tool: 'generate_image',
100
+ description: 'Generate minimalist graphic',
101
+ params: {
102
+ prompt: '{{inputs.prompt}}, minimalist clean vector style, solid plain background',
103
+ size: '1024x1024',
104
+ },
105
+ estimatedCost: 0.02,
106
+ },
107
+ {
108
+ id: 'vectorize',
109
+ tool: 'vectorize_image',
110
+ description: 'Trace raster into clean vector paths',
111
+ params: {
112
+ image: '{{steps.generate.path}}',
113
+ mode: 'color',
114
+ colors: '{{inputs.colors}}',
115
+ },
116
+ estimatedCost: 0.00,
117
+ },
118
+ ],
119
+ },
120
+ {
121
+ id: 'character_pipeline',
122
+ name: 'Character Turnaround Pipeline',
123
+ description: 'Synthesizes character concept and generates multi-angle model turnaround sheet.',
124
+ version: '1.0.0',
125
+ category: 'creative',
126
+ inputs: [
127
+ { name: 'character_name', type: 'string', required: true, description: 'Name of the character' },
128
+ { name: 'prompt', type: 'string', required: true, description: 'Character description and attire' },
129
+ ],
130
+ steps: [
131
+ {
132
+ id: 'concept',
133
+ tool: 'generate_image',
134
+ description: 'Generate key character portrait',
135
+ params: {
136
+ prompt: '{{inputs.prompt}}, full body concept character art',
137
+ size: '1024x1024',
138
+ },
139
+ estimatedCost: 0.02,
140
+ },
141
+ {
142
+ id: 'turnaround',
143
+ tool: 'generate_character_sheet',
144
+ description: 'Generate model turnaround sheet',
145
+ params: {
146
+ character_name: '{{inputs.character_name}}',
147
+ reference_image: '{{steps.concept.path}}',
148
+ views: 'front,side,back',
149
+ },
150
+ estimatedCost: 0.03,
151
+ },
152
+ ],
153
+ },
154
+ ]
155
+
156
+ function getRecipesFilePath() {
157
+ const base = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
158
+ return path.join(base, 'dsh-image-gen', 'recipes.json')
159
+ }
160
+
161
+ /**
162
+ * Loads custom recipes from local storage file.
163
+ */
164
+ export function loadCustomRecipes() {
165
+ const filePath = getRecipesFilePath()
166
+ try {
167
+ if (!fs.existsSync(filePath)) return []
168
+ const content = fs.readFileSync(filePath, 'utf8')
169
+ const list = JSON.parse(content)
170
+ return Array.isArray(list) ? list : []
171
+ } catch (_) {
172
+ return []
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Saves a user recipe to local storage.
178
+ */
179
+ export function saveCustomRecipe(recipe) {
180
+ if (!recipe || typeof recipe !== 'object') {
181
+ throw new Error('Recipe must be an object')
182
+ }
183
+ if (!recipe.id || typeof recipe.id !== 'string') {
184
+ throw new Error('Recipe must have a valid string "id"')
185
+ }
186
+ if (!recipe.name || typeof recipe.name !== 'string') {
187
+ throw new Error('Recipe must have a valid string "name"')
188
+ }
189
+ if (!Array.isArray(recipe.steps) || recipe.steps.length === 0) {
190
+ throw new Error('Recipe must have a non-empty array of "steps"')
191
+ }
192
+
193
+ // Validate step format
194
+ for (let i = 0; i < recipe.steps.length; i++) {
195
+ const s = recipe.steps[i]
196
+ if (!s || !s.tool || typeof s.tool !== 'string') {
197
+ throw new Error(`Recipe step #${i + 1} missing required "tool" name`)
198
+ }
199
+ }
200
+
201
+ const list = loadCustomRecipes().filter((r) => r.id !== recipe.id)
202
+ const item = {
203
+ ...recipe,
204
+ custom: true,
205
+ updatedAt: new Date().toISOString(),
206
+ }
207
+ list.push(item)
208
+
209
+ const filePath = getRecipesFilePath()
210
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
211
+ fs.writeFileSync(filePath, JSON.stringify(list, null, 2), 'utf8')
212
+ return item
213
+ }
214
+
215
+ /**
216
+ * Deletes a user recipe by ID.
217
+ */
218
+ export function deleteCustomRecipe(id) {
219
+ const list = loadCustomRecipes()
220
+ const filtered = list.filter((r) => r.id !== id)
221
+ if (filtered.length === list.length) return false
222
+ const filePath = getRecipesFilePath()
223
+ fs.writeFileSync(filePath, JSON.stringify(filtered, null, 2), 'utf8')
224
+ return true
225
+ }
226
+
227
+ /**
228
+ * Returns merged list of all available recipes (built-in + custom).
229
+ */
230
+ export function getAllRecipes() {
231
+ const custom = loadCustomRecipes()
232
+ const customIds = new Set(custom.map((c) => c.id))
233
+ const builtins = BUILTIN_RECIPES.filter((b) => !customIds.has(b.id)).map((b) => ({
234
+ ...b,
235
+ builtin: true,
236
+ }))
237
+ return [...builtins, ...custom]
238
+ }
239
+
240
+ /**
241
+ * Retrieves a recipe by ID.
242
+ */
243
+ export function getRecipe(id) {
244
+ if (!id) return null
245
+ return getAllRecipes().find((r) => r.id === id) || null
246
+ }
247
+
248
+ /**
249
+ * Interpolates recipe step parameters using context containing inputs and prior step results.
250
+ */
251
+ export function interpolateRecipeParams(rawParams, context = {}) {
252
+ const { inputs = {}, steps = {} } = context
253
+
254
+ function resolveRef(str) {
255
+ if (typeof str !== 'string') return str
256
+ const trimmed = str.trim()
257
+
258
+ // Handle exact number matches
259
+ if (/^\{\{inputs\.([a-zA-Z0-9_.-]+)\}\}$/.test(trimmed)) {
260
+ const key = trimmed.slice(9, -2)
261
+ return inputs[key] !== undefined ? inputs[key] : ''
262
+ }
263
+
264
+ // General replacements
265
+ let out = str.replace(/\{\{inputs\.([a-zA-Z0-9_.-]+)\}\}/g, (_, k) => {
266
+ return inputs[k] !== undefined ? String(inputs[k]) : ''
267
+ })
268
+
269
+ out = out.replace(/\{\{steps\.([a-zA-Z0-9_.-]+)\.([a-zA-Z0-9_.-]+)\}\}/g, (_, stepId, prop) => {
270
+ const stepData = steps[stepId]
271
+ if (!stepData) return ''
272
+ // Try direct property or nested image/attachment properties
273
+ if (stepData[prop] !== undefined) return String(stepData[prop])
274
+ if (prop === 'path' && stepData.images?.[0]?.path) return String(stepData.images[0].path)
275
+ if (prop === 'url' && stepData.images?.[0]?.url) return String(stepData.images[0].url)
276
+ if (prop === 'path' && stepData.attachment?.path) return String(stepData.attachment.path)
277
+ if (prop === 'path' && stepData.svgPath) return String(stepData.svgPath)
278
+ return ''
279
+ })
280
+
281
+ return out
282
+ }
283
+
284
+ function transform(val) {
285
+ if (typeof val === 'string') return resolveRef(val)
286
+ if (Array.isArray(val)) return val.map(transform)
287
+ if (val && typeof val === 'object') {
288
+ const res = {}
289
+ for (const [k, v] of Object.entries(val)) {
290
+ res[k] = transform(v)
291
+ }
292
+ return res
293
+ }
294
+ return val
295
+ }
296
+
297
+ return transform(rawParams)
298
+ }
299
+
300
+ /**
301
+ * Executes a sequential image recipe pipeline (#432).
302
+ *
303
+ * @param {string|object} recipeOrId Recipe ID or full recipe object
304
+ * @param {object} userInputs User provided arguments (e.g. prompt, image, aspect)
305
+ * @param {object} env Runtime tools environment
306
+ * @param {Function} env.toolDispatcher Function (toolName, params) => Promise<result>
307
+ * @param {Function} [env.assertBudget] Function (amount) => throws if insufficient budget
308
+ * @param {Function} [env.onProgress] Function ({ stage, progress })
309
+ * @returns {Promise<object>} Recipe execution summary and final artifact
310
+ */
311
+ export async function executeRecipe(recipeOrId, userInputs = {}, env = {}) {
312
+ const { toolDispatcher, assertBudget, onProgress } = env
313
+ if (typeof toolDispatcher !== 'function') {
314
+ throw new Error('Recipe runner requires a toolDispatcher function')
315
+ }
316
+
317
+ const recipe = typeof recipeOrId === 'string' ? getRecipe(recipeOrId) : recipeOrId
318
+ if (!recipe) {
319
+ throw new Error(`Recipe "${recipeOrId}" not found`)
320
+ }
321
+
322
+ // 1. Validate required inputs
323
+ const mergedInputs = {}
324
+ for (const inp of recipe.inputs || []) {
325
+ if (inp.default !== undefined) mergedInputs[inp.name] = inp.default
326
+ }
327
+ Object.assign(mergedInputs, userInputs)
328
+
329
+ for (const inp of recipe.inputs || []) {
330
+ if (inp.required && (mergedInputs[inp.name] === undefined || mergedInputs[inp.name] === null || mergedInputs[inp.name] === '')) {
331
+ throw new Error(`Recipe "${recipe.name}" missing required input: "${inp.name}"`)
332
+ }
333
+ }
334
+
335
+ // 2. Pre-flight dry run: estimate total budget and verify tools exist before executing paid requests
336
+ let totalEstimatedCost = 0
337
+ for (let i = 0; i < recipe.steps.length; i++) {
338
+ const step = recipe.steps[i]
339
+ totalEstimatedCost += Number(step.estimatedCost || 0)
340
+ }
341
+
342
+ if (typeof assertBudget === 'function' && totalEstimatedCost > 0) {
343
+ assertBudget(totalEstimatedCost)
344
+ }
345
+
346
+ // 3. Sequential step execution
347
+ const completedSteps = []
348
+ const stepContext = {
349
+ inputs: mergedInputs,
350
+ steps: {},
351
+ }
352
+
353
+ for (let i = 0; i < recipe.steps.length; i++) {
354
+ const step = recipe.steps[i]
355
+ const stepId = step.id || String(i)
356
+ const stepDescription = step.description || `Step ${i + 1}: ${step.tool}`
357
+
358
+ onProgress?.({
359
+ stage: `Step ${i + 1}/${recipe.steps.length}: ${step.tool}`,
360
+ progress: Math.round((i / recipe.steps.length) * 100),
361
+ })
362
+
363
+ const interpolated = interpolateRecipeParams(step.params || {}, stepContext)
364
+
365
+ const startTime = Date.now()
366
+ let result
367
+ try {
368
+ result = await toolDispatcher(step.tool, interpolated)
369
+ } catch (err) {
370
+ const failedErr = new Error(`Recipe "${recipe.name}" failed at step ${i + 1} (${step.tool}): ${err.message}`)
371
+ failedErr.recipeId = recipe.id
372
+ failedErr.failedStepIndex = i
373
+ failedErr.failedStepTool = step.tool
374
+ failedErr.completedSteps = completedSteps
375
+ throw failedErr
376
+ }
377
+
378
+ const durationMs = Date.now() - startTime
379
+ const record = {
380
+ stepIndex: i,
381
+ stepId,
382
+ tool: step.tool,
383
+ description: stepDescription,
384
+ params: interpolated,
385
+ durationMs,
386
+ result,
387
+ }
388
+
389
+ completedSteps.push(record)
390
+ stepContext.steps[stepId] = result
391
+ stepContext.steps[String(i)] = result
392
+ }
393
+
394
+ onProgress?.({
395
+ stage: 'Recipe completed',
396
+ progress: 100,
397
+ })
398
+
399
+ const lastResult = completedSteps[completedSteps.length - 1]?.result
400
+
401
+ return {
402
+ ok: true,
403
+ recipeId: recipe.id,
404
+ recipeName: recipe.name,
405
+ stepsCount: recipe.steps.length,
406
+ completedSteps,
407
+ finalResult: lastResult,
408
+ totalEstimatedCost: +totalEstimatedCost.toFixed(4),
409
+ }
410
+ }
@@ -19,6 +19,7 @@ import { registerThemePairTools } from './tools/theme-pair.js'
19
19
  import { registerCharacterSheetTools } from './tools/character-sheet.js'
20
20
  import { registerVisionOcrTools } from './tools/vision-ocr.js'
21
21
  import { registerDiagramTools } from './tools/diagram.js'
22
+ import { registerRecipeTools } from './tools/recipes.js'
22
23
  import {
23
24
  createLiveProgressSession,
24
25
  publishLiveProgress,
@@ -38,6 +39,13 @@ export function registerAllTools(ctx, deps) {
38
39
  const registeredNames = new Set()
39
40
 
40
41
  const originalToolsRegister = ctx?.tools?.register ? ctx.tools.register.bind(ctx.tools) : null
42
+ const toolMap = new Map()
43
+ const toolDispatcher = async (name, params, exec) => {
44
+ const fn = toolMap.get(name)
45
+ if (!fn) throw new Error(`Tool "${name}" is not registered in current toolset profile`)
46
+ return fn(params, exec)
47
+ }
48
+
41
49
  const wrappedTools = originalToolsRegister ? Object.create(ctx.tools, {
42
50
  register: {
43
51
  value: (def) => {
@@ -68,6 +76,7 @@ export function registerAllTools(ctx, deps) {
68
76
  }
69
77
  const res = originalToolsRegister(wrappedDef)
70
78
  registeredNames.add(def.name)
79
+ toolMap.set(def.name, (p, e) => originalExecute(p, e))
71
80
  if (typeof res === 'function') {
72
81
  activeDisposers.push(res)
73
82
  } else {
@@ -130,12 +139,16 @@ export function registerAllTools(ctx, deps) {
130
139
  registerResponsiveTools(targetCtx, deps)
131
140
  }
132
141
 
133
- // 5. Creative toolset
142
+ // 5. Creative toolset & Recipes (#432, #433)
134
143
  if (creative) {
135
144
  registerSketchTools(targetCtx, deps)
136
145
  registerSpritesheetTools(targetCtx, deps)
137
146
  registerPatternTools(targetCtx, deps)
138
147
  }
148
+
149
+ if (Boolean(sets.recipes)) {
150
+ registerRecipeTools(targetCtx, { ...deps, toolDispatcher })
151
+ }
139
152
  }
140
153
 
141
154
  function dispose() {
@@ -0,0 +1,250 @@
1
+ // lib/tools/recipes.js — Tools for running recipes & validating ComfyUI workflows (#432, #433)
2
+ import { defineTool } from '@deepseek-ai/dsh-tools'
3
+ import {
4
+ getAllRecipes,
5
+ getRecipe,
6
+ saveCustomRecipe,
7
+ deleteCustomRecipe,
8
+ executeRecipe,
9
+ } from '../recipes.js'
10
+ import {
11
+ getComfyTemplates,
12
+ getComfyTemplate,
13
+ createComfyDependencyValidator,
14
+ prepareComfyWorkflowFromTemplate,
15
+ } from '../comfy-catalog.js'
16
+
17
+ /**
18
+ * Registers recipe execution and workflow catalog tools.
19
+ *
20
+ * @param {object} ctx cordis context
21
+ * @param {object} deps shared runtime dependencies
22
+ */
23
+ export function registerRecipeTools(ctx, deps) {
24
+ const { config, toolDispatcher, assertBudget } = deps
25
+
26
+ // 1. run_image_recipe (#432)
27
+ ctx.tools.register({
28
+ name: 'run_image_recipe',
29
+ description: 'Executes a repeatable sequential image recipe pipeline (e.g. product_card, social_promo, vector_asset, character_pipeline). Runs each step, validates budget and prerequisites, and provides intermediate and final results.',
30
+ parameters: {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ required: ['recipe_id'],
34
+ properties: {
35
+ recipe_id: {
36
+ type: 'string',
37
+ description: 'Identifier of the recipe (e.g. "product_card", "social_promo", "vector_asset", "character_pipeline", or custom recipe ID).',
38
+ },
39
+ inputs: {
40
+ type: 'object',
41
+ additionalProperties: true,
42
+ description: 'Input arguments required by the recipe (e.g. prompt, image, aspect, scale, character_name).',
43
+ },
44
+ },
45
+ },
46
+ output: {
47
+ render(args, value) {
48
+ const steps = value?.completedSteps || []
49
+ const lines = [
50
+ `### Recipe: ${value?.recipeName || 'Recipe'} (${value?.recipeId || ''})`,
51
+ `Steps completed: ${steps.length}/${value?.stepsCount || 0} (Est. cost: $${value?.totalEstimatedCost || 0})`,
52
+ '',
53
+ ]
54
+ for (const step of steps) {
55
+ lines.push(`- **Step ${step.stepIndex + 1}: ${step.tool}** (${step.durationMs}ms) — ${step.description}`)
56
+ }
57
+ return [{ type: 'text', text: lines.join('\n') }]
58
+ },
59
+ },
60
+ execute: async (args) => {
61
+ const { recipe_id, inputs = {} } = args || {}
62
+ if (!recipe_id) throw new Error('recipe_id is required')
63
+
64
+ const res = await executeRecipe(recipe_id, inputs, {
65
+ toolDispatcher,
66
+ assertBudget,
67
+ })
68
+
69
+ return res
70
+ },
71
+ render(args, value) {
72
+ const steps = value?.completedSteps || []
73
+ const lines = [
74
+ `### Recipe: ${value?.recipeName || 'Recipe'} (${value?.recipeId || ''})`,
75
+ `Steps completed: ${steps.length}/${value?.stepsCount || 0} (Est. cost: $${value?.totalEstimatedCost || 0})`,
76
+ '',
77
+ ]
78
+ for (const step of steps) {
79
+ lines.push(`- **Step ${step.stepIndex + 1}: ${step.tool}** (${step.durationMs}ms) — ${step.description}`)
80
+ }
81
+ return [{ type: 'text', text: lines.join('\n') }]
82
+ },
83
+ isConcurrencySafe: () => false,
84
+ })
85
+
86
+ // 2. manage_image_recipes (#432)
87
+ ctx.tools.register({
88
+ name: 'manage_image_recipes',
89
+ description: 'Lists, inspects, saves, or deletes sequential image generation recipes.',
90
+ parameters: {
91
+ type: 'object',
92
+ additionalProperties: false,
93
+ required: ['action'],
94
+ properties: {
95
+ action: {
96
+ type: 'string',
97
+ enum: ['list', 'get', 'save', 'delete'],
98
+ description: 'Recipe management action.',
99
+ },
100
+ recipe_id: {
101
+ type: 'string',
102
+ description: 'Recipe ID (required for get and delete).',
103
+ },
104
+ recipe: {
105
+ type: 'object',
106
+ additionalProperties: true,
107
+ description: 'Complete recipe object to save (required for action="save"). Must specify id, name, inputs, and steps.',
108
+ },
109
+ },
110
+ },
111
+ output: {
112
+ render(args, value) {
113
+ if (args?.action === 'list') {
114
+ const list = value?.recipes || []
115
+ const lines = [`Found ${list.length} available image recipes:`, '']
116
+ for (const r of list) {
117
+ lines.push(`- **${r.id}** (${r.name}): ${r.description} [${r.steps?.length || 0} steps]`)
118
+ }
119
+ return [{ type: 'text', text: lines.join('\n') }]
120
+ }
121
+ return [{ type: 'text', text: JSON.stringify(value || {}, null, 2) }]
122
+ },
123
+ },
124
+ execute: async (args) => {
125
+ const { action, recipe_id, recipe } = args || {}
126
+ if (action === 'list') {
127
+ return { ok: true, recipes: getAllRecipes() }
128
+ }
129
+ if (action === 'get') {
130
+ if (!recipe_id) throw new Error('recipe_id is required for action="get"')
131
+ const found = getRecipe(recipe_id)
132
+ if (!found) throw new Error(`Recipe "${recipe_id}" not found`)
133
+ return { ok: true, recipe: found }
134
+ }
135
+ if (action === 'save') {
136
+ if (!recipe) throw new Error('recipe object is required for action="save"')
137
+ const saved = saveCustomRecipe(recipe)
138
+ return { ok: true, recipe: saved, message: `Recipe "${saved.id}" saved successfully` }
139
+ }
140
+ if (action === 'delete') {
141
+ if (!recipe_id) throw new Error('recipe_id is required for action="delete"')
142
+ const deleted = deleteCustomRecipe(recipe_id)
143
+ if (!deleted) throw new Error(`Custom recipe "${recipe_id}" not found or cannot be deleted`)
144
+ return { ok: true, deleted: true, recipe_id }
145
+ }
146
+ throw new Error(`Unsupported action "${action}"`)
147
+ },
148
+ render(args, value) {
149
+ if (args?.action === 'list') {
150
+ const list = value?.recipes || []
151
+ const lines = [`Found ${list.length} available image recipes:`, '']
152
+ for (const r of list) {
153
+ lines.push(`- **${r.id}** (${r.name}): ${r.description} [${r.steps?.length || 0} steps]`)
154
+ }
155
+ return [{ type: 'text', text: lines.join('\n') }]
156
+ }
157
+ return [{ type: 'text', text: JSON.stringify(value || {}, null, 2) }]
158
+ },
159
+ isConcurrencySafe: () => true,
160
+ })
161
+
162
+ // 3. validate_comfy_workflow (#433)
163
+ ctx.tools.register({
164
+ name: 'validate_comfy_workflow',
165
+ description: 'Validates a vetted ComfyUI template or custom workflow graph against the connected ComfyUI server dependencies and models.',
166
+ parameters: {
167
+ type: 'object',
168
+ additionalProperties: false,
169
+ properties: {
170
+ template_id: {
171
+ type: 'string',
172
+ description: 'Identifier of the vetted ComfyUI template (e.g. "txt2img_sdxl", "img2img_sdxl", "face_instantid", "upscale_esrgan").',
173
+ },
174
+ base_url: {
175
+ type: 'string',
176
+ description: 'ComfyUI server address (defaults to configured localBaseURL).',
177
+ },
178
+ },
179
+ },
180
+ output: {
181
+ render(args, value) {
182
+ if (value?.templateId) {
183
+ const status = value.ok ? 'READY' : 'MISSING DEPENDENCIES'
184
+ const lines = [
185
+ `### ComfyUI Template: ${value.templateName} [${status}]`,
186
+ `Server: ${value.serverUrl}`,
187
+ ]
188
+ if (value.missingNodes?.length) lines.push(`- Missing Nodes: ${value.missingNodes.join(', ')}`)
189
+ if (value.missingModels?.length) lines.push(`- Missing Models: ${value.missingModels.join(', ')}`)
190
+ if (value.ok) lines.push('All required custom nodes and model assets are available on the ComfyUI host.')
191
+ return [{ type: 'text', text: lines.join('\n') }]
192
+ }
193
+ const list = value?.templates || []
194
+ const lines = [`Vetted ComfyUI Templates (${list.length}):`, '']
195
+ for (const t of list) lines.push(`- **${t.id}**: ${t.name} (${t.description})`)
196
+ return [{ type: 'text', text: lines.join('\n') }]
197
+ },
198
+ },
199
+ execute: async (args) => {
200
+ const { template_id, base_url } = args || {}
201
+ const targetBase = base_url || config?.localBaseURL || 'http://127.0.0.1:8188'
202
+
203
+ if (template_id) {
204
+ const template = getComfyTemplate(template_id)
205
+ if (!template) {
206
+ throw new Error(`ComfyUI template "${template_id}" not found. Available: ${getComfyTemplates().map((t) => t.id).join(', ')}`)
207
+ }
208
+
209
+ const validator = createComfyDependencyValidator()
210
+ const check = await validator(targetBase, template)
211
+ return {
212
+ ok: check.ok,
213
+ templateId: template.id,
214
+ templateName: template.name,
215
+ requiredNodes: template.requiredNodes,
216
+ requiredModels: template.requiredModels,
217
+ missingNodes: check.missingNodes,
218
+ missingModels: check.missingModels,
219
+ serverUrl: targetBase,
220
+ error: check.error,
221
+ }
222
+ }
223
+
224
+ // If no template specified, return catalog summary
225
+ return {
226
+ ok: true,
227
+ templates: getComfyTemplates(),
228
+ serverUrl: targetBase,
229
+ }
230
+ },
231
+ render(args, value) {
232
+ if (value?.templateId) {
233
+ const status = value.ok ? 'READY' : 'MISSING DEPENDENCIES'
234
+ const lines = [
235
+ `### ComfyUI Template: ${value.templateName} [${status}]`,
236
+ `Server: ${value.serverUrl}`,
237
+ ]
238
+ if (value.missingNodes?.length) lines.push(`- Missing Nodes: ${value.missingNodes.join(', ')}`)
239
+ if (value.missingModels?.length) lines.push(`- Missing Models: ${value.missingModels.join(', ')}`)
240
+ if (value.ok) lines.push('All required custom nodes and model assets are available on the ComfyUI host.')
241
+ return [{ type: 'text', text: lines.join('\n') }]
242
+ }
243
+ const list = value?.templates || []
244
+ const lines = [`Vetted ComfyUI Templates (${list.length}):`, '']
245
+ for (const t of list) lines.push(`- **${t.id}**: ${t.name} (${t.description})`)
246
+ return [{ type: 'text', text: lines.join('\n') }]
247
+ },
248
+ isConcurrencySafe: () => true,
249
+ })
250
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.38",
3
+ "version": "0.11.39",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",