@goodandready/dsh-image-gen 0.11.23 → 0.11.25

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.
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
- import { saveAttachmentSafe, buildSidecar, toLosslessJson } from './providers.js'
3
+ import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
4
4
  import { recordSpend } from './cost-meter.js'
5
5
  import { buildDualOutputMarkdown } from './resolve-image.js'
6
6
 
@@ -21,6 +21,10 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
21
21
  args = {},
22
22
  action = 'generated',
23
23
  }) {
24
+ const targetFormat = format || (mediaType ? mediaType.replace('image/', '') : undefined)
25
+ const formatted = await ensureImageFormat(bytes, targetFormat, { fallbackMime: mediaType })
26
+ bytes = formatted.bytes
27
+ mediaType = formatted.mediaType
24
28
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
25
29
 
26
30
  const sessionCwd = exec?.agent?.session?.header?.cwd
package/lib/client.js CHANGED
@@ -264,6 +264,40 @@ window.__ModuleLoader__.load({
264
264
  }
265
265
  }
266
266
 
267
+ function arrayField(field) {
268
+ return {
269
+ field,
270
+ format: (value) => (Array.isArray(value) ? value.join(', ') : typeof value === 'string' ? value : ''),
271
+ parse: (text) => {
272
+ if (text === '') return { kind: 'clear' }
273
+ const items = text.split(',').map((s) => s.trim()).filter(Boolean)
274
+ return { kind: 'set', value: items }
275
+ },
276
+ }
277
+ }
278
+
279
+ function jsonField(field) {
280
+ return {
281
+ field,
282
+ format: (value) => {
283
+ if (typeof value === 'string') return value
284
+ if (value && typeof value === 'object') {
285
+ try { return JSON.stringify(value, null, 2) } catch { return '' }
286
+ }
287
+ return ''
288
+ },
289
+ parse: (text) => {
290
+ if (text === '') return { kind: 'clear' }
291
+ try {
292
+ JSON.parse(text)
293
+ return { kind: 'set', value: text }
294
+ } catch (_) {
295
+ return void 0
296
+ }
297
+ },
298
+ }
299
+ }
300
+
267
301
  const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
268
302
  const IMAGE_SIZES = ['landscape_4_3', 'landscape_16_9', 'portrait_4_3', 'portrait_16_9', 'square', 'square_hd']
269
303
  const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
@@ -328,10 +362,11 @@ window.__ModuleLoader__.load({
328
362
  // Backward-compat aliases for tests
329
363
  { field: 'localComfyUrl', spec: textField('localComfyUrl'), kind: 'text', labelKey: 'f.localComfyUrl', hintKey: 'f.localComfyUrlHint', placeholderKey: 'p.localComfyUrl', when: ['local'], tab: 'provider' },
330
364
  { field: 'localA1111Url', spec: textField('localA1111Url'), kind: 'text', labelKey: 'f.localA1111Url', hintKey: 'f.localA1111UrlHint', placeholderKey: 'p.localA1111Url', when: ['local'], tab: 'provider' },
365
+ { field: 'comfyWorkflowJson', spec: jsonField('comfyWorkflowJson'), kind: 'text', labelKey: 'f.comfyWorkflowJson', hintKey: 'f.comfyWorkflowJsonHint', placeholderKey: 'p.comfyWorkflowJson', when: ['local'], tab: 'provider' },
331
366
 
332
367
  // Provider: Codex / Grok
333
368
  { field: 'subscriptionQuality', spec: selectField('subscriptionQuality', ['', 'low', 'medium', 'high']), kind: 'select', options: ['', 'low', 'medium', 'high'], labelKey: 'f.subscriptionQuality', hintKey: 'f.subscriptionQualityHint', when: ['codex', 'grok'], tab: 'provider' },
334
- { field: 'fallbackProviders', spec: textField('fallbackProviders'), kind: 'text', labelKey: 'f.fallbackProviders', hintKey: 'f.fallbackProvidersHint', placeholderKey: 'p.fallbackProviders', tab: 'provider' },
369
+ { field: 'fallbackProviders', spec: arrayField('fallbackProviders'), kind: 'text', labelKey: 'f.fallbackProviders', hintKey: 'f.fallbackProvidersHint', placeholderKey: 'p.fallbackProviders', tab: 'provider' },
335
370
 
336
371
  // Enhancer & Style
337
372
  { field: 'autoEnhancePrompt', spec: booleanField('autoEnhancePrompt'), kind: 'select', options: ['true', 'false'], labelKey: 'f.autoEnhancePrompt', hintKey: 'f.autoEnhancePromptHint', tab: 'enhancer' },
@@ -2548,6 +2583,9 @@ window.__ModuleLoader__.load({
2548
2583
  'f.localA1111Url': 'Local A1111 URL (Legacy)',
2549
2584
  'f.localA1111UrlHint': 'Direct Automatic1111 endpoint URL.',
2550
2585
  'p.localA1111Url': 'http://127.0.0.1:7860',
2586
+ 'f.comfyWorkflowJson': 'ComfyUI Workflow JSON',
2587
+ 'f.comfyWorkflowJsonHint': 'Custom ComfyUI API workflow graph (JSON) with {{prompt}} placeholders.',
2588
+ 'p.comfyWorkflowJson': '{"3": {"class_type": "KSampler", ...}}',
2551
2589
 
2552
2590
  'f.subscriptionQuality': 'Subscription Quality',
2553
2591
  'f.subscriptionQualityHint': 'Target quality profile when using Codex or Grok subscriptions.',
@@ -2824,6 +2862,9 @@ window.__ModuleLoader__.load({
2824
2862
  'f.localA1111Url': '本地 A1111 地址 (Legacy)',
2825
2863
  'f.localA1111UrlHint': '直接指向 Automatic1111 的服务地址。',
2826
2864
  'p.localA1111Url': 'http://127.0.0.1:7860',
2865
+ 'f.comfyWorkflowJson': 'ComfyUI 工作流 JSON',
2866
+ 'f.comfyWorkflowJsonHint': '包含 {{prompt}} 等占位符的自定义 ComfyUI API 工作流 JSON。',
2867
+ 'p.comfyWorkflowJson': '{"3": {"class_type": "KSampler", ...}}',
2827
2868
 
2828
2869
  'f.subscriptionQuality': '订阅质量',
2829
2870
  'f.subscriptionQualityHint': 'Codex 或 Grok 订阅通道的质量档位。',
@@ -3009,31 +3050,80 @@ window.__ModuleLoader__.load({
3009
3050
  const target = activeTarget || resolveActiveScope(ctx)
3010
3051
  const snap = target?.getSnapshot ? target.getSnapshot() : null
3011
3052
  if (snap?.status === 'ready' && snap.writable && target.set) return target.set(key, val)
3053
+ const prevValue = httpSnapshot.value
3012
3054
  httpSnapshot = { ...httpSnapshot, value: { ...httpSnapshot.value, [key]: val } }
3013
3055
  notify()
3014
3056
  try {
3015
- await fetch('/dsh-image-gen/config', {
3057
+ const res = await fetch('/dsh-image-gen/config', {
3016
3058
  method: 'PUT',
3017
3059
  headers: { 'Content-Type': 'application/json' },
3018
3060
  body: JSON.stringify({ [key]: val }),
3019
3061
  })
3020
- } catch { /* ignore */ }
3062
+ if (!res.ok) {
3063
+ let errText = `Failed to update setting (HTTP ${res.status})`
3064
+ try {
3065
+ const errData = await res.json()
3066
+ if (errData?.error) errText = errData.error
3067
+ } catch { /* ignore */ }
3068
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3069
+ notify()
3070
+ throw new Error(errText)
3071
+ }
3072
+ const data = await res.json()
3073
+ if (!data.ok) {
3074
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3075
+ notify()
3076
+ throw new Error(data.error || 'Failed to update setting')
3077
+ }
3078
+ if (data.config) {
3079
+ httpSnapshot = { status: 'ready', writable: true, value: data.config }
3080
+ notify()
3081
+ }
3082
+ return data
3083
+ } catch (err) {
3084
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3085
+ notify()
3086
+ throw err
3087
+ }
3021
3088
  },
3022
3089
  async delete(key) {
3023
3090
  const target = activeTarget || resolveActiveScope(ctx)
3024
3091
  const snap = target?.getSnapshot ? target.getSnapshot() : null
3025
- if (snap?.status === 'ready' && snap.writable && target.delete) return target.delete(key)
3092
+ if (snap?.status === 'ready' && snap.writable && (target.delete || target.reset)) {
3093
+ if (target.delete) return target.delete(key)
3094
+ if (target.reset) return target.reset(key)
3095
+ }
3096
+ const prevValue = httpSnapshot.value
3026
3097
  const nextVal = { ...httpSnapshot.value }
3027
3098
  delete nextVal[key]
3028
3099
  httpSnapshot = { ...httpSnapshot, value: nextVal }
3029
3100
  notify()
3030
3101
  try {
3031
- await fetch('/dsh-image-gen/config', {
3102
+ const res = await fetch('/dsh-image-gen/config', {
3032
3103
  method: 'PUT',
3033
3104
  headers: { 'Content-Type': 'application/json' },
3034
- body: JSON.stringify({ [key]: undefined }),
3105
+ body: JSON.stringify({ resetFields: [key] }),
3035
3106
  })
3036
- } catch { /* ignore */ }
3107
+ if (!res.ok) {
3108
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3109
+ notify()
3110
+ throw new Error(`Failed to reset setting (HTTP ${res.status})`)
3111
+ }
3112
+ const data = await res.json()
3113
+ if (!data.ok) {
3114
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3115
+ notify()
3116
+ throw new Error(data.error || 'Failed to reset setting')
3117
+ }
3118
+ if (data.config) {
3119
+ httpSnapshot = { status: 'ready', writable: true, value: data.config }
3120
+ notify()
3121
+ }
3122
+ } catch (err) {
3123
+ httpSnapshot = { ...httpSnapshot, value: prevValue }
3124
+ notify()
3125
+ throw err
3126
+ }
3037
3127
  },
3038
3128
  subscribe(fn) {
3039
3129
  listeners.add(fn)
package/lib/commands.js CHANGED
@@ -85,11 +85,14 @@ export function registerImageCommand(ctx, deps) {
85
85
  const size = flags.size || cfg.defaultSize || 'square'
86
86
  const format = flags.format || cfg.defaultFormat || 'png'
87
87
  const aspectRatio = flags.aspect || flags.aspect_ratio || cfg.defaultAspectRatio
88
- const effectiveStyle = flags.style || cfg.stylePreset
88
+ const effectivePreset = (flags.style_preset !== undefined && flags.style_preset !== null && flags.style_preset !== '')
89
+ ? flags.style_preset
90
+ : (flags.style || cfg.stylePreset || cfg.defaultStylePreset)
89
91
  const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
90
92
 
91
- const styleInfo = resolveStylePreset(effectiveStyle, flags.negative_prompt, flags.guidance_scale)
92
- const effectivePrompt = applyStylePreset(prompt, effectiveStyle)
93
+ const styleInfo = resolveStylePreset(effectivePreset, flags.negative_prompt, flags.guidance_scale)
94
+ const effectivePrompt = styleInfo.promptSuffix ? `${prompt}, ${styleInfo.promptSuffix}` : prompt
95
+ const effectiveNegative = styleInfo.negativePrompt || flags.negative_prompt || cfg.negativePrompt
93
96
 
94
97
  const job = {
95
98
  prompt: effectivePrompt,
@@ -99,6 +102,9 @@ export function registerImageCommand(ctx, deps) {
99
102
  aspectPixels: aspectRatio ? ASPECT_RATIOS[aspectRatio] : undefined,
100
103
  seed,
101
104
  signal: invocation?.signal,
105
+ negativePrompt: effectiveNegative,
106
+ guidanceScale: styleInfo.guidanceScale ?? flags.guidance_scale,
107
+ style: flags.style,
102
108
  }
103
109
 
104
110
  const pdeps = {
@@ -176,8 +176,8 @@ export const Config = z.object({
176
176
  .volatile(),
177
177
  geminiModel: z
178
178
  .string()
179
- .description('Google Gemini Imagen model identifier.')
180
- .default('imagen-3.0-generate-002')
179
+ .description('Google Gemini image generation model identifier.')
180
+ .default('gemini-2.0-flash-exp-image-generation')
181
181
  .volatile(),
182
182
  geminiKeyEnv: z
183
183
  .string()
@@ -215,6 +215,34 @@ export const Config = z.object({
215
215
  .description('CFG scale for local generation.')
216
216
  .default(7.0)
217
217
  .volatile(),
218
+ comfyWorkflowJson: z
219
+ .string()
220
+ .description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
221
+ .default('')
222
+ .volatile(),
223
+ localComfyUrl: z
224
+ .string()
225
+ .description('Legacy alias for ComfyUI URL.')
226
+ .default('')
227
+ .volatile(),
228
+ localA1111Url: z
229
+ .string()
230
+ .description('Legacy alias for A1111 URL.')
231
+ .default('')
232
+ .volatile(),
233
+ enableLlmEnhancer: z
234
+ .boolean()
235
+ .description('Legacy alias for enhancePrompt.')
236
+ .default(false)
237
+ .volatile(),
238
+ cacheTtlDays: z
239
+ .number()
240
+ .min(0)
241
+ .max(3650)
242
+ .step(1)
243
+ .description('Legacy alias for pruneDays.')
244
+ .default(0)
245
+ .volatile(),
218
246
  subscriptionQuality: z
219
247
  .string()
220
248
  .description('Image quality tier for subscription accounts: "standard" or "hd".')
@@ -354,6 +382,26 @@ export function sanitizeRaw(raw) {
354
382
  if (cleaned.localKind === 'automatic1111') {
355
383
  cleaned.localKind = 'a1111'
356
384
  }
385
+ // Coerce string fallbackProviders to array (#369)
386
+ if (typeof cleaned.fallbackProviders === 'string') {
387
+ cleaned.fallbackProviders = cleaned.fallbackProviders
388
+ .split(',')
389
+ .map((s) => s.trim())
390
+ .filter(Boolean)
391
+ }
392
+ // Migrate legacy fields (#369)
393
+ if (cleaned.localComfyUrl && !cleaned.localBaseURL && cleaned.localKind === 'comfyui') {
394
+ cleaned.localBaseURL = cleaned.localComfyUrl
395
+ }
396
+ if (cleaned.localA1111Url && !cleaned.localBaseURL && cleaned.localKind === 'a1111') {
397
+ cleaned.localBaseURL = cleaned.localA1111Url
398
+ }
399
+ if (typeof cleaned.enableLlmEnhancer === 'boolean' && cleaned.enhancePrompt === undefined) {
400
+ cleaned.enhancePrompt = cleaned.enableLlmEnhancer
401
+ }
402
+ if (typeof cleaned.cacheTtlDays === 'number' && cleaned.cacheTtlDays > 0 && cleaned.pruneDays === undefined) {
403
+ cleaned.pruneDays = cleaned.cacheTtlDays
404
+ }
357
405
  return cleaned
358
406
  }
359
407
 
@@ -13,6 +13,7 @@ import {
13
13
  embedPngMetadata,
14
14
  resolveStylePreset,
15
15
  saveAttachmentSafe,
16
+ ensureImageFormat,
16
17
  } from './providers.js'
17
18
  import { enhancePrompt } from './prompt-enhancer.js'
18
19
  import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
@@ -23,6 +24,7 @@ import {
23
24
  readHistory,
24
25
  writeHistory,
25
26
  pruneHistory,
27
+ appendHistoryEntry,
26
28
  } from './history.js'
27
29
 
28
30
  /** Run an array of async task functions with a concurrency cap */
@@ -62,8 +64,10 @@ export function validateGenerationParams(cfg, args) {
62
64
  /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
65
  export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
66
  const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
- const effectiveStyle = args.style || cfg.stylePreset
66
- const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ const candidatePreset = (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
68
+ ? args.style_preset
69
+ : (cfg.stylePreset || cfg.defaultStylePreset)
70
+ const styleInfo = resolveStylePreset(candidatePreset, args.negative_prompt, args.guidance_scale)
67
71
  let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
72
  let effectiveNegative = styleInfo.negativePrompt
69
73
  const activeAnchor = getSessionAnchor(sessionId)
@@ -132,8 +136,10 @@ export async function saveGeneratedImageArtifact({
132
136
  estimatedCost,
133
137
  totalCount,
134
138
  }) {
135
- const mediaType = gen.mediaType || 'image/png'
136
- let bytes = gen.bytes
139
+ const targetFormat = format || (args.output_format || cfg.defaultFormat)
140
+ const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
141
+ let bytes = formatted.bytes
142
+ const mediaType = formatted.mediaType || 'image/png'
137
143
  if (mediaType === 'image/png') {
138
144
  bytes = embedPngMetadata(bytes, {
139
145
  prompt: promptArg,
@@ -189,7 +195,10 @@ export async function saveGeneratedImageArtifact({
189
195
  prompt: promptArg,
190
196
  seed: gen.seed,
191
197
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
192
- style: args.style || cfg.stylePreset,
198
+ style: args.style,
199
+ stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
200
+ ? args.style_preset
201
+ : (cfg.stylePreset || cfg.defaultStylePreset),
193
202
  format: format || args.output_format || cfg.defaultFormat || 'png',
194
203
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
195
204
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
@@ -229,13 +238,10 @@ export async function saveGeneratedImageArtifact({
229
238
  attachmentId: attachment.attachmentId,
230
239
  }
231
240
  // Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
241
+ // #365: Atomic transaction with file lock prevents read-modify-write race during concurrent batches
232
242
  const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
233
243
  if (historyLimit > 0) {
234
- let current = await readHistory()
235
- current = await pruneHistory(current, cfg.pruneDays)
236
- current.unshift(entry)
237
- if (current.length > historyLimit) current.length = historyLimit
238
- await writeHistory(current)
244
+ await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
239
245
  }
240
246
 
241
247
  // #167: Record spend in cost meter
package/lib/history.js CHANGED
@@ -1,9 +1,30 @@
1
1
  import path from 'node:path'
2
2
  import os from 'node:os'
3
3
  import { existsSync, unlinkSync, chmodSync, readdirSync, statSync } from 'node:fs'
4
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
4
+ import { readFile, mkdir } from 'node:fs/promises'
5
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
5
6
  import { enforceSecurePermissions } from './security.js'
6
7
 
8
+ let inMemoryLockQueue = Promise.resolve()
9
+
10
+ /** Execute an operation with in-process queue + cross-process file lock (#365) */
11
+ export async function withHistoryLock(operation, options = { waitMs: 5000 }) {
12
+ const file = historyFile()
13
+ const dir = path.dirname(file)
14
+ try {
15
+ await mkdir(dir, { recursive: true, mode: 0o700 })
16
+ try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
17
+ } catch (_err) { /* non-fatal mkdir */ }
18
+
19
+ const runWithLocks = async () => {
20
+ return await withFileLock(file, operation, options)
21
+ }
22
+
23
+ const next = inMemoryLockQueue.then(runWithLocks, runWithLocks)
24
+ inMemoryLockQueue = next.catch(() => {})
25
+ return await next
26
+ }
27
+
7
28
  export function historyFile() {
8
29
  return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
9
30
  }
@@ -104,21 +125,50 @@ export async function readHistory() {
104
125
  const raw = await readFile(historyFile(), 'utf-8')
105
126
  const parsed = JSON.parse(raw)
106
127
  return Array.isArray(parsed) ? parsed : []
107
- } catch (e) {
128
+ } catch (_e) {
108
129
  return []
109
130
  }
110
131
  }
111
132
 
112
- /** Save history to disk file (atomic overwrite). */
133
+ /** Save history to disk file (atomic overwrite with 0600 mode via dsh-atomic-write). */
113
134
  export async function writeHistory(entries) {
114
135
  try {
115
136
  const file = historyFile()
116
137
  const dir = path.dirname(file)
117
138
  await mkdir(dir, { recursive: true, mode: 0o700 })
118
139
  try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
119
- await writeFile(file, JSON.stringify(entries, null, 2), { mode: 0o600 })
140
+ await writeFileAtomic(file, JSON.stringify(entries, null, 2), {
141
+ mode: 0o600,
142
+ dirMode: 0o700,
143
+ })
120
144
  enforceSecurePermissions(file)
121
- } catch (e) { /* history write failure is non-fatal */ }
145
+ } catch (_e) { /* history write failure is non-fatal */ }
146
+ }
147
+
148
+ /**
149
+ * Atomically append an entry to history, running read -> prune -> unshift -> write
150
+ * under an exclusive lock transaction (#365).
151
+ */
152
+ export async function appendHistoryEntry(entry, { pruneDays = 0, historyLimit = 50 } = {}) {
153
+ if (!entry || historyLimit <= 0) return []
154
+ return await withHistoryLock(async () => {
155
+ let current = await readHistory()
156
+ if (pruneDays > 0) {
157
+ current = await pruneHistory(current, pruneDays)
158
+ }
159
+ const existingIdx = current.findIndex(
160
+ (e) => (entry.attachmentId && e.attachmentId === entry.attachmentId) || (entry.path && e.path === entry.path)
161
+ )
162
+ if (existingIdx !== -1) {
163
+ current.splice(existingIdx, 1)
164
+ }
165
+ current.unshift(entry)
166
+ if (current.length > historyLimit) {
167
+ current.length = historyLimit
168
+ }
169
+ await writeHistory(current)
170
+ return current
171
+ })
122
172
  }
123
173
 
124
174
  /** Filter entries whose files exist on disk; newest first. */
package/lib/index.js CHANGED
@@ -114,6 +114,74 @@ export { buildEnhancePromptSystemMessage } from './prompt-enhancer.js'
114
114
  * Canonical implementation lives in prompt-enhancer.js; re-exported for backwards compat. */
115
115
  export { enhancePrompt } from './prompt-enhancer.js'
116
116
 
117
+ export function createSettingsAdapter(svc, { ns = NS, getLive = () => ({}), setLive = () => {} } = {}) {
118
+ if (!svc) return undefined
119
+ if (typeof svc.replace === 'function' || typeof svc.update === 'function' || typeof svc.mutate === 'function') {
120
+ const getRevision = () => {
121
+ try {
122
+ return svc.describe?.().find((row) => row.ns === ns)?.revision
123
+ } catch (_) {
124
+ return undefined
125
+ }
126
+ }
127
+ return {
128
+ get: () => getLive(),
129
+ replace: async (next) => {
130
+ const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
131
+ const payload = volatileConfig(parsed)
132
+ if (typeof svc.replace === 'function') {
133
+ await svc.replace(ns, payload, getRevision())
134
+ } else if (typeof svc.update === 'function') {
135
+ await svc.update(ns, payload, getRevision())
136
+ } else if (typeof svc.mutate === 'function') {
137
+ await svc.mutate(ns, payload, getRevision())
138
+ } else {
139
+ throw new Error('Settings service has no replace, update, or mutate method')
140
+ }
141
+ setLive(parsed)
142
+ return parsed
143
+ },
144
+ update: async (patch) => {
145
+ const merged = plainConfig(Config({ ...publicConfig(getLive()), ...plainConfig(patch) }))
146
+ const payload = volatileConfig(merged)
147
+ if (typeof svc.update === 'function') {
148
+ await svc.update(ns, payload, getRevision())
149
+ } else if (typeof svc.replace === 'function') {
150
+ await svc.replace(ns, payload, getRevision())
151
+ } else if (typeof svc.mutate === 'function') {
152
+ await svc.mutate(ns, payload, getRevision())
153
+ } else {
154
+ throw new Error('Settings service has no update, replace, or mutate method')
155
+ }
156
+ setLive(merged)
157
+ return merged
158
+ },
159
+ delete: async (key) => {
160
+ const base = structuredClone(plainConfig(getLive()) || {})
161
+ delete base[key]
162
+ const merged = plainConfig(Config(base))
163
+ const payload = volatileConfig(merged)
164
+ if (typeof svc.replace === 'function') {
165
+ await svc.replace(ns, payload, getRevision())
166
+ } else if (typeof svc.update === 'function') {
167
+ await svc.update(ns, payload, getRevision())
168
+ } else if (typeof svc.mutate === 'function') {
169
+ await svc.mutate(ns, payload, getRevision())
170
+ } else {
171
+ throw new Error('Settings service has no replace, update, or mutate method')
172
+ }
173
+ setLive(merged)
174
+ return merged
175
+ },
176
+ watch: (cb) => {
177
+ if (typeof svc.watch === 'function') return svc.watch(cb)
178
+ return () => {}
179
+ },
180
+ }
181
+ }
182
+ return undefined
183
+ }
184
+
117
185
  export function apply(ctx, rawConfig) {
118
186
  const config = ensureConfig(rawConfig)
119
187
  const baseConfig = plainConfig(config)
@@ -130,50 +198,18 @@ export function apply(ctx, rawConfig) {
130
198
 
131
199
  let settingsApi
132
200
  let currentLiveConfig = null
201
+ let toolController = null
133
202
  let getConfig = () => currentLiveConfig || config
134
203
  const live = () => plainConfig(getConfig() ?? {})
135
204
 
136
- const createSettingsAdapter = (svc) => {
137
- if (!svc) return undefined
138
- if (typeof svc.replace === 'function' || typeof svc.update === 'function' || typeof svc.mutate === 'function') {
139
- const getRevision = () => {
140
- try {
141
- return svc.describe?.().find((row) => row.ns === NS)?.revision
142
- } catch (_) {
143
- return undefined
144
- }
145
- }
146
- return {
147
- get: () => live(),
148
- replace: async (next) => {
149
- const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
150
- currentLiveConfig = parsed
151
- const payload = volatileConfig(parsed)
152
- if (typeof svc.replace === 'function') {
153
- await svc.replace(NS, payload, getRevision())
154
- } else if (typeof svc.update === 'function') {
155
- await svc.update(NS, payload, getRevision())
156
- }
157
- return parsed
158
- },
159
- update: async (patch) => {
160
- const merged = plainConfig(Config({ ...publicConfig(live()), ...plainConfig(patch) }))
161
- currentLiveConfig = merged
162
- const payload = volatileConfig(merged)
163
- if (typeof svc.update === 'function') {
164
- await svc.update(NS, payload, getRevision())
165
- } else if (typeof svc.replace === 'function') {
166
- await svc.replace(NS, payload, getRevision())
167
- }
168
- return merged
169
- },
170
- watch: (cb) => {
171
- if (typeof svc.watch === 'function') return svc.watch(cb)
172
- return () => {}
173
- },
205
+ const onConfigUpdated = () => {
206
+ try {
207
+ toolController?.sync?.()
208
+ } catch (err) {
209
+ if (typeof ctx.logger?.warn === 'function') {
210
+ ctx.logger.warn(`[dsh-image-gen] reactive tool sync failed: ${err?.message || err}`)
174
211
  }
175
212
  }
176
- return undefined
177
213
  }
178
214
 
179
215
  let settingsSvc = null
@@ -194,12 +230,20 @@ export function apply(ctx, rawConfig) {
194
230
  if (!next) return false
195
231
  currentLiveConfig = next
196
232
  getConfig = () => currentLiveConfig || config
233
+ onConfigUpdated()
197
234
  return true
198
235
  }
199
236
 
200
237
  ctx.inject(['settings'], (sctx) => {
201
238
  settingsSvc = sctx.settings
202
- settingsApi = createSettingsAdapter(sctx.settings)
239
+ settingsApi = createSettingsAdapter(sctx.settings, {
240
+ ns: NS,
241
+ getLive: live,
242
+ setLive: (c) => {
243
+ currentLiveConfig = c
244
+ onConfigUpdated()
245
+ },
246
+ })
203
247
  adoptServed()
204
248
  sctx.effect(() => () => {
205
249
  getConfig = () => config
@@ -291,7 +335,10 @@ export function apply(ctx, rawConfig) {
291
335
  registerSettingsRoutes(ctx, {
292
336
  live,
293
337
  getSettingsApi: () => settingsApi,
294
- setLiveConfig: (c) => { currentLiveConfig = c },
338
+ setLiveConfig: (c) => {
339
+ currentLiveConfig = c
340
+ onConfigUpdated()
341
+ },
295
342
  })
296
343
 
297
344
  // One-click plugin updater route per DSH standard
@@ -398,7 +445,7 @@ export function apply(ctx, rawConfig) {
398
445
  resolveApiKey,
399
446
  })
400
447
 
401
- registerAllTools(ctx, {
448
+ toolController = registerAllTools(ctx, {
402
449
  config: baseConfig,
403
450
  live,
404
451
  saveAndAttachResult,
@@ -406,4 +453,8 @@ export function apply(ctx, rawConfig) {
406
453
  slugify,
407
454
  resolveApiKey,
408
455
  })
456
+
457
+ ctx.effect(() => () => {
458
+ toolController?.dispose?.()
459
+ }, 'dsh-image-gen: tool registration cleanup')
409
460
  }
@@ -117,6 +117,7 @@ export function computeGenerationHash({
117
117
  seed,
118
118
  size,
119
119
  style,
120
+ stylePreset,
120
121
  format,
121
122
  aspectRatio,
122
123
  negativePrompt,
@@ -144,6 +145,7 @@ export function computeGenerationHash({
144
145
  String(seed ?? ''),
145
146
  String(size || '').trim().toLowerCase(),
146
147
  String(style || '').trim().toLowerCase(),
148
+ String(stylePreset || '').trim().toLowerCase(),
147
149
  String(format || '').trim().toLowerCase(),
148
150
  String(aspectRatio || '').trim().toLowerCase(),
149
151
  String(negativePrompt || '').trim(),