@goodandready/dsh-image-gen 0.11.37 → 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 +575 -495
- package/lib/comfy-catalog.js +433 -0
- package/lib/config-schema.js +5 -0
- package/lib/index.js +33 -0
- package/lib/live-progress.js +9 -0
- package/lib/providers/backends/custom.js +2 -2
- 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/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
|
+
}
|