@goodandready/dsh-image-gen 0.11.22 → 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.
@@ -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
  }
@@ -1,5 +1,5 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // character-sheet.js — generate_character_sheet tool (#181).
1
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
2
+ // character-sheet.js — generate_character_sheet tool (#181, #358).
3
3
  // Generates multi-angle character model sheets and turnarounds with visual consistency.
4
4
 
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,10 +7,12 @@ import path from 'node:path'
7
7
  import { mkdir, writeFile } from 'node:fs/promises'
8
8
  import {
9
9
  ASPECT_RATIOS,
10
+ PROVIDER_KEYS,
10
11
  buildSidecar,
11
12
  makeProviders,
12
13
  toLosslessJson,
13
14
  saveAttachmentSafe,
15
+
14
16
  } from '../providers.js'
15
17
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
16
18
  import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
@@ -24,7 +26,7 @@ import {
24
26
  export function registerCharacterSheetTools(ctx, deps) {
25
27
  const {
26
28
  live,
27
- slugify,
29
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
28
30
  resolveApiKey,
29
31
  } = deps
30
32
 
@@ -78,6 +80,8 @@ export function registerCharacterSheetTools(ctx, deps) {
78
80
  },
79
81
  seed: { type: 'integer' },
80
82
  provider: { type: 'string' },
83
+ path: { type: 'string' },
84
+ url: { type: 'string' },
81
85
  attachment: {
82
86
  type: 'object',
83
87
  additionalProperties: true,
@@ -89,13 +93,24 @@ export function registerCharacterSheetTools(ctx, deps) {
89
93
  byteSize: { type: 'integer' },
90
94
  },
91
95
  },
96
+ _fallback: {
97
+ type: 'object',
98
+ additionalProperties: true,
99
+ },
92
100
  },
93
101
  },
94
102
  render: (_args, value) => renderToolOutput(value),
95
103
  },
