@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/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
+ }