@goodandready/dsh-image-gen 0.11.26 → 0.11.28

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.
@@ -115,7 +115,7 @@ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
115
115
  return data
116
116
  }
117
117
 
118
- export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
118
+ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000, onProgress) {
119
119
  const deadline = Date.now() + timeoutMs
120
120
  let attempt = 0
121
121
  for (;;) {
@@ -138,6 +138,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
138
138
  const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
139
139
  const data = await res.json().catch(() => ({}))
140
140
  const status = data.status
141
+ if (typeof onProgress === 'function') {
142
+ if (status === 'IN_QUEUE') {
143
+ const pos = data.queue_position
144
+ onProgress({
145
+ stage: pos !== undefined ? `In queue (#${pos})` : 'In queue',
146
+ progress: undefined, // honest status: do NOT simulate fake percentage
147
+ })
148
+ } else if (status === 'IN_PROGRESS') {
149
+ const rawProg = typeof data.progress === 'number' ? Math.round(data.progress * 100) : undefined
150
+ onProgress({
151
+ stage: 'Generating...',
152
+ progress: rawProg,
153
+ })
154
+ }
155
+ }
141
156
  if (status === 'COMPLETED') return data
142
157
  if (status === 'ERROR' || data.error || data.detail) {
143
158
  throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
@@ -148,6 +163,53 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
148
163
  }
149
164
  }
150
165
 
166
+ /**
167
+ * Normalizes mask bytes for OpenAI/Custom inpainting where transparent alpha (0) indicates edit region.
168
+ * If the incoming mask has an alpha channel with transparent areas, it is preserved.
169
+ * If the incoming mask is opaque black & white (e.g. from Canvas), white pixels are converted to transparent (alpha 0)
170
+ * and black pixels to opaque (alpha 255).
171
+ *
172
+ * @param {Buffer|Uint8Array} maskBytes
173
+ * @returns {Promise<Buffer>}
174
+ */
175
+ export async function normalizeMaskAlpha(maskBytes) {
176
+ if (!maskBytes || maskBytes.length === 0) return maskBytes
177
+ try {
178
+ const img = sharp(maskBytes)
179
+ const { data, info } = await img.raw().ensureAlpha().toBuffer({ resolveWithObject: true })
180
+ const { width, height, channels } = info
181
+
182
+ let hasTransparency = false
183
+ for (let i = 3; i < data.length; i += channels) {
184
+ if (data[i] < 250) {
185
+ hasTransparency = true
186
+ break
187
+ }
188
+ }
189
+
190
+ if (hasTransparency) {
191
+ return await sharp(data, { raw: { width, height, channels } }).png().toBuffer()
192
+ }
193
+
194
+ const out = Buffer.from(data)
195
+ for (let i = 0; i < out.length; i += channels) {
196
+ const r = out[i]
197
+ const g = out[i + 1]
198
+ const b = out[i + 2]
199
+ const lum = 0.299 * r + 0.587 * g + 0.114 * b
200
+ if (lum > 128) {
201
+ out[i + 3] = 0
202
+ } else {
203
+ out[i + 3] = 255
204
+ }
205
+ }
206
+
207
+ return await sharp(out, { raw: { width, height, channels } }).png().toBuffer()
208
+ } catch (_) {
209
+ return maskBytes
210
+ }
211
+ }
212
+
151
213
  export function buildEditForm({ source, mask, prompt, size, strength, model }) {
152
214
  const form = new FormData()
153
215
  if (model) form.append('model', model)
package/lib/providers.js CHANGED
@@ -640,7 +640,7 @@ export function toLosslessJson(val) {
640
640
  * Returns { ok: boolean, latencyMs: number, message: string }.
641
641
  */
642
642
  export async function testProviderConnection(deps, providerId) {
643
- const { fetchImpl = fetch, resolveKey, cfg } = deps
643
+ const { fetchImpl = fetch, resolveKey, cfg } = deps || {}
644
644
  const start = Date.now()
645
645
  const rawProvider = (typeof providerId === 'string' && providerId.trim())
646
646
  || (typeof cfg?.provider === 'string' && cfg.provider.trim())
@@ -648,128 +648,199 @@ export async function testProviderConnection(deps, providerId) {
648
648
  || 'fal'
649
649
  const p = rawProvider.toLowerCase()
650
650
 
651
+ const safeTimeout = (ms) => {
652
+ return typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(ms) : undefined
653
+ }
654
+
651
655
  try {
652
656
  if (p === 'fal') {
653
- const key = await resolveKey(cfg.apiKeyEnv || 'FAL_KEY')
654
- if (!key) return { ok: false, latencyMs: 0, message: 'FAL API key not configured' }
655
- const res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
656
- headers: { Authorization: falAuthHeader(key) },
657
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
658
- })
657
+ const key = await resolveKey(cfg?.apiKeyEnv || 'FAL_KEY')
658
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'FAL API key not configured' }
659
+ let res
660
+ try {
661
+ res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
662
+ headers: { Authorization: falAuthHeader(key) },
663
+ signal: safeTimeout(6000),
664
+ })
665
+ } catch (netErr) {
666
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Fal queue unreachable: ${netErr.message || 'connection error'}` }
667
+ }
659
668
  const latencyMs = Date.now() - start
660
669
  if (res.status === 401 || res.status === 403) {
661
- return { ok: false, latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
670
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
671
+ }
672
+ if (res.status >= 500) {
673
+ return { ok: false, state: 'server_error', latencyMs, message: `Fal server error (HTTP ${res.status})` }
662
674
  }
663
- return { ok: true, latencyMs, message: 'Fal.ai queue reachable' }
675
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Fal.ai queue reachable' }
664
676
  }
665
677
 
666
- if (p === 'comfyui') {
667
- const url = cfg.comfyuiUrl || cfg.localBaseURL || 'http://127.0.0.1:8188'
668
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
669
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
670
- })
671
- const latencyMs = Date.now() - start
672
- if (res.ok) {
673
- const data = await res.json().catch(() => ({}))
674
- const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
675
- return { ok: true, latencyMs, message: `ComfyUI operational${vram}` }
678
+ if (p === 'local' || p === 'comfyui' || p === 'a1111') {
679
+ const backend = (p === 'local' ? (cfg?.localBackend || 'comfyui') : p).toLowerCase()
680
+ if (backend === 'a1111') {
681
+ const url = cfg?.a1111Url || cfg?.localBaseURL || 'http://127.0.0.1:7860'
682
+ let res
683
+ try {
684
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
685
+ signal: safeTimeout(5000),
686
+ })
687
+ } catch (netErr) {
688
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Automatic1111 offline (${netErr.message || 'connection refused'})` }
689
+ }
690
+ const latencyMs = Date.now() - start
691
+ if (res.status === 401 || res.status === 403) {
692
+ return { ok: false, state: 'unauthorized', latencyMs, message: `A1111 auth error (HTTP ${res.status})` }
693
+ }
694
+ if (res.status >= 500) {
695
+ return { ok: false, state: 'server_error', latencyMs, message: `A1111 server error (HTTP ${res.status})` }
696
+ }
697
+ if (res.ok) {
698
+ return { ok: true, state: 'model_available', latencyMs, message: 'Automatic1111 operational' }
699
+ }
700
+ return { ok: false, state: 'server_error', latencyMs, message: `A1111 returned HTTP ${res.status}` }
676
701
  }