96
104
  async execute(args, execCtx) {
97
- const cfg = live.value
98
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
105
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
106
+ if (cfg.enabled === false) {
107
+ throw new Error('Image generation is disabled in settings.')
108
+ }
109
+
110
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
111
+ ? (args.provider || cfg.provider)
112
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
113
+
99
114
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
100
115
 
101
116
  const sheetPlan = buildCharacterSheetPrompt({
@@ -105,73 +120,98 @@ export function registerCharacterSheetTools(ctx, deps) {
105
120
  negativePrompt: args.negativePrompt,
106
121
  })
107
122
 
108
- const providers = makeProviders({
109
- fetchImpl: fetch,
110
- resolveKey: (id) => resolveApiKey(cfg, id),
111
- cfg,
112
- })
123
+ const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
113
124
 
114
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
125
+ let subscriptionImages
126
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
127
+
128
+ const providers = makeProviders(
129
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
130
+ {
131
+ prompt: sheetPlan.prompt,
132
+ negativePrompt: sheetPlan.negativePrompt,
133
+ size: 'custom',
134
+ format: 'png',
135
+ seed,
136
+ signal: execCtx?.signal,
137
+ aspectPixels,
138
+ aspectRatio: sheetPlan.aspectRatio,
139
+ quality: 'high',
140
+ },
141
+ )
115
142
 
116
- const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
143
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
117
144
 
118
145
  const estimatedCost = calculateGenerationCost({
119
- provider: activeProvider,
146
+ provider,
120
147
  model: cfg.model || cfg.customModel || 'default',
121
148
  size: cfg.defaultSize || '1024x1024',
122
149
  count: 1,
123
150
  })
124
151
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
125
152
 
126
- const jobPayload = {
127
- prompt: sheetPlan.prompt,
128
- negativePrompt: sheetPlan.negativePrompt,
129
- aspectRatio: sheetPlan.aspectRatio,
130
- aspectPixels,
131
- format: 'png',
132
- seed,
133
- quality: 'high',
134
- }
135
-
136
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
137
- fallbackChain,
153
+ const gen = await executeWithFallback(
138
154
  providers,
139
- jobPayload,
140
- { signal: execCtx?.signal }
155
+ fallbackChain,
156
+ seed,
157
+ sheetPlan.prompt,
158
+ { logger: ctx.logger },
141
159
  )
142
160
 
161
+ const usedProvider = gen._fallback?.providerUsed || provider
162
+ const finalBytes = gen.bytes
143
163
  const timestamp = Date.now()
144
164
  const safeSlug = slugify(args.prompt.slice(0, 30)) || 'character-sheet'
145
165
  const filename = `character-sheet-${sheetPlan.layout}-${safeSlug}-${timestamp}.png`
146
166
 
147
167
  let attachment = null
148
- if (genResult.bytes) {
149
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
168
+ let localUrl = ''
169
+ let diskPath = ''
170
+ if (finalBytes) {
171
+ const saveRes = await saveAttachmentSafe(ctx, {
172
+ bytes: finalBytes,
173
+ mediaType: 'image/png',
174
+ name: filename,
175
+ })
176
+ attachment = saveRes?.attachment || null
177
+ localUrl = saveRes?.localUrl || ''
178
+
179
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
180
+ const targetDir = cfg.outputDir || 'generated/images'
181
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
150
182
  await mkdir(outDir, { recursive: true })
151
- const diskPath = path.join(outDir, filename)
152
- await writeFile(diskPath, Buffer.from(genResult.bytes))
153
-
154
- attachment = await saveAttachmentSafe(
155
- ctx,
156
- Buffer.from(genResult.bytes),
157
- 'image/png',
158
- filename,
159
- diskPath
160
- )
161
- } else if (genResult.url) {
183
+ diskPath = path.join(outDir, filename)
184
+ await writeFile(diskPath, Buffer.from(finalBytes))
185
+ } else if (gen.url) {
162
186
  attachment = {
163
- url: genResult.url,
187
+ url: gen.url,
164
188
  mediaType: 'image/png',
165
189
  filename,
166
190
  }
191
+ localUrl = gen.url
167
192
  }
168
193
 
169
- recordSpend(Number(genResult.cost) || estimatedCost, {
194
+ const effectiveCost = Number(gen.cost) || estimatedCost
195
+ recordSpend(effectiveCost, {
170
196
  ctx,
171
197
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
172
198
  })
173
199
 
200
+ const dualOutput = buildDualOutputMarkdown({
201
+ action: 'generated',
202
+ filePath: diskPath || filename,
203
+ width: aspectPixels[0],
204
+ height: aspectPixels[1],
205
+ mediaType: 'image/png',
206
+ seed,
207
+ provider: usedProvider,
208
+ model: cfg.model || cfg.customModel || 'character-sheet',
209
+ cost: gen.cost,
210
+ attachmentId: attachment?.attachmentId,
211
+ })
212
+
174
213
  const response = {
214
+ summary: dualOutput,
175
215
  prompt: sheetPlan.prompt,
176
216
  characterDescription: args.prompt,
177
217
  layout: sheetPlan.layout,
@@ -181,16 +221,13 @@ export function registerCharacterSheetTools(ctx, deps) {
181
221
  seed,
182
222
  provider: usedProvider,
183
223
  attachment,
224
+ path: diskPath,
225
+ url: localUrl,
226
+ attachmentId: attachment?.attachmentId,
227
+ _fallback: gen._fallback,
184
228
  }
185
229
 
186
- if (attachment?.attachmentId) {
187
- response.attachmentId = attachment.attachmentId
188
- }
189
- if (attachment?.url) {
190
- response.url = attachment.url
191
- }
192
-
193
- return response
230
+ return toLosslessJson(response)
194
231
  },
195
232
  })
196
233
  )
@@ -1,5 +1,4 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // diagram.js — beautify_diagram tool (#185).
1
+ // diagram.js — beautify_diagram tool (#185, #359).
3
2
  // Transforms raw Mermaid / Graphviz diagrams into high-end 3D isometric architectural illustrations.
4
3
 
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,9 +6,12 @@ import path from 'node:path'
7
6
  import { mkdir, writeFile } from 'node:fs/promises'
8
7
  import {
9
8
  ASPECT_RATIOS,
9
+ PROVIDER_KEYS,
10
10
  makeProviders,
11
+ toLosslessJson,
11
12
  saveAttachmentSafe,
12
13
  } from '../providers.js'
14
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
13
15
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
14
16
  import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
15
17
  import { renderToolOutput } from '../attachment-helper.js'
@@ -21,7 +23,7 @@ import {
21
23
  export function registerDiagramTools(ctx, deps) {
22
24
  const {
23
25
  live,
24
- slugify,
26
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
25
27
  resolveApiKey,
26
28
  } = deps
27
29
 
@@ -75,6 +77,8 @@ export function registerDiagramTools(ctx, deps) {
75
77
  prompt: { type: 'string' },
76
78
  seed: { type: 'integer' },
77
79
  provider: { type: 'string' },
80
+ path: { type: 'string' },
81
+ url: { type: 'string' },
78
82
  attachment: {
79
83
  type: 'object',
80
84
  additionalProperties: true,
@@ -85,13 +89,24 @@ export function registerDiagramTools(ctx, deps) {
85
89
  filename: { type: 'string' },
86
90
  },
87
91
  },
92
+ _fallback: {
93
+ type: 'object',
94
+ additionalProperties: true,
95
+ },
88
96
  },
89
97
  },
90
98
  render: (_args, value) => renderToolOutput(value),
91
99
  },
92
100
  async execute(args, execCtx) {
93
- const cfg = live?.value || (typeof live === 'function' ? live() : {})
94
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
101
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
102
+ if (cfg.enabled === false) {
103
+ throw new Error('Image generation is disabled in settings.')
104
+ }
105
+
106
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
107
+ ? (args.provider || cfg.provider)
108
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
109
+
95
110
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
96
111
 
97
112
  const plan = buildDiagramIllustrationPrompt({
@@ -101,70 +116,98 @@ export function registerDiagramTools(ctx, deps) {
101
116
  aspectRatio: args.aspect_ratio || '16:9',
102
117
  })
103
118
 
104
- const providers = makeProviders({
105
- fetchImpl: fetch,
106
- resolveKey: (id) => resolveApiKey(cfg, id),
107
- cfg,
108
- })
109
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
110
119
  const aspectPixels = ASPECT_RATIOS[plan.aspectRatio] || ASPECT_RATIOS['16:9']
111
120
 
121
+ let subscriptionImages
122
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
123
+
124
+ const providers = makeProviders(
125
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
126
+ {
127
+ prompt: plan.prompt,
128
+ negativePrompt: plan.negativePrompt,
129
+ size: 'custom',
130
+ format: 'png',
131
+ seed,
132
+ signal: execCtx?.signal,
133
+ aspectPixels,
134
+ aspectRatio: plan.aspectRatio,
135
+ quality: 'high',
136
+ },
137
+ )
138
+
139
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
140
+
112
141
  const estimatedCost = calculateGenerationCost({
113
- provider: activeProvider,
142
+ provider,
114
143
  model: cfg.model || cfg.customModel || 'default',
115
144
  size: cfg.defaultSize || '1024x1024',
116
145
  count: 1,
117
146
  })
118
147
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
119
148
 
120
- const jobPayload = {
121
- prompt: plan.prompt,
122
- negativePrompt: plan.negativePrompt,
123
- aspectRatio: plan.aspectRatio,
124
- aspectPixels,
125
- format: 'png',
126
- seed,
127
- quality: 'high',
128
- }
129
-
130
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
131
- fallbackChain,
149
+ const gen = await executeWithFallback(
132
150
  providers,
133
- jobPayload,
134
- { signal: execCtx?.signal }
151
+ fallbackChain,
152
+ seed,
153
+ plan.prompt,
154
+ { logger: ctx.logger },
135
155
  )
136
156
 
137
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
138
- await mkdir(outDir, { recursive: true })
157
+ const usedProvider = gen._fallback?.providerUsed || provider
158
+ const finalBytes = gen.bytes
139
159
  const timestamp = Date.now()
140
160
  const safeSlug = slugify(args.title || 'architecture-diagram') || 'diagram'
141
161
  const filename = `diagram-3d-${safeSlug}-${timestamp}.png`
142
162
 
143
163
  let attachment = null
144
- if (genResult.bytes) {
145
- const diskPath = path.join(outDir, filename)
146
- await writeFile(diskPath, Buffer.from(genResult.bytes))
147
- attachment = await saveAttachmentSafe(
148
- ctx,
149
- Buffer.from(genResult.bytes),
150
- 'image/png',
151
- filename,
152
- diskPath
153
- )
154
- } else if (genResult.url) {
164
+ let localUrl = ''
165
+ let diskPath = ''
166
+ if (finalBytes) {
167
+ const saveRes = await saveAttachmentSafe(ctx, {
168
+ bytes: finalBytes,
169
+ mediaType: 'image/png',
170
+ name: filename,
171
+ })
172
+ attachment = saveRes?.attachment || null
173
+ localUrl = saveRes?.localUrl || ''
174
+
175
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
176
+ const targetDir = cfg.outputDir || 'generated/images'
177
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
178
+ await mkdir(outDir, { recursive: true })
179
+ diskPath = path.join(outDir, filename)
180
+ await writeFile(diskPath, Buffer.from(finalBytes))
181
+ } else if (gen.url) {
155
182
  attachment = {
156
- url: genResult.url,
183
+ url: gen.url,
157
184
  mediaType: 'image/png',
158
185
  filename,
159
186
  }
187
+ localUrl = gen.url
160
188
  }
161
189
 
162
- recordSpend(Number(genResult.cost) || estimatedCost, {
190
+ const effectiveCost = Number(gen.cost) || estimatedCost
191
+ recordSpend(effectiveCost, {
163
192
  ctx,
164
193
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
165
194
  })
166
195
 
167
- return {
196
+ const dualOutput = buildDualOutputMarkdown({
197
+ action: 'generated',
198
+ filePath: diskPath || filename,
199
+ width: aspectPixels[0],
200
+ height: aspectPixels[1],
201
+ mediaType: 'image/png',
202
+ seed,
203
+ provider: usedProvider,
204
+ model: cfg.model || cfg.customModel || 'beautify-diagram',
205
+ cost: gen.cost,
206
+ attachmentId: attachment?.attachmentId,
207
+ })
208
+
209
+ const response = {
210
+ summary: dualOutput,
168
211
  title: args.title || 'System Architecture',
169
212
  style: plan.style,
170
213
  aspectRatio: plan.aspectRatio,
@@ -173,9 +216,13 @@ export function registerDiagramTools(ctx, deps) {
173
216
  seed,
174
217
  provider: usedProvider,
175
218
  attachment,
176
- url: attachment?.url,
219
+ path: diskPath,
220
+ url: localUrl,
177
221
  attachmentId: attachment?.attachmentId,
222
+ _fallback: gen._fallback,
178
223
  }
224
+
225
+ return toLosslessJson(response)
179
226
  },
180
227
  })
181
228
  )