@goodandready/dsh-image-gen 0.11.38 → 0.11.40
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/capabilities.js +433 -0
- package/lib/client.js +10 -0
- package/lib/comfy-catalog.js +433 -0
- package/lib/config-schema.js +10 -0
- package/lib/index.js +76 -0
- package/lib/project-profiles.js +234 -0
- package/lib/providers/backends/local.js +35 -1
- package/lib/recipes.js +410 -0
- package/lib/register-tools.js +22 -1
- package/lib/tools/capabilities.js +139 -0
- package/lib/tools/project-profiles.js +139 -0
- package/lib/tools/recipes.js +250 -0
- package/package.json +1 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Visual Project & Series Profiles (#435)
|
|
7
|
+
* Manages persistent project visual profiles with explicit scope,
|
|
8
|
+
* parameter cascade (defaults -> profile -> request), and immutable history tagging.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_PROJECT_PROFILES = [
|
|
12
|
+
{
|
|
13
|
+
id: 'cyberpunk_neon',
|
|
14
|
+
name: 'Cyberpunk Neon',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
description: 'High contrast neon lighting and dark futuristic styling.',
|
|
17
|
+
palette: ['#00f0ff', '#ff003c', '#ffe600', '#0a0a12'],
|
|
18
|
+
stylePrompt: 'cyberpunk aesthetic, volumetric atmospheric lighting, raytracing, sharp neon accents, highly detailed 8k',
|
|
19
|
+
negativePrompt: 'washed out, vintage, pastel, desaturated, noisy',
|
|
20
|
+
format: { size: '1024x1024', aspect: '1:1', format: 'webp' },
|
|
21
|
+
rules: {
|
|
22
|
+
background: 'dark cityscapes or moody interior',
|
|
23
|
+
typography: 'clean monospace or futuristic geometric',
|
|
24
|
+
},
|
|
25
|
+
allowedModels: ['fal-ai/flux/dev', 'sdxl_base'],
|
|
26
|
+
builtin: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'minimal_nordic',
|
|
30
|
+
name: 'Scandinavian Minimalist',
|
|
31
|
+
version: '1.0.0',
|
|
32
|
+
description: 'Warm daylight, matte textures, and understated earthy tones.',
|
|
33
|
+
palette: ['#f4f1de', '#e07a5f', '#3d405b', '#81b29a'],
|
|
34
|
+
stylePrompt: 'scandinavian minimalism, clean composition, soft natural daylight, matte surfaces, earthy organic elegance',
|
|
35
|
+
negativePrompt: 'cluttered, neon, heavy shadows, oversaturated, glossy plastic',
|
|
36
|
+
format: { size: '1024x1024', aspect: '1:1', format: 'png' },
|
|
37
|
+
rules: {
|
|
38
|
+
background: 'plain textured warm neutral',
|
|
39
|
+
typography: 'light grotesque sans',
|
|
40
|
+
},
|
|
41
|
+
allowedModels: ['fal-ai/flux/dev', 'imagen-3.0-generate-002'],
|
|
42
|
+
builtin: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'corporate_editorial',
|
|
46
|
+
name: 'Corporate Editorial',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
description: 'High-end business magazine photography with crisp studio lighting.',
|
|
49
|
+
palette: ['#0f172a', '#3b82f6', '#64748b', '#f8fafc'],
|
|
50
|
+
stylePrompt: 'high-end corporate magazine editorial, crisp balanced studio lighting, premium commercial photography',
|
|
51
|
+
negativePrompt: 'anime, cartoon, oversaturated, exaggerated distortions, amateur',
|
|
52
|
+
format: { size: '1024x1024', aspect: '16:9', format: 'webp' },
|
|
53
|
+
rules: {
|
|
54
|
+
background: 'architectural soft blur or pristine studio',
|
|
55
|
+
typography: 'editorial serif / humanistic sans',
|
|
56
|
+
},
|
|
57
|
+
allowedModels: ['fal-ai/flux/dev', 'black-forest-labs/flux-1.1-pro'],
|
|
58
|
+
builtin: true,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
export function getProfilesStoragePath(customPath) {
|
|
63
|
+
if (customPath) return customPath
|
|
64
|
+
if (process.env.DSH_PROJECT_PROFILES_PATH) return process.env.DSH_PROJECT_PROFILES_PATH
|
|
65
|
+
const home = os.homedir() || '/tmp'
|
|
66
|
+
return path.join(home, '.dsh', 'dsh-image-gen', 'project-profiles.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function loadCustomProfiles(storagePath) {
|
|
70
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(filePath)) return []
|
|
73
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
74
|
+
const parsed = JSON.parse(content)
|
|
75
|
+
return Array.isArray(parsed) ? parsed : []
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function saveCustomProfile(profile, storagePath) {
|
|
82
|
+
if (!profile || typeof profile !== 'object') {
|
|
83
|
+
throw new Error('Invalid project profile: object required')
|
|
84
|
+
}
|
|
85
|
+
if (!profile.id || typeof profile.id !== 'string') {
|
|
86
|
+
throw new Error('Project profile requires a string "id"')
|
|
87
|
+
}
|
|
88
|
+
if (!profile.name || typeof profile.name !== 'string') {
|
|
89
|
+
throw new Error('Project profile requires a string "name"')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
93
|
+
const dir = path.dirname(filePath)
|
|
94
|
+
if (!fs.existsSync(dir)) {
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const existing = loadCustomProfiles(storagePath)
|
|
99
|
+
const index = existing.findIndex((p) => p.id === profile.id)
|
|
100
|
+
const prepared = {
|
|
101
|
+
...profile,
|
|
102
|
+
version: profile.version || '1.0.0',
|
|
103
|
+
builtin: false,
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (index >= 0) {
|
|
108
|
+
existing[index] = prepared
|
|
109
|
+
} else {
|
|
110
|
+
existing.push(prepared)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
114
|
+
return prepared
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function deleteCustomProfile(id, storagePath) {
|
|
118
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
119
|
+
const existing = loadCustomProfiles(storagePath)
|
|
120
|
+
const filtered = existing.filter((p) => p.id !== id)
|
|
121
|
+
if (filtered.length === existing.length) {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
fs.writeFileSync(filePath, JSON.stringify(filtered, null, 2), 'utf8')
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getAllProjectProfiles(storagePath) {
|
|
129
|
+
const custom = loadCustomProfiles(storagePath)
|
|
130
|
+
const customIds = new Set(custom.map((c) => c.id))
|
|
131
|
+
const builtins = BUILTIN_PROJECT_PROFILES.filter((b) => !customIds.has(b.id))
|
|
132
|
+
return [...builtins, ...custom]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getProjectProfileById(id, storagePath) {
|
|
136
|
+
if (!id) return null
|
|
137
|
+
const all = getAllProjectProfiles(storagePath)
|
|
138
|
+
return all.find((p) => p.id === id) || null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parameter resolution cascade (#435):
|
|
143
|
+
* Priority: Request parameters > Project Profile > System Defaults.
|
|
144
|
+
* Returns effective params, provenance mapping, and immutable profile tag for history.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveEffectiveParams(requestParams = {}, profileId = null, storagePath = null) {
|
|
147
|
+
const profile = profileId ? getProjectProfileById(profileId, storagePath) : null
|
|
148
|
+
const provenance = {}
|
|
149
|
+
const effective = {}
|
|
150
|
+
|
|
151
|
+
// 1. Defaults
|
|
152
|
+
const defaults = {
|
|
153
|
+
size: '1024x1024',
|
|
154
|
+
format: 'webp',
|
|
155
|
+
negativePrompt: '',
|
|
156
|
+
steps: 25,
|
|
157
|
+
cfg: 7,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [key, val] of Object.entries(defaults)) {
|
|
161
|
+
effective[key] = val
|
|
162
|
+
provenance[key] = 'default'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Profile layer
|
|
166
|
+
if (profile) {
|
|
167
|
+
if (profile.format?.size) {
|
|
168
|
+
effective.size = profile.format.size
|
|
169
|
+
provenance.size = 'profile'
|
|
170
|
+
}
|
|
171
|
+
if (profile.format?.format) {
|
|
172
|
+
effective.format = profile.format.format
|
|
173
|
+
provenance.format = 'profile'
|
|
174
|
+
}
|
|
175
|
+
if (profile.negativePrompt) {
|
|
176
|
+
effective.negativePrompt = profile.negativePrompt
|
|
177
|
+
provenance.negativePrompt = 'profile'
|
|
178
|
+
}
|
|
179
|
+
if (profile.stylePrompt) {
|
|
180
|
+
effective.stylePrompt = profile.stylePrompt
|
|
181
|
+
provenance.stylePrompt = 'profile'
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(profile.palette) && profile.palette.length > 0) {
|
|
184
|
+
effective.palette = [...profile.palette]
|
|
185
|
+
provenance.palette = 'profile'
|
|
186
|
+
}
|
|
187
|
+
if (profile.rules) {
|
|
188
|
+
effective.rules = { ...profile.rules }
|
|
189
|
+
provenance.rules = 'profile'
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3. Request layer (highest precedence)
|
|
194
|
+
for (const [key, val] of Object.entries(requestParams)) {
|
|
195
|
+
if (val !== undefined && val !== null) {
|
|
196
|
+
if (key === 'prompt') {
|
|
197
|
+
// Synthesize prompt with profile style if available
|
|
198
|
+
if (effective.stylePrompt) {
|
|
199
|
+
effective.prompt = `${val}, ${effective.stylePrompt}`
|
|
200
|
+
provenance.prompt = 'request+profile'
|
|
201
|
+
} else {
|
|
202
|
+
effective.prompt = val
|
|
203
|
+
provenance.prompt = 'request'
|
|
204
|
+
}
|
|
205
|
+
} else if (key === 'negativePrompt') {
|
|
206
|
+
if (effective.negativePrompt && val) {
|
|
207
|
+
effective.negativePrompt = `${effective.negativePrompt}, ${val}`
|
|
208
|
+
provenance.negativePrompt = 'request+profile'
|
|
209
|
+
} else {
|
|
210
|
+
effective.negativePrompt = val || effective.negativePrompt
|
|
211
|
+
provenance.negativePrompt = 'request'
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
effective[key] = val
|
|
215
|
+
provenance[key] = 'request'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Immutable applied profile metadata snapshot for history
|
|
221
|
+
const appliedProfile = profile
|
|
222
|
+
? {
|
|
223
|
+
id: profile.id,
|
|
224
|
+
name: profile.name,
|
|
225
|
+
version: profile.version || '1.0.0',
|
|
226
|
+
}
|
|
227
|
+
: null
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
effectiveParams: effective,
|
|
231
|
+
provenance,
|
|
232
|
+
appliedProfile,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -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
|
+
}
|