@goodandready/dsh-image-gen 0.10.30 → 0.10.32
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/anchor-helpers.js +103 -0
- package/lib/client.js +462 -52
- package/lib/fallback-router.js +163 -0
- package/lib/index.js +27 -5
- package/lib/register-tools.js +6 -0
- package/lib/security.js +166 -0
- package/lib/style-matrix-helpers.js +85 -0
- package/lib/tools/anchor.js +129 -0
- package/lib/tools/generation.js +21 -5
- package/lib/tools/style-matrix.js +227 -0
- package/lib/tools/ui-asset.js +238 -0
- package/lib/ui-asset-helpers.js +44 -0
- package/lib/updater.js +9 -19
- package/package.json +2 -2
package/lib/tools/generation.js
CHANGED
|
@@ -52,6 +52,8 @@ import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../
|
|
|
52
52
|
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
53
53
|
import { sanitizeErrorAndLogs } from '../security.js'
|
|
54
54
|
import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
|
|
55
|
+
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
56
|
+
import { getSessionAnchor, applyAnchorPromptHints } from '../anchor-helpers.js'
|
|
55
57
|
import {
|
|
56
58
|
extractDesignTokens,
|
|
57
59
|
generateCssGradient,
|
|
@@ -173,6 +175,14 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
173
175
|
type: 'boolean',
|
|
174
176
|
description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
|
|
175
177
|
},
|
|
178
|
+
reference_image: {
|
|
179
|
+
type: 'string',
|
|
180
|
+
description: 'Optional reference image (URL, path, or sha256:...) for character or style consistency.',
|
|
181
|
+
},
|
|
182
|
+
reference_strength: {
|
|
183
|
+
type: 'number',
|
|
184
|
+
description: 'Optional strength of reference anchor influence (0.1 to 1.0).',
|
|
185
|
+
},
|
|
176
186
|
},
|
|
177
187
|
output: {
|
|
178
188
|
schema: {
|
|
@@ -266,7 +276,13 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
266
276
|
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
267
277
|
const effectiveStyle = args.style || cfg.stylePreset
|
|
268
278
|
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
269
|
-
|
|
279
|
+
let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
280
|
+
const activeAnchor = getSessionAnchor(sessionId)
|
|
281
|
+
if (args.reference_image) {
|
|
282
|
+
effectivePrompt = `${effectivePrompt}, visual reference anchor`
|
|
283
|
+
} else if (activeAnchor) {
|
|
284
|
+
effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
|
|
285
|
+
}
|
|
270
286
|
|
|
271
287
|
// #165: Negative prompt sanitizer for diffusion models
|
|
272
288
|
let effectiveNegative = styleInfo.negativePrompt
|
|
@@ -419,7 +435,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
419
435
|
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
420
436
|
// Fallback cascade: evaluate configured provider, falling back to alternates
|
|
421
437
|
// (fal -> custom -> codex -> grok), accumulating failure diagnostics.
|
|
422
|
-
const order =
|
|
438
|
+
const order = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
423
439
|
const qgEnabled = args.quality_gate ?? cfg.qualityGate
|
|
424
440
|
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|
|
425
441
|
k,
|
|
@@ -512,7 +528,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
512
528
|
const tasks = batchPrompts.map((item, i) => async () => {
|
|
513
529
|
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
514
530
|
const cached = await checkCache(seedBase + i, text)
|
|
515
|
-
return cached || await
|
|
531
|
+
return cached || await executeWithFallback(generators, order, seedBase + i, text, { logger: ctx.logger })
|
|
516
532
|
})
|
|
517
533
|
const parallelResults = await asyncPool(tasks, 3)
|
|
518
534
|
images.push(...parallelResults)
|
|
@@ -520,7 +536,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
520
536
|
const count = normalizeCount(args.count)
|
|
521
537
|
const tasks = Array.from({ length: count }, (_, i) => async () => {
|
|
522
538
|
const cached = await checkCache(seedBase + i, effectivePrompt)
|
|
523
|
-
return cached || await
|
|
539
|
+
return cached || await executeWithFallback(generators, order, seedBase + i, effectivePrompt, { logger: ctx.logger })
|
|
524
540
|
})
|
|
525
541
|
const parallelResults = await asyncPool(tasks, 3)
|
|
526
542
|
images.push(...parallelResults)
|
|
@@ -538,7 +554,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
538
554
|
cost: first.cost,
|
|
539
555
|
attachmentId: first.attachment?.attachmentId || 'N/A',
|
|
540
556
|
})
|
|
541
|
-
return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, ...first, images })
|
|
557
|
+
return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
|
|
542
558
|
},
|
|
543
559
|
}),
|
|
544
560
|
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// style-matrix.js — generate_style_matrix tool (#286).
|
|
2
|
+
// Parallel 2×2 style matrix explorer with blind A/B compare and one-click preset selection.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
|
+
import {
|
|
8
|
+
ASPECT_RATIOS,
|
|
9
|
+
PROVIDER_KEYS,
|
|
10
|
+
buildSidecar,
|
|
11
|
+
makeProviders,
|
|
12
|
+
toLosslessJson,
|
|
13
|
+
saveAttachmentSafe,
|
|
14
|
+
} from '../providers.js'
|
|
15
|
+
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_MATRIX_STYLES,
|
|
18
|
+
normalizeMatrixStyles,
|
|
19
|
+
buildMatrixCellPrompt,
|
|
20
|
+
formatMatrixMarkdown,
|
|
21
|
+
} from '../style-matrix-helpers.js'
|
|
22
|
+
|
|
23
|
+
async function asyncPool(tasks, concurrency = 2) {
|
|
24
|
+
const results = new Array(tasks.length)
|
|
25
|
+
let nextIdx = 0
|
|
26
|
+
async function worker() {
|
|
27
|
+
while (nextIdx < tasks.length) {
|
|
28
|
+
const idx = nextIdx++
|
|
29
|
+
results[idx] = await tasks[idx]()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
|
|
33
|
+
await Promise.all(workers)
|
|
34
|
+
return results
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function registerStyleMatrixTools(ctx, deps) {
|
|
38
|
+
const {
|
|
39
|
+
live,
|
|
40
|
+
slugify,
|
|
41
|
+
resolveApiKey,
|
|
42
|
+
} = deps
|
|
43
|
+
|
|
44
|
+
ctx.effect(() => {
|
|
45
|
+
ctx.tools.register(
|
|
46
|
+
defineTool({
|
|
47
|
+
name: 'generate_style_matrix',
|
|
48
|
+
description:
|
|
49
|
+
'Generate a 2×2 visual style exploration matrix for a single subject or concept. '
|
|
50
|
+
+ 'Renders 4 distinct artistic styles in parallel for rapid comparison or blind evaluation.',
|
|
51
|
+
parameters: {
|
|
52
|
+
prompt: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
required: true,
|
|
55
|
+
description: 'Core subject or scene description to benchmark across styles, e.g. "a lone astronaut sitting on Mars campfire".',
|
|
56
|
+
},
|
|
57
|
+
styles: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
description: 'Optional array of 4 style presets to compare. Defaults to: editorial_photo, flat_vector, clay_3d, cyberpunk.',
|
|
61
|
+
},
|
|
62
|
+
aspect_ratio: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
enum: ['1:1', '16:9', '4:3', '3:4'],
|
|
65
|
+
description: 'Aspect ratio for all matrix cells (default: 1:1).',
|
|
66
|
+
},
|
|
67
|
+
blind_mode: {
|
|
68
|
+
type: 'boolean',
|
|
69
|
+
description: 'When true, masks style names in the initial view to facilitate unbiased visual judging.',
|
|
70
|
+
},
|
|
71
|
+
seed: {
|
|
72
|
+
type: 'integer',
|
|
73
|
+
description: 'Base seed for the matrix generations.',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
output: {
|
|
77
|
+
schema: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: true,
|
|
80
|
+
properties: {
|
|
81
|
+
matrix_id: { type: 'string' },
|
|
82
|
+
base_prompt: { type: 'string' },
|
|
83
|
+
blind_mode: { type: 'boolean' },
|
|
84
|
+
cells: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
additionalProperties: true,
|
|
89
|
+
properties: {
|
|
90
|
+
id: { type: 'string' },
|
|
91
|
+
style: { type: 'string' },
|
|
92
|
+
path: { type: 'string' },
|
|
93
|
+
url: { type: 'string' },
|
|
94
|
+
seed: { type: 'integer' },
|
|
95
|
+
width: { type: 'integer' },
|
|
96
|
+
height: { type: 'integer' },
|
|
97
|
+
attachment: { type: 'object' },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
render(args, value) {
|
|
104
|
+
const summary = formatMatrixMarkdown({
|
|
105
|
+
basePrompt: value.base_prompt,
|
|
106
|
+
blindMode: value.blind_mode,
|
|
107
|
+
cells: value.cells || [],
|
|
108
|
+
})
|
|
109
|
+
return [{ type: 'text', text: summary }]
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
isConcurrencySafe: () => false,
|
|
113
|
+
async execute(args, exec) {
|
|
114
|
+
const cfg = live()
|
|
115
|
+
if (cfg.enabled === false) {
|
|
116
|
+
throw new Error('Image generation is disabled in settings.')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const basePrompt = String(args.prompt || '').trim()
|
|
120
|
+
if (!basePrompt) throw new Error('Parameter "prompt" is required.')
|
|
121
|
+
|
|
122
|
+
const activeStyles = normalizeMatrixStyles(args.styles)
|
|
123
|
+
const aspectRatio = args.aspect_ratio || '1:1'
|
|
124
|
+
const aspectPixels = ASPECT_RATIOS[aspectRatio] || [1024, 1024]
|
|
125
|
+
const blindMode = Boolean(args.blind_mode)
|
|
126
|
+
const baseSeed = args.seed ?? Math.floor(Math.random() * 100000)
|
|
127
|
+
|
|
128
|
+
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
129
|
+
let subscriptionImages
|
|
130
|
+
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_) { subscriptionImages = undefined }
|
|
131
|
+
|
|
132
|
+
const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
133
|
+
const quadrantIds = ['A', 'B', 'C', 'D']
|
|
134
|
+
const matrixId = `matrix-${Date.now().toString(36)}`
|
|
135
|
+
|
|
136
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
137
|
+
const targetDir = cfg.outputDir || 'generated/images'
|
|
138
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
139
|
+
await mkdir(outDir, { recursive: true })
|
|
140
|
+
|
|
141
|
+
const tasks = activeStyles.map((styleName, idx) => async () => {
|
|
142
|
+
const quadrantId = quadrantIds[idx] || `Q${idx + 1}`
|
|
143
|
+
const cellSeed = baseSeed + idx * 1013
|
|
144
|
+
const cellPrompt = buildMatrixCellPrompt(basePrompt, styleName)
|
|
145
|
+
|
|
146
|
+
const providers = makeProviders(
|
|
147
|
+
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
148
|
+
{
|
|
149
|
+
prompt: cellPrompt,
|
|
150
|
+
size: 'custom',
|
|
151
|
+
format: 'png',
|
|
152
|
+
seed: cellSeed,
|
|
153
|
+
signal: exec.signal,
|
|
154
|
+
aspectPixels,
|
|
155
|
+
aspectRatio,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const gen = await executeWithFallback(providers, chain, cellSeed, cellPrompt, {
|
|
160
|
+
logger: ctx.logger,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
const stem = `${slugify(`matrix-${styleName}-${basePrompt}`)}-${Date.now().toString(36)}-${cellSeed}`
|
|
164
|
+
const filename = `${stem}.png`
|
|
165
|
+
const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
|
|
166
|
+
bytes: gen.bytes,
|
|
167
|
+
mediaType: 'image/png',
|
|
168
|
+
name: filename,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const filePath = path.join(outDir, filename)
|
|
172
|
+
await writeFile(filePath, gen.bytes)
|
|
173
|
+
|
|
174
|
+
await writeFile(
|
|
175
|
+
path.join(outDir, `${stem}.json`),
|
|
176
|
+
JSON.stringify(buildSidecar({
|
|
177
|
+
prompt: cellPrompt,
|
|
178
|
+
size: `${gen.width || aspectPixels[0]}x${gen.height || aspectPixels[1]}`,
|
|
179
|
+
format: 'png',
|
|
180
|
+
seed: cellSeed,
|
|
181
|
+
provider: gen._fallback?.providerUsed || provider,
|
|
182
|
+
deliverAs: cfg.deliverAs || 'link',
|
|
183
|
+
width: gen.width || aspectPixels[0],
|
|
184
|
+
height: gen.height || aspectPixels[1],
|
|
185
|
+
mediaType: 'image/png',
|
|
186
|
+
attachmentId: attachment.attachmentId,
|
|
187
|
+
url: localUrl,
|
|
188
|
+
cost: gen.cost,
|
|
189
|
+
}), null, 2),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: quadrantId,
|
|
194
|
+
index: idx,
|
|
195
|
+
style: styleName,
|
|
196
|
+
prompt: cellPrompt,
|
|
197
|
+
seed: cellSeed,
|
|
198
|
+
path: filePath,
|
|
199
|
+
url: localUrl,
|
|
200
|
+
width: gen.width || aspectPixels[0],
|
|
201
|
+
height: gen.height || aspectPixels[1],
|
|
202
|
+
attachment,
|
|
203
|
+
cost: gen.cost,
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const cells = await asyncPool(tasks, 2)
|
|
208
|
+
|
|
209
|
+
const summary = formatMatrixMarkdown({
|
|
210
|
+
basePrompt,
|
|
211
|
+
blindMode,
|
|
212
|
+
cells,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return toLosslessJson({
|
|
216
|
+
matrix_id: matrixId,
|
|
217
|
+
base_prompt: basePrompt,
|
|
218
|
+
blind_mode: blindMode,
|
|
219
|
+
aspect_ratio: aspectRatio,
|
|
220
|
+
cells,
|
|
221
|
+
summary,
|
|
222
|
+
})
|
|
223
|
+
},
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
}, 'dsh-image-gen: tool generate_style_matrix')
|
|
227
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// ui-asset.js — generate_ui_asset tool (#284).
|
|
2
|
+
// Specialized generator for UI icons, stickers, illustrations, and layout-aware banners with negative space.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
|
+
import {
|
|
8
|
+
ASPECT_RATIOS,
|
|
9
|
+
PROVIDER_KEYS,
|
|
10
|
+
buildSidecar,
|
|
11
|
+
makeProviders,
|
|
12
|
+
removeBackgroundFal,
|
|
13
|
+
toLosslessJson,
|
|
14
|
+
saveAttachmentSafe,
|
|
15
|
+
} from '../providers.js'
|
|
16
|
+
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
17
|
+
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
18
|
+
import {
|
|
19
|
+
UI_ASSET_TYPES,
|
|
20
|
+
LAYOUT_COMPOSITIONS,
|
|
21
|
+
COLOR_MODES,
|
|
22
|
+
buildUiAssetPrompt,
|
|
23
|
+
} from '../ui-asset-helpers.js'
|
|
24
|
+
|
|
25
|
+
export function registerUiAssetTools(ctx, deps) {
|
|
26
|
+
const {
|
|
27
|
+
live,
|
|
28
|
+
slugify,
|
|
29
|
+
resolveApiKey,
|
|
30
|
+
} = deps
|
|
31
|
+
|
|
32
|
+
ctx.effect(() => {
|
|
33
|
+
ctx.tools.register(
|
|
34
|
+
defineTool({
|
|
35
|
+
name: 'generate_ui_asset',
|
|
36
|
+
description:
|
|
37
|
+
'Specialized generator for UI elements (icons, illustrations, stickers, badges, banners). '
|
|
38
|
+
+ 'Supports layout-aware composition with empty negative space for typography and automatic background removal.',
|
|
39
|
+
parameters: {
|
|
40
|
+
prompt: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
required: true,
|
|
43
|
+
description: 'Subject or motif to generate, e.g. "rocket taking off with smoke trail" or "shopping cart with notification dot".',
|
|
44
|
+
},
|
|
45
|
+
asset_type: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
enum: UI_ASSET_TYPES,
|
|
48
|
+
description: 'Type of UI element: icon, illustration, badge, sticker, or hero_banner (default: icon).',
|
|
49
|
+
},
|
|
50
|
+
transparent: {
|
|
51
|
+
type: 'boolean',
|
|
52
|
+
description: 'When true (default), automatically strips background into a clean transparent PNG.',
|
|
53
|
+
},
|
|
54
|
+
layout_composition: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
enum: LAYOUT_COMPOSITIONS,
|
|
57
|
+
description: 'Negative space layout: isolated (centered), left_empty (negative space on left for text), right_empty, top_empty, or center_empty.',
|
|
58
|
+
},
|
|
59
|
+
color_mode: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
enum: COLOR_MODES,
|
|
62
|
+
description: 'Color styling: flat (solid fills, default), monochrome, duotone, or full_color.',
|
|
63
|
+
},
|
|
64
|
+
aspect_ratio: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
67
|
+
description: 'Asset aspect ratio. Defaults to 1:1 (or 16:9 for hero_banner).',
|
|
68
|
+
},
|
|
69
|
+
output_name: {
|
|
70
|
+
type: 'string',
|
|
71
|
+
description: 'Optional file stem for saved asset.',
|
|
72
|
+
},
|
|
73
|
+
seed: {
|
|
74
|
+
type: 'integer',
|
|
75
|
+
description: 'Optional seed for reproducible generation.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
output: {
|
|
79
|
+
schema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
additionalProperties: true,
|
|
82
|
+
properties: {
|
|
83
|
+
path: { type: 'string' },
|
|
84
|
+
url: { type: 'string' },
|
|
85
|
+
asset_type: { type: 'string' },
|
|
86
|
+
layout_composition: { type: 'string' },
|
|
87
|
+
transparent: { type: 'boolean' },
|
|
88
|
+
width: { type: 'integer' },
|
|
89
|
+
height: { type: 'integer' },
|
|
90
|
+
attachment: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
additionalProperties: true,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
render(args, value) {
|
|
97
|
+
const summary = `Generated UI ${value.asset_type || 'asset'} (${value.layout_composition || 'isolated'}, ${value.transparent ? 'transparent' : 'opaque'}): ${value.path}`
|
|
98
|
+
return [{ type: 'text', text: summary }]
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
isConcurrencySafe: () => false,
|
|
102
|
+
async execute(args, exec) {
|
|
103
|
+
const cfg = live()
|
|
104
|
+
if (cfg.enabled === false) {
|
|
105
|
+
throw new Error('Image generation is disabled in settings.')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const asset_type = UI_ASSET_TYPES.includes(args.asset_type) ? args.asset_type : 'icon'
|
|
109
|
+
const layout_composition = LAYOUT_COMPOSITIONS.includes(args.layout_composition) ? args.layout_composition : 'isolated'
|
|
110
|
+
const color_mode = COLOR_MODES.includes(args.color_mode) ? args.color_mode : 'flat'
|
|
111
|
+
const transparent = args.transparent !== false
|
|
112
|
+
|
|
113
|
+
const defaultRatio = asset_type === 'hero_banner' ? '16:9' : '1:1'
|
|
114
|
+
const aspectRatio = args.aspect_ratio || defaultRatio
|
|
115
|
+
const aspectPixels = ASPECT_RATIOS[aspectRatio] || [1024, 1024]
|
|
116
|
+
|
|
117
|
+
const synthesizedPrompt = buildUiAssetPrompt({
|
|
118
|
+
prompt: args.prompt,
|
|
119
|
+
asset_type,
|
|
120
|
+
layout_composition,
|
|
121
|
+
color_mode,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
125
|
+
let subscriptionImages
|
|
126
|
+
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_) { subscriptionImages = undefined }
|
|
127
|
+
|
|
128
|
+
const providers = makeProviders(
|
|
129
|
+
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
130
|
+
{
|
|
131
|
+
prompt: synthesizedPrompt,
|
|
132
|
+
size: 'custom',
|
|
133
|
+
format: 'png',
|
|
134
|
+
seed: args.seed,
|
|
135
|
+
signal: exec.signal,
|
|
136
|
+
aspectPixels,
|
|
137
|
+
aspectRatio,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
142
|
+
const seedVal = args.seed ?? Math.floor(Math.random() * 100000)
|
|
143
|
+
|
|
144
|
+
const gen = await executeWithFallback(providers, chain, seedVal, synthesizedPrompt, {
|
|
145
|
+
logger: ctx.logger,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
let finalBytes = gen.bytes
|
|
149
|
+
let mediaType = gen.mediaType || 'image/png'
|
|
150
|
+
let width = gen.width || aspectPixels[0]
|
|
151
|
+
let height = gen.height || aspectPixels[1]
|
|
152
|
+
|
|
153
|
+
// Automatic background removal if requested and image is generated
|
|
154
|
+
if (transparent && finalBytes) {
|
|
155
|
+
try {
|
|
156
|
+
const bgResult = await removeBackgroundFal(
|
|
157
|
+
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
158
|
+
{ imageBytes: finalBytes, mediaType, signal: exec.signal },
|
|
159
|
+
)
|
|
160
|
+
if (bgResult && bgResult.bytes) {
|
|
161
|
+
finalBytes = bgResult.bytes
|
|
162
|
+
mediaType = 'image/png'
|
|
163
|
+
if (bgResult.width) width = bgResult.width
|
|
164
|
+
if (bgResult.height) height = bgResult.height
|
|
165
|
+
}
|
|
166
|
+
} catch (bgErr) {
|
|
167
|
+
if (ctx.logger && typeof ctx.logger.warn === 'function') {
|
|
168
|
+
ctx.logger.warn(`[generate_ui_asset] Transparent background removal skipped: ${bgErr.message}`)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const stem = `${slugify(args.output_name || `${asset_type}-${args.prompt}`)}-${Date.now().toString(36)}-${seedVal}`
|
|
174
|
+
const filename = `${stem}.png`
|
|
175
|
+
const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
|
|
176
|
+
bytes: finalBytes,
|
|
177
|
+
mediaType: 'image/png',
|
|
178
|
+
name: filename,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
182
|
+
const targetDir = cfg.outputDir || 'generated/images'
|
|
183
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
184
|
+
await mkdir(outDir, { recursive: true })
|
|
185
|
+
const filePath = path.join(outDir, filename)
|
|
186
|
+
await writeFile(filePath, finalBytes)
|
|
187
|
+
|
|
188
|
+
await writeFile(
|
|
189
|
+
path.join(outDir, `${stem}.json`),
|
|
190
|
+
JSON.stringify(buildSidecar({
|
|
191
|
+
prompt: synthesizedPrompt,
|
|
192
|
+
size: `${width}x${height}`,
|
|
193
|
+
format: 'png',
|
|
194
|
+
seed: seedVal,
|
|
195
|
+
provider: gen._fallback?.providerUsed || provider,
|
|
196
|
+
deliverAs: cfg.deliverAs || 'link',
|
|
197
|
+
width,
|
|
198
|
+
height,
|
|
199
|
+
mediaType: 'image/png',
|
|
200
|
+
attachmentId: attachment.attachmentId,
|
|
201
|
+
url: localUrl,
|
|
202
|
+
cost: gen.cost,
|
|
203
|
+
}), null, 2),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const dualOutput = buildDualOutputMarkdown({
|
|
207
|
+
action: 'generated',
|
|
208
|
+
filePath,
|
|
209
|
+
width,
|
|
210
|
+
height,
|
|
211
|
+
mediaType: 'image/png',
|
|
212
|
+
seed: seedVal,
|
|
213
|
+
provider: gen._fallback?.providerUsed || provider,
|
|
214
|
+
model: cfg.model || cfg.customModel || 'ui-asset',
|
|
215
|
+
cost: gen.cost,
|
|
216
|
+
attachmentId: attachment.attachmentId,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return toLosslessJson({
|
|
220
|
+
summary: dualOutput,
|
|
221
|
+
path: filePath,
|
|
222
|
+
url: localUrl,
|
|
223
|
+
asset_type,
|
|
224
|
+
layout_composition,
|
|
225
|
+
color_mode,
|
|
226
|
+
transparent,
|
|
227
|
+
width,
|
|
228
|
+
height,
|
|
229
|
+
seed: seedVal,
|
|
230
|
+
format: 'png',
|
|
231
|
+
attachment,
|
|
232
|
+
_fallback: gen._fallback,
|
|
233
|
+
})
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
)
|
|
237
|
+
}, 'dsh-image-gen: tool generate_ui_asset')
|
|
238
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ui-asset-helpers.js — Helper constants and prompt builder for generate_ui_asset (#284).
|
|
2
|
+
|
|
3
|
+
export const UI_ASSET_TYPES = ['icon', 'illustration', 'badge', 'sticker', 'hero_banner']
|
|
4
|
+
export const LAYOUT_COMPOSITIONS = ['isolated', 'left_empty', 'right_empty', 'top_empty', 'center_empty']
|
|
5
|
+
export const COLOR_MODES = ['flat', 'monochrome', 'duotone', 'full_color']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Augments the user's prompt with specialized UI design directives and negative space constraints.
|
|
9
|
+
*/
|
|
10
|
+
export function buildUiAssetPrompt({
|
|
11
|
+
prompt,
|
|
12
|
+
asset_type = 'icon',
|
|
13
|
+
layout_composition = 'isolated',
|
|
14
|
+
color_mode = 'flat',
|
|
15
|
+
}) {
|
|
16
|
+
const typeMap = {
|
|
17
|
+
icon: 'crisp minimalist app icon, vector iconographic style, sharp contours, modern UI asset',
|
|
18
|
+
illustration: 'digital editorial tech illustration, modern flat vector scene, web app asset',
|
|
19
|
+
badge: 'gamified badge emblem, achievement icon, metallic and enamel accents, clean vector badge',
|
|
20
|
+
sticker: 'die-cut vector sticker graphic, bold clean outlines, crisp edges, sticker asset',
|
|
21
|
+
hero_banner: 'marketing website hero illustration, modern corporate SaaS graphic',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const layoutMap = {
|
|
25
|
+
isolated: 'centered single subject, solid plain white background, generous empty padding on all sides, clean isolation',
|
|
26
|
+
left_empty: 'subject placed strictly on the right half, left 60% entirely empty negative space copy space for headlines and UI text with plain flat background',
|
|
27
|
+
right_empty: 'subject placed strictly on the left half, right 60% entirely empty negative space copy space for headlines and UI text with plain flat background',
|
|
28
|
+
top_empty: 'subject anchored at bottom edge, upper 60% completely empty negative space copy space for banner typography',
|
|
29
|
+
center_empty: 'composition framed around outer borders and corners, center area completely empty negative space for search bar or central logo',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const colorMap = {
|
|
33
|
+
flat: 'flat vector colors, clean solid fills, no photorealism, no noisy textures',
|
|
34
|
+
monochrome: 'monochromatic single-color palette, high contrast minimal style',
|
|
35
|
+
duotone: 'two-tone duotone aesthetic, sharp complementary contrast',
|
|
36
|
+
full_color: 'vibrant modern UI color scheme, harmonious hex accents',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const baseType = typeMap[asset_type] || typeMap.icon
|
|
40
|
+
const layout = layoutMap[layout_composition] || layoutMap.isolated
|
|
41
|
+
const color = colorMap[color_mode] || colorMap.flat
|
|
42
|
+
|
|
43
|
+
return `${String(prompt || '').trim()}, ${baseType}, ${layout}, ${color}`
|
|
44
|
+
}
|
package/lib/updater.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { isLoopbackAddress, isPrivateLanAddress, extractHostName } from './security.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Host-side one-click updater for @goodandready/dsh-image-gen.
|
|
@@ -21,31 +22,17 @@ function header(request, name) {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
function isLoopback(value) {
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
address === 'localhost' ||
|
|
27
|
-
address === 'localhost.' ||
|
|
28
|
-
address === '::1' ||
|
|
29
|
-
address?.startsWith('127.') === true ||
|
|
30
|
-
address?.startsWith('::ffff:127.') === true
|
|
31
|
-
)
|
|
25
|
+
return isLoopbackAddress(value)
|
|
32
26
|
}
|
|
33
27
|
|
|
34
28
|
function isPrivateLan(value) {
|
|
35
|
-
|
|
36
|
-
if (!address) return false
|
|
37
|
-
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
|
|
38
|
-
return (
|
|
39
|
-
ipv4.startsWith('192.168.') ||
|
|
40
|
-
ipv4.startsWith('10.') ||
|
|
41
|
-
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
|
|
42
|
-
)
|
|
29
|
+
return isPrivateLanAddress(value)
|
|
43
30
|
}
|
|
44
31
|
|
|
45
32
|
export function isTrustedUpdateRequest(request) {
|
|
46
33
|
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
47
34
|
const remote = request.socket?.remoteAddress
|
|
48
|
-
if (!
|
|
35
|
+
if (!isLoopbackAddress(remote) && !isPrivateLanAddress(remote)) return false
|
|
49
36
|
const site = header(request, 'sec-fetch-site')
|
|
50
37
|
if (site !== undefined && site !== 'same-origin') return false
|
|
51
38
|
const origin = header(request, 'origin')
|
|
@@ -53,10 +40,13 @@ export function isTrustedUpdateRequest(request) {
|
|
|
53
40
|
if (origin === undefined || host === undefined) return false
|
|
54
41
|
try {
|
|
55
42
|
const url = new URL(origin)
|
|
43
|
+
const originHostName = extractHostName(url.host)
|
|
44
|
+
const hostName = extractHostName(host)
|
|
56
45
|
return (
|
|
57
46
|
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
58
|
-
(
|
|
59
|
-
|
|
47
|
+
(isLoopbackAddress(originHostName) || isPrivateLanAddress(originHostName)) &&
|
|
48
|
+
(isLoopbackAddress(hostName) || isPrivateLanAddress(hostName)) &&
|
|
49
|
+
url.host.toLowerCase() === host.toLowerCase()
|
|
60
50
|
)
|
|
61
51
|
} catch {
|
|
62
52
|
return false
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
4
|
-
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers
|
|
3
|
+
"version": "0.10.32",
|
|
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",
|
|
7
7
|
"dsh",
|