@goodandready/dsh-image-gen 0.11.23 → 0.11.24

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/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)
@@ -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
 
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
  }
@@ -1,5 +1,6 @@
1
- // register-tools.js — orchestrates all image-gen tool registrations (#216, #337).
1
+ // register-tools.js — orchestrates all image-gen tool registrations (#216, #337, #384).
2
2
  // Supports 'minimal' (default: <= 3 core tools), 'all' (all 30 tools), and 'custom' (toggled toolsets).
3
+ // Reactively updates tool registrations when toolsetProfile or toolsets change.
3
4
 
4
5
  import { registerGenerationTools } from './tools/generation.js'
5
6
  import { registerProcessingTools } from './tools/processing-basic.js'
@@ -20,12 +21,16 @@ import { registerVisionOcrTools } from './tools/vision-ocr.js'
20
21
  import { registerDiagramTools } from './tools/diagram.js'
21
22
 
22
23
  /**
23
- * Registers every image-gen tool on the host tools service according to toolsetProfile (#337).
24
+ * Registers every image-gen tool on the host tools service according to toolsetProfile (#337, #384).
24
25
  *
25
26
  * @param {object} ctx cordis context
26
27
  * @param {object} deps shared runtime from apply()
28
+ * @returns {object} { sync, dispose, getRegisteredNames }
27
29
  */