677
- return { ok: false, latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
678
- }
679
702
 
680
- if (p === 'a1111') {
681
- const url = cfg.a1111Url || cfg.localBaseURL || 'http://127.0.0.1:7860'
682
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
683
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
684
- })
703
+ // Default local is ComfyUI
704
+ const url = cfg?.comfyuiUrl || cfg?.localBaseURL || 'http://127.0.0.1:8188'
705
+ let res
706
+ try {
707
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
708
+ signal: safeTimeout(5000),
709
+ })
710
+ } catch (netErr) {
711
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `ComfyUI offline (${netErr.message || 'connection refused'})` }
712
+ }
685
713
  const latencyMs = Date.now() - start
714
+ if (res.status === 401 || res.status === 403) {
715
+ return { ok: false, state: 'unauthorized', latencyMs, message: `ComfyUI auth error (HTTP ${res.status})` }
716
+ }
717
+ if (res.status >= 500) {
718
+ return { ok: false, state: 'server_error', latencyMs, message: `ComfyUI server error (HTTP ${res.status})` }
719
+ }
686
720
  if (res.ok) {
687
- return { ok: true, latencyMs, message: 'Automatic1111 operational' }
721
+ const data = await res.json().catch(() => ({}))
722
+ const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
723
+ return { ok: true, state: 'model_available', latencyMs, message: `ComfyUI operational${vram}` }
688
724
  }
