@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 +5 -0
- package/lib/comfy-catalog.js +433 -0
- package/lib/config-schema.js +5 -0
- package/lib/index.js +31 -0
- package/lib/providers/backends/local.js +35 -1
- package/lib/recipes.js +410 -0
- package/lib/register-tools.js +14 -1
- package/lib/tools/recipes.js +250 -0
- package/package.json +1 -1
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
|
+
}
|
package/lib/config-schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/register-tools.js
CHANGED
|
@@ -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.
|
|
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",
|