@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.
@@ -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
- const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
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 = fallbackOrder(provider)
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 tryGenerate(generators, order, seedBase + i, text)
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 tryGenerate(generators, order, seedBase + i)
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
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
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
- const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
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 (!isLoopback(remote) && !isPrivateLan(remote)) return false
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
- (isLoopback(url.hostname) || isPrivateLan(url.hostname)) &&
59
- url.host === host
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.30",
4
- "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 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).",
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",