689
- return { ok: false, latencyMs, message: `A1111 returned HTTP ${res.status}` }
725
+ return { ok: false, state: 'server_error', latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
690
726
  }
691
727
 
692
728
  if (p === 'custom') {
693
- const url = cfg.customEndpoint || cfg.customBaseURL || 'https://api.openai.com/v1'
694
- const key = await resolveKey(cfg.customApiKeyEnv || cfg.customKeyEnv || 'OPENAI_API_KEY')
729
+ const url = cfg?.customEndpoint || cfg?.customBaseURL || 'https://api.openai.com/v1'
730
+ const key = await resolveKey(cfg?.customApiKeyEnv || cfg?.customKeyEnv || 'OPENAI_API_KEY')
695
731
  const headers = key ? { Authorization: `Bearer ${key}` } : {}
696
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
697
- headers,
698
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
699
- })
732
+ let res
733
+ try {
734
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
735
+ headers,
736
+ signal: safeTimeout(6000),
737
+ })
738
+ } catch (netErr) {
739
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Custom endpoint offline (${netErr.message || 'connection error'})` }
740
+ }
700
741
  const latencyMs = Date.now() - start
701
742
  if (res.status === 401 || res.status === 403) {
702
- return { ok: false, latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
743
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
744
+ }
745
+ if (res.status >= 500) {
746
+ return { ok: false, state: 'server_error', latencyMs, message: `Custom endpoint server error (HTTP ${res.status})` }
703
747
  }
704
748
  if (res.ok || res.status === 404 || res.status === 405) {
705
- return { ok: true, latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
749
+ return { ok: true, state: res.ok ? 'authenticated' : 'reachable', latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
706
750
  }
707
- return { ok: false, latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
751
+ return { ok: false, state: 'server_error', latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
708
752
  }
709
753
 
710
754
  if (p === 'subscription' || p === 'chatgpt' || p === 'grok' || p === 'codex') {
711
- const hasSub = Boolean(deps.ctx?.subscriptions)
755
+ const subService = deps?.subscriptionImages
756
+ || (deps?.ctx?.get && deps.ctx.get('subscriptionImages'))
757
+ || deps?.ctx?.subscriptionImages
758
+ const hasSub = Boolean(subService && typeof subService.generate === 'function')
712
759
  const latencyMs = Date.now() - start
713
760
  if (hasSub) {
714
- return { ok: true, latencyMs, message: 'Subscription bridge available' }
761
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Subscription bridge available' }
715
762
  }
716
- return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
763
+ return { ok: false, state: 'not_configured', latencyMs, message: 'subscriptionImages service not available (requires dsh-subscriptions plugin)' }
717
764
  }
718
765
 
719
766
  if (p === 'replicate') {
720
- const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
721
- if (!key) return { ok: false, latencyMs: 0, message: 'Replicate API token not configured' }
722
- const res = await fetchImpl('https://api.replicate.com/v1/models', {
723
- headers: { Authorization: `Bearer ${key}` },
724
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
725
- })
767
+ const key = await resolveKey(cfg?.replicateKeyEnv || 'REPLICATE_API_TOKEN')
768
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Replicate API token not configured' }
769
+ let res
770
+ try {
771
+ res = await fetchImpl('https://api.replicate.com/v1/models', {
772
+ headers: { Authorization: `Bearer ${key}` },
773
+ signal: safeTimeout(6000),
774
+ })
775
+ } catch (netErr) {
776
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Replicate unreachable (${netErr.message || 'connection error'})` }
777
+ }
726
778
  const latencyMs = Date.now() - start