28
30
  export function registerAllTools(ctx, deps) {
31
+ let activeDisposers = []
32
+ const registeredNames = new Set()
33
+
29
34
  const originalToolsRegister = ctx?.tools?.register ? ctx.tools.register.bind(ctx.tools) : null
30
35
  const wrappedTools = originalToolsRegister ? Object.create(ctx.tools, {
31
36
  register: {
@@ -41,7 +46,23 @@ export function registerAllTools(ctx, deps) {
41
46
  return originalExecute(args, exec)
42
47
  },
43
48
  }
44
- return originalToolsRegister(wrappedDef)
49
+ const res = originalToolsRegister(wrappedDef)
50
+ registeredNames.add(def.name)
51
+ if (typeof res === 'function') {
52
+ activeDisposers.push(res)
53
+ } else {
54
+ activeDisposers.push(() => {
55
+ if (typeof ctx.tools?.unregister === 'function') {
56
+ ctx.tools.unregister(def.name)
57
+ } else if (typeof ctx.tools?.delete === 'function') {
58
+ ctx.tools.delete(def.name)
59
+ } else if (Array.isArray(ctx.tools?.list)) {
60
+ const idx = ctx.tools.list.findIndex((t) => t.name === def.name)
61
+ if (idx !== -1) ctx.tools.list.splice(idx, 1)
62
+ }
63
+ })
64
+ }
65
+ return res
45
66
  },
46
67
  writable: true,
47
68
  configurable: true,
@@ -51,47 +72,95 @@ export function registerAllTools(ctx, deps) {
51
72
  const targetCtx = originalToolsRegister ? Object.create(ctx, {
52
73
  tools: { value: wrappedTools, writable: true, configurable: true }
53
74
  }) : ctx
54
- const cfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
55
- const profile = cfg.toolsetProfile || 'minimal'
56
- const isAll = profile === 'all'
57
- const sets = cfg.toolsets || {}
58
- const design = isAll || Boolean(sets.design || cfg.toolsetDesign)
59
- const processing = isAll || Boolean(sets.processing || cfg.toolsetProcessing)
60
- const frontend = isAll || Boolean(sets.frontend || cfg.toolsetFrontend)
61
- const creative = isAll || Boolean(sets.creative || cfg.toolsetCreative)
62
75
 
63
- // 1. Core tools: generate_image, edit_image, inspect_image_quality (<= 3 tools in minimal)
64
- registerGenerationTools(targetCtx, deps, { coreOnly: !creative })
65
- registerEditingTools(targetCtx, deps, { coreOnly: !processing })
66
- registerInspectTools(targetCtx, deps, { coreOnly: !processing })
76
+ function doRegister(cfg) {
77
+ const profile = cfg.toolsetProfile || 'minimal'
78
+ const isAll = profile === 'all'
79
+ const sets = cfg.toolsets || {}
80
+ const design = isAll || Boolean(sets.design || cfg.toolsetDesign)
81
+ const processing = isAll || Boolean(sets.processing || cfg.toolsetProcessing)
82
+ const frontend = isAll || Boolean(sets.frontend || cfg.toolsetFrontend)
83
+ const creative = isAll || Boolean(sets.creative || cfg.toolsetCreative)
84
+
85
+ // 1. Core tools: generate_image, edit_image, inspect_image_quality (<= 3 tools in minimal)
86
+ registerGenerationTools(targetCtx, deps, { coreOnly: !creative })
87
+ registerEditingTools(targetCtx, deps, { coreOnly: !processing })
88
+ registerInspectTools(targetCtx, deps, { coreOnly: !processing })
89
+
90
+ // 2. Processing toolset
91
+ if (processing) {
92
+ registerProcessingTools(targetCtx, deps)
93
+ registerProcessingAdvancedTools(targetCtx, deps)
94
+ registerVisionOcrTools(targetCtx, deps)
95
+ }
96
+
97
+ // 3. Design toolset
98
+ if (design) {
99
+ registerAnchorTools(targetCtx, deps)
100
+ registerUiAssetTools(targetCtx, deps)
101
+ registerStyleMatrixTools(targetCtx, deps)
102
+ registerThemePairTools(targetCtx, deps)
103
+ registerCharacterSheetTools(targetCtx, deps)
104
+ registerDiagramTools(targetCtx, deps)
105
+ }
67
106
 
68
- // 2. Processing toolset
69
- if (processing) {
70
- registerProcessingTools(targetCtx, deps)
71
- registerProcessingAdvancedTools(targetCtx, deps)
72
- registerVisionOcrTools(targetCtx, deps)
107
+ // 4. Frontend toolset
108
+ if (frontend) {
109
+ registerFrontendTools(targetCtx, deps)
110
+ registerResponsiveTools(targetCtx, deps)
111
+ }
112
+
113
+ // 5. Creative toolset
114
+ if (creative) {
115
+ registerSketchTools(targetCtx, deps)
116
+ registerSpritesheetTools(targetCtx, deps)
117
+ registerPatternTools(targetCtx, deps)
118
+ }
73
119
  }
74
120
 
75
- // 3. Design toolset
76
- if (design) {
77
- registerAnchorTools(targetCtx, deps)
78
- registerUiAssetTools(targetCtx, deps)
79
- registerStyleMatrixTools(targetCtx, deps)
80
- registerThemePairTools(targetCtx, deps)
81
- registerCharacterSheetTools(targetCtx, deps)
82
- registerDiagramTools(targetCtx, deps)
121
+ function dispose() {
122
+ for (const d of activeDisposers) {
123
+ if (typeof d === 'function') {
124
+ try { d() } catch (err) {
125
+ // Disposer cleanup errors are safely ignored on tool rebuild
126
+ }
127
+ }
128
+ }
129
+ activeDisposers = []
130
+ registeredNames.clear()
83
131
  }
84
132
 
85
- // 4. Frontend toolset
86
- if (frontend) {
87
- registerFrontendTools(targetCtx, deps)
88
- registerResponsiveTools(targetCtx, deps)
133
+ function computeKey(cfg) {
134
+ const profile = cfg.toolsetProfile || 'minimal'
135
+ const isAll = profile === 'all'
136
+ const sets = cfg.toolsets || {}
137
+ return JSON.stringify({
138
+ profile,
139
+ design: isAll || Boolean(sets.design || cfg.toolsetDesign),
140
+ processing: isAll || Boolean(sets.processing || cfg.toolsetProcessing),
141
+ frontend: isAll || Boolean(sets.frontend || cfg.toolsetFrontend),
142
+ creative: isAll || Boolean(sets.creative || cfg.toolsetCreative),
143
+ })
89
144
  }
90
145
 
91
- // 5. Creative toolset
92
- if (creative) {
93
- registerSketchTools(targetCtx, deps)
94
- registerSpritesheetTools(targetCtx, deps)
95
- registerPatternTools(targetCtx, deps)
146
+ let currentKey = ''
147
+ function sync() {
148
+ const currentCfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
149
+ const key = computeKey(currentCfg)
150
+ if (key === currentKey) return
151
+ currentKey = key
152
+ dispose()
153
+ doRegister(currentCfg)
154
+ }
155
+
156
+ // Initial registration
157
+ const initialCfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
158
+ currentKey = computeKey(initialCfg)
159
+ doRegister(initialCfg)
160
+
161
+ return {
162
+ sync,
163
+ dispose,
164
+ getRegisteredNames: () => Array.from(registeredNames),
96
165
  }
97
166
  }
@@ -1,3 +1,4 @@
1
+ import { validateComfyWorkflow } from './comfy-workflow-helpers.js'
1
2
  // lib/settings-route.js
2
3
  // REST endpoints for plugin settings (/dsh-image-gen/config, /dsh-image-gen/status) (#295, #300).
3
4
  // Provides HTTP read/write fallback when DSH core is accessed over network.
@@ -92,23 +93,72 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfi
92
93
  return
93
94
  }
94
95
 
96
+ const resetFields = []
97
+ if (Array.isArray(payload?.resetFields)) {
98
+ resetFields.push(...payload.resetFields)
99
+ } else if (Array.isArray(payload?.reset)) {
100
+ resetFields.push(...payload.reset)
101
+ } else if (typeof payload?.resetField === 'string') {
102
+ resetFields.push(payload.resetField)
103
+ }
104
+
105
+ // Coerce fallbackProviders string to array (#369)
106
+ if (typeof raw.fallbackProviders === 'string') {
107
+ raw.fallbackProviders = raw.fallbackProviders
108
+ .split(',')
109
+ .map((s) => s.trim())
110
+ .filter(Boolean)
111
+ }
112
+
113
+ // Validate comfyWorkflowJson if non-empty string (#369)
114
+ if (typeof raw.comfyWorkflowJson === 'string' && raw.comfyWorkflowJson.trim() !== '') {
115
+ try {
116
+ validateComfyWorkflow(raw.comfyWorkflowJson)
117
+ } catch (err) {
118
+ res.writeHead(400, { 'Content-Type': 'application/json' })
119
+ res.end(JSON.stringify({ ok: false, error: `Invalid comfyWorkflowJson: ${err.message}` }))
120
+ return
121
+ }
122
+ }
123
+
95
124
  const allowedKeys = new Set(Object.keys(Config.dict || {}))
96
- for (const k of Object.keys(raw)) {
125
+ for (const k of resetFields) {
126
+ if (!allowedKeys.has(k)) {
127
+ res.writeHead(400, { 'Content-Type': 'application/json' })
128
+ res.end(JSON.stringify({ ok: false, error: `unknown config field to reset: ${k}` }))
129
+ return
130
+ }
131
+ }
132
+
133
+ for (const [k, v] of Object.entries(raw)) {
134
+ if (k === 'resetFields' || k === 'reset' || k === 'resetField') continue
97
135
  if (!allowedKeys.has(k)) {
98
136
  res.writeHead(400, { 'Content-Type': 'application/json' })
99
137
  res.end(JSON.stringify({ ok: false, error: `unknown config field: ${k}` }))
100
138
  return
101
139
  }
140
+ if (v === null) {
141
+ resetFields.push(k)
142
+ }
102
143
  }
103
144
 
104
145
  try {
105
- const base = plainConfig(live()) || {}
106
- const merged = plainConfig(Config({ ...base, ...raw }))
146
+ const base = structuredClone(plainConfig(live()) || {})
147
+ for (const k of resetFields) {
148
+ delete base[k]
149
+ }
150
+ for (const [k, v] of Object.entries(raw)) {
151
+ if (k === 'resetFields' || k === 'reset' || k === 'resetField') continue
152
+ if (v !== null && v !== undefined) {
153
+ base[k] = v
154
+ }
155
+ }
156
+ const merged = plainConfig(Config(base))
107
157
  const settingsApi = getSettingsApi ? getSettingsApi() : null
108
158
  if (settingsApi && typeof settingsApi.replace === 'function') {
109
159
  await settingsApi.replace(merged)
110
160
  } else if (settingsApi && typeof settingsApi.update === 'function') {
111
- await settingsApi.update(raw)
161
+ await settingsApi.update(merged)
112
162
  } else if (typeof setLiveConfig === 'function') {
113
163
  setLiveConfig(merged)
114
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.23",
3
+ "version": "0.11.24",
4
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",