727
779
  if (res.status === 401 || res.status === 403) {
728
- return { ok: false, latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
780
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
781
+ }
782
+ if (res.status >= 500) {
783
+ return { ok: false, state: 'server_error', latencyMs, message: `Replicate server error (HTTP ${res.status})` }
729
784
  }
730
785
  if (res.ok) {
731
- return { ok: true, latencyMs, message: 'Replicate API reachable' }
786
+ return { ok: true, state: 'authenticated', latencyMs, message: 'Replicate API reachable' }
732
787
  }
733
- return { ok: false, latencyMs, message: `Replicate returned HTTP ${res.status}` }
788
+ return { ok: false, state: 'server_error', latencyMs, message: `Replicate returned HTTP ${res.status}` }
734
789
  }
735
790
 
736
791
  if (p === 'gemini') {
737
- const key = await resolveKey(cfg.geminiKeyEnv || 'GEMINI_API_KEY')
738
- if (!key) return { ok: false, latencyMs: 0, message: 'Google Gemini API key not configured' }
739
- const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
740
- const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
741
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
742
- })
792
+ const key = await resolveKey(cfg?.geminiKeyEnv || 'GEMINI_API_KEY')
793
+ if (!key) return { ok: false, state: 'not_configured', latencyMs: 0, message: 'Google Gemini API key not configured' }
794
+ const model = cfg?.geminiModel || 'gemini-2.0-flash-exp-image-generation'
795
+ let res
796
+ try {
797
+ res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
798
+ signal: safeTimeout(6000),
799
+ })
800
+ } catch (netErr) {
801
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Google Gemini API unreachable (${netErr.message || 'connection error'})` }
802
+ }
743
803
  const latencyMs = Date.now() - start
744
804
  if (res.status === 400 || res.status === 401 || res.status === 403) {
745
- return { ok: false, latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
805
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
806
+ }
807
+ if (res.status >= 500) {
808
+ return { ok: false, state: 'server_error', latencyMs, message: `Gemini API server error (HTTP ${res.status})` }
746
809
  }
747
810
  if (res.ok) {
748
- return { ok: true, latencyMs, message: 'Gemini Vision API operational' }
811
+ return { ok: true, state: 'model_available', latencyMs, message: 'Gemini Vision API operational' }
749
812
  }
750
- return { ok: false, latencyMs, message: `Gemini returned HTTP ${res.status}` }
813
+ return { ok: false, state: 'server_error', latencyMs, message: `Gemini returned HTTP ${res.status}` }
751
814
  }
752
815
 
753
816
  if (p === 'seedream') {
754
- const url = cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
755
- const key = await resolveKey(cfg.seedreamKeyEnv || 'SEEDREAM_API_KEY')
817
+ const url = cfg?.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
818
+ const key = await resolveKey(cfg?.seedreamKeyEnv || 'SEEDREAM_API_KEY')
756
819
  const headers = key ? { Authorization: `Bearer ${key}` } : {}
757
- const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
758
- headers,
759
- signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
760
- }).catch(() => null)
820
+ let res
821
+ try {
822
+ res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
823
+ headers,
824
+ signal: safeTimeout(6000),
825
+ })
826
+ } catch (netErr) {
827
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: `Seedream endpoint offline (${netErr.message || 'connection error'})` }
828
+ }
761
829
  const latencyMs = Date.now() - start
762
- if (res && (res.ok || res.status === 404 || res.status === 405)) {
763
- return { ok: true, latencyMs, message: 'Seedream endpoint reachable' }
830
+ if (res.status === 401 || res.status === 403) {
831
+ return { ok: false, state: 'unauthorized', latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
764
832
  }
765
- if (res && (res.status === 401 || res.status === 403)) {
766
- return { ok: false, latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
833
+ if (res.status >= 500) {
834
+ return { ok: false, state: 'server_error', latencyMs, message: `Seedream server error (HTTP ${res.status})` }
835
+ }
836
+ if (res.ok || res.status === 404 || res.status === 405) {
837
+ return { ok: true, state: res.ok ? 'authenticated' : 'reachable', latencyMs, message: 'Seedream endpoint reachable' }
767
838
  }
768
- return { ok: true, latencyMs, message: 'Seedream provider endpoint configured' }
839
+ return { ok: false, state: 'server_error', latencyMs, message: `Seedream returned HTTP ${res.status}` }
769
840
  }
770
841
 
771
- return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
842
+ return { ok: false, state: 'unsupported', latencyMs: Date.now() - start, message: `Unknown or unsupported provider: "${p}"` }
772
843
  } catch (err) {
773
- return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
844
+ return { ok: false, state: 'offline', latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
774
845
  }
775
846
  }
@@ -19,6 +19,12 @@ import { registerThemePairTools } from './tools/theme-pair.js'
19
19
  import { registerCharacterSheetTools } from './tools/character-sheet.js'
20
20
  import { registerVisionOcrTools } from './tools/vision-ocr.js'
21
21
  import { registerDiagramTools } from './tools/diagram.js'
22
+ import {
23
+ createLiveProgressSession,
24
+ publishLiveProgress,
25
+ completeLiveProgress,
26
+ abortLiveProgress,
27
+ } from './live-progress.js'
22
28
 
23
29
  /**
24
30
  * Registers every image-gen tool on the host tools service according to toolsetProfile (#337, #384).
@@ -43,7 +49,21 @@ export function registerAllTools(ctx, deps) {
43
49
  if (currentCfg && currentCfg.enabled === false) {
44
50
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
45
51
  }
46
- return originalExecute(args, exec)
52
+ const safeArgs = args || {}
53
+ const safeExec = exec || {}
54
+ const callId = safeExec.callId || safeExec.rootCallId
55
+ if (callId) {
56
+ createLiveProgressSession(callId, { signal: safeExec.signal })
57
+ safeExec.onProgress = (update) => publishLiveProgress(callId, update)
58
+ }
59
+ try {
60
+ const res = await originalExecute(safeArgs, safeExec)
61
+ if (callId) completeLiveProgress(callId)
62
+ return res
63
+ } catch (err) {
64
+ if (callId) abortLiveProgress(callId, err.message)
65
+ throw err
66
+ }
47
67
  },
48
68
  }
49
69
  const res = originalToolsRegister(wrappedDef)
@@ -34,6 +34,11 @@ export function registerAnchorTools(ctx, deps) {
34
34
  enum: ['style', 'character', 'face'],
35
35
  description: 'Anchor mode: "face" preserves facial portrait identity via FaceID/IP-Adapter; "character" preserves overall character traits; "style" preserves aesthetic palette. Default: style.',
36
36
  },
37
+ anchor_type: {
38
+ type: 'string',
39
+ enum: ['style', 'character', 'face'],
40
+ description: 'Alias for mode ("style", "character", "face").',
41
+ },
37
42
  strength: {
38
43
  type: 'number',
39
44
  description: 'Reference influence strength between 0.1 and 1.0 (default 0.65).',
@@ -95,9 +100,11 @@ export function registerAnchorTools(ctx, deps) {
95
100
  }
96
101
 
97
102
  const imageRef = args.image.trim()
98
- const mode = args.mode === 'character' ? 'character' : 'style'
99
- const strength = normalizeAnchorStrength(args.strength)
100
- const label = args.label ? String(args.label).trim() : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
103
+ const rawMode = String(args.mode || args.anchor_type || '').toLowerCase().trim()
104
+ const mode = (rawMode === 'character' || rawMode === 'face') ? rawMode : 'style'
105
+ const strength = normalizeAnchorStrength(args.strength, mode === 'face' || mode === 'character' ? 0.75 : 0.65)
106
+ const defaultLabel = mode === 'face' ? 'Face Identity Anchor' : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
107
+ const label = args.label ? String(args.label).trim() : defaultLabel
101
108
 
102
109
  // Validate source readability if it is a local path or attachment
103
110
  if (resolveSource && (imageRef.startsWith('sha256:') || !imageRef.startsWith('http'))) {
@@ -107,6 +107,10 @@ export function registerCharacterSheetTools(ctx, deps) {
107
107
  throw new Error('Image generation is disabled in settings.')
108
108
  }
109
109
 
110
+ if (!args?.prompt || typeof args.prompt !== 'string' || !args.prompt.trim()) {
111
+ throw new Error('Character description prompt is required for character sheet generation.')
112
+ }
113
+
110
114
  const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
111
115
  ? (args.provider || cfg.provider)
112
116
  : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
@@ -103,6 +103,10 @@ export function registerDiagramTools(ctx, deps) {
103
103
  throw new Error('Image generation is disabled in settings.')
104
104
  }
105
105
 
106
+ if (!args?.diagram || typeof args.diagram !== 'string' || !args.diagram.trim()) {
107
+ throw new Error('Diagram content (Mermaid code, ASCII, or description) is required for beautify_diagram.')
108
+ }
109
+
106
110
  const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
107
111
  ? (args.provider || cfg.provider)
108
112
  : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
@@ -98,6 +98,21 @@ export function registerEditingTools(ctx, deps, options = {}) {
98
98
  const source = await resolveConversationImage(ctx, exec, args.image)
99
99
  const maskRef = args.mask_image || args.mask
100
100
  const mask = maskRef ? await resolveSource(ctx, exec, maskRef) : undefined
101
+ if (mask && mask.bytes && source && source.bytes) {
102
+ try {
103
+ const sharpMod = await import('sharp')
104
+ const sharp = sharpMod.default || sharpMod
105
+ const srcMeta = await sharp(source.bytes).metadata()
106
+ const maskMeta = await sharp(mask.bytes).metadata()
107
+ if (srcMeta.width && srcMeta.height && (maskMeta.width !== srcMeta.width || maskMeta.height !== srcMeta.height)) {
108
+ const resized = await sharp(mask.bytes).resize(srcMeta.width, srcMeta.height, { fit: 'fill' }).png().toBuffer()
109
+ mask.bytes = resized
110
+ mask.mediaType = 'image/png'
111
+ }
112
+ } catch (_err) {
113
+ // Mask dimension normalization failure ignored
114
+ }
115
+ }
101
116
  const vision = await analyzeImageWithVision(ctx, exec, source)
102
117
 
103
118
  let effectivePrompt = args.prompt
@@ -127,6 +142,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
127
142
  strength,
128
143
  provider,
129
144
  signal: exec?.signal,
145
+ onProgress: exec?.onProgress,
146
+ callId: exec?.callId,
130
147
  }
131
148
 
132
149
  const gen = await editImageDirect(deps, job)
@@ -30,7 +30,10 @@ export function registerGenerationPackTool(ctx, deps) {
30
30
  },
31
31
  async execute(args, exec) {
32
32
  const ratios = Array.isArray(args.aspect_ratios) && args.aspect_ratios.length ? args.aspect_ratios : ['1:1', '16:9', '9:16']
33
- const generateTool = ctx.tools.get('generate_image')
33
+ const generateTool = ctx?.tools?.get ? ctx.tools.get('generate_image') : (ctx?.tools?.list?.find ? ctx.tools.list.find((t) => t.name === 'generate_image') : null)
34
+ if (!generateTool || typeof generateTool.execute !== 'function') {
35
+ throw new Error('Required tool "generate_image" is not available on host.')
36
+ }
34
37
  const results = []
35
38
  const warnings = []
36
39
  const seedBase = Math.floor(Math.random() * 100000)
@@ -1,3 +1,4 @@
1
+ import { buildPresentationMeta } from '../attachment-helper.js'
1
2
  import { registerGenerationPackTool } from './generation-pack.js'
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import path from 'node:path'
@@ -282,12 +283,16 @@ export function registerGenerationTools(ctx, deps, options = {}) {
282
283
  const summary = lines.join('\n')
283
284
  if (live().deliverAs !== 'image') {
284
285
  const urls = list.map((img) => img.url).filter(Boolean)
285
- return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
286
+ const mdLinks = list.map((img) => `![Generated Image](${img.url})`).join('\n')
287
+ return [{ type: 'text', text: urls.length ? `${summary}\n${mdLinks}` : summary }]
286
288
  }
287
289
  const blocks = [{ type: 'text', text: summary }]
288
290
  for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
289
291
  return blocks
290
292
  },
293
+ presentationMeta(args, value) {
294
+ return buildPresentationMeta(value)
295
+ },
291
296
  },
292
297
  isConcurrencySafe: () => false,
293
298
  timeoutMs: (config?.timeoutMs || 180000) + 30000,
@@ -295,7 +300,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
295
300
  const cfg = live()
296
301
 
297
302
  // #168: Fail-fast loop guard protecting against runaway agent loops
298
- const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
303
+ const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
299
304
  if (cfg.loopGuardLimit > 0) {
300
305
  trackAndAssertLoopGuard(sessionId, {
301
306
  limit: cfg.loopGuardLimit,
@@ -321,6 +326,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
321
326
  effectiveGuidance,
322
327
  activeAnchor,
323
328
  resolvedFaceRef,
329
+ resolvedRefImage,
324
330
  } = await prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource })
325
331
 
326
332
  // #167: Assert daily budget availability before calling API
@@ -334,7 +340,28 @@ export function registerGenerationTools(ctx, deps, options = {}) {
334
340
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
335
341
  const providers = makeProviders(
336
342
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
337
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
343
+ {
344
+ prompt: effectivePrompt,
345
+ size,
346
+ format,
347
+ seed: args.seed,
348
+ signal: exec?.signal,
349
+ negativePrompt: effectiveNegative,
350
+ guidanceScale: effectiveGuidance,
351
+ source,
352
+ mask,
353
+ strength: args.strength,
354
+ quality: args.quality,
355
+ style: args.style,
356
+ aspectPixels,
357
+ aspectRatio: args.aspect_ratio,
358
+ faceReference: resolvedFaceRef,
359
+ faceStrength: args.face_strength || activeAnchor?.strength,
360
+ referenceImage: resolvedRefImage,
361
+ referenceStrength: args.reference_strength || activeAnchor?.strength,
362
+ onProgress: exec?.onProgress,
363
+ callId: exec?.callId,
364
+ },
338
365
  )
339
366
  // Subscription providers return {ok:false, reason} instead of throwing:
340
367
  // rejection reaches the model as text. Without this guard,
@@ -440,7 +467,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
440
467
  const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
441
468
  const name = `${stem}.${extension}`
442
469
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
443
- const sessionCwd = exec.agent?.session?.header?.cwd
470
+ const sessionCwd = exec?.agent?.session?.header?.cwd
444
471
  const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
445
472
  const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
446
473
  await mkdir(outDir, { recursive: true })
@@ -58,12 +58,12 @@ export function registerProcessingTools(ctx, deps) {
58
58
  assertBudgetAvailable(0.02, cfg.dailyBudgetUsd)
59
59
  const res = await removeBackgroundFal(
60
60
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
61
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
61
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec?.signal }
62
62
  )
63
63
  const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
64
64
  const name = `${stem}.png`
65
65
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
66
- const sessionCwd = exec.agent?.session?.header?.cwd
66
+ const sessionCwd = exec?.agent?.session?.header?.cwd
67
67
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
68
68
  await mkdir(outDir, { recursive: true })
69
69
  const filePath = path.join(outDir, name)
@@ -129,12 +129,12 @@ export function registerProcessingTools(ctx, deps) {
129
129
  assertBudgetAvailable(0.025, cfg.dailyBudgetUsd)
130
130
  const res = await upscaleImageFal(
131
131
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
132
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
132
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec?.signal }
133
133
  )
134
134
  const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
135
135
  const name = `${stem}.png`
136
136
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
137
- const sessionCwd = exec.agent?.session?.header?.cwd
137
+ const sessionCwd = exec?.agent?.session?.header?.cwd
138
138
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
139
139
  await mkdir(outDir, { recursive: true })
140
140
  const filePath = path.join(outDir, name)
@@ -196,7 +196,7 @@ export function registerProcessingTools(ctx, deps) {
196
196
  const res = await traceToSvg(source.bytes, { colorMode: args.color_mode })
197
197
  const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
198
198
  const name = `${stem}.svg`
199
- const sessionCwd = exec.agent?.session?.header?.cwd
199
+ const sessionCwd = exec?.agent?.session?.header?.cwd
200
200
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
201
201
  await mkdir(outDir, { recursive: true })
202
202
  const filePath = path.join(outDir, name)
@@ -268,12 +268,12 @@ export function registerProcessingTools(ctx, deps) {
268
268
  }
269
269
  const res = await blendImagesFal(
270
270
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
271
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
271
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec?.signal }
272
272
  )
273
273
  const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
274
274
  const name = `${stem}.png`
275
275
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
276
- const sessionCwd = exec.agent?.session?.header?.cwd
276
+ const sessionCwd = exec?.agent?.session?.header?.cwd
277
277
  const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
278
278
  await mkdir(outDir, { recursive: true })
279
279
  const filePath = path.join(outDir, name)