@goodandready/dsh-image-gen 0.8.6 → 0.8.7

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
@@ -419,11 +419,11 @@ window.__ModuleLoader__.load({
419
419
  }
420
420
  const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
421
421
  const DELIVERY_MODES = ['link', 'image']
422
- const PROVIDERS = ['fal', 'custom', 'codex', 'grok']
422
+ const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local']
423
423
 
424
424
  /** Field specs in display order. */
425
425
  const FIELDS = [
426
- { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint' },
426
+ { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint', providerNeeds: { fal: 'ключ FAL', custom: 'адрес + ключ', codex: 'подписка ChatGPT', grok: 'подписка Grok', local: 'адрес сервера' } },
427
427
  { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
428
428
  { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
429
429
  { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
@@ -457,7 +457,8 @@ window.__ModuleLoader__.load({
457
457
  }
458
458
  if (entry.kind === 'select') {
459
459
  const px = entry.sizePixels && entry.sizePixels[state.text] ? ` — ${entry.sizePixels[state.text]}` : ''
460
- const hint = base.hint + px
460
+ const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ` (нужно: ${entry.providerNeeds[state.text]})` : ''
461
+ const hint = base.hint + px + need
461
462
  return react_jsx_runtime.jsx(SelectField, { ...base, hint, options: entry.options, inheritLabel: t('f.inherit') })
462
463
  }
463
464
  return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
@@ -742,6 +743,9 @@ window.__ModuleLoader__.load({
742
743
 
743
744
  const body = []
744
745
  if (prompt) body.push(react.createElement('div', { className: 'fal-prompt', key: 'p' }, prompt))
746
+ if (args.originalPrompt && args.originalPrompt !== prompt) {
747
+ body.push(react.createElement('div', { className: 'fal-meta', key: 'orig' }, `Исходный запрос: ${args.originalPrompt}`))
748
+ }
745
749
  if (failed) {
746
750
  body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || 'неизвестная ошибка'))
747
751
  } else if (!parsed.attachment && parsed.url) {
@@ -772,6 +776,15 @@ window.__ModuleLoader__.load({
772
776
  body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
773
777
  }
774
778
 
779
+ // Ссылка для скачивания: та же, что у картинки (наш роут или провайдерский url).
780
+ const downloadUrl = parsed.attachment
781
+ ? '/dsh-image-gen/image?id=' + encodeURIComponent(parsed.attachment.attachmentId ?? parsed.attachment.id ?? '')
782
+ + '&mt=' + encodeURIComponent(parsed.attachment.mediaType || 'image/png')
783
+ + '&b=' + encodeURIComponent(String(parsed.attachment.bytes ?? 0))
784
+ + '&w=' + encodeURIComponent(String(parsed.attachment.width ?? 0))
785
+ + '&h=' + encodeURIComponent(String(parsed.attachment.height ?? 0))
786
+ : parsed.url
787
+
775
788
  // Диагностика: метаданные генерации (prompt/seed/size/format), чтобы было
776
789
  // понятно, по каким параметрам получилась именно эта картинка.
777
790
  const metaParts = []
@@ -792,6 +805,14 @@ window.__ModuleLoader__.load({
792
805
  onClick: repeat,
793
806
  }, 'Повторить генерацию'))
794
807
  }
808
+ if (!running && !failed && downloadUrl) {
809
+ body.push(react.createElement('a', {
810
+ key: 'download',
811
+ className: 'fal-repeat',
812
+ href: downloadUrl,
813
+ download: 'generated-image.png',
814
+ }, 'Скачать'))
815
+ }
795
816
 
796
817
  return react.createElement('div', { className: 'fal-card' }, head, body)
797
818
  }
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools'
17
17
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
18
- import { mkdir, writeFile } from 'node:fs/promises'
18
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
19
19
  import path from 'node:path'
20
20
  import {
21
21
  IMAGE_SIZES,
@@ -40,7 +40,7 @@ const NS = 'dsh-image-gen'
40
40
  // настройки лежат под старым именем. Оно читается как запасное и переносится
41
41
  // под новое имя один раз — молча, при первом запуске после обновления.
42
42
  const LEGACY_NS = 'dsh-fal-image-gen'
43
- export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
43
+ export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
44
44
 
45
45
  export const Config = z.object({
46
46
  provider: z
@@ -108,13 +108,62 @@ export const Config = z.object({
108
108
  .description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
109
109
  + 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
110
110
  .default(''),
111
+ localKind: z
112
+ .string()
113
+ .description('provider=local: which local API to use — "comfyui" or "a1111".')
114
+ .default('comfyui'),
115
+ localBaseURL: z
116
+ .string()
117
+ .description('provider=local: server address, e.g. http://127.0.0.1:8188 (ComfyUI) or http://127.0.0.1:7860 (A1111).')
118
+ .default(''),
119
+ localModel: z
120
+ .string()
121
+ .description('provider=local: model id (A1111) or workflow/schema name (ComfyUI). Empty means the server default.')
122
+ .default(''),
123
+ localSteps: z
124
+ .number()
125
+ .description('provider=local: sampling steps.')
126
+ .default(20),
127
+ localCfg: z
128
+ .number()
129
+ .description('provider=local: CFG scale.')
130
+ .default(7),
111
131
  outputDir: z
112
132
  .string()
113
133
  .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
114
134
  .default('generated/images'),
135
+ historyLimit: z
136
+ .number()
137
+ .description('How many recent generations to keep in the in-memory history list.')
138
+ .default(50),
139
+ enhancePrompt: z
140
+ .boolean()
141
+ .description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
142
+ .default(false),
143
+ enhanceModel: z
144
+ .string()
145
+ .description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
146
+ .default(''),
147
+ enhanceBelowChars: z
148
+ .number()
149
+ .description('Only enhance prompts shorter than this many characters.')
150
+ .default(200),
115
151
  })
116
152
 
117
153
  /** Keep a file stem safe for the filesystem. */
154
+ /** Прочитать исходное изображение для правки: путь к файлу или attachment id. */
155
+ export async function resolveSource(ctx, exec, ref) {
156
+ if (!ref) return undefined
157
+ const sessionCwd = exec.agent?.session?.header?.cwd
158
+ if (ref.startsWith('sha256:')) {
159
+ const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
160
+ return { bytes: Buffer.from(stored.data), mediaType: stored.ref?.mediaType || 'image/png' }
161
+ }
162
+ const p = path.resolve(sessionCwd || process.cwd(), ref)
163
+ const bytes = await readFile(p)
164
+ return { bytes, mediaType: 'image/png' }
165
+ }
166
+
118
167
  export function slugify(input) {
119
168
  const stem = String(input ?? '')
120
169
  .toLowerCase()
@@ -165,6 +214,53 @@ function migrateLegacySettings(sctx, scope) {
165
214
  }
166
215
  }
167
216
 
217
+ /** In-memory история генераций (пополняется в execute, где доступен cwd). */
218
+ const history = []
219
+
220
+ /** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
221
+ export function filterHistory(entries, exists) {
222
+ return entries.filter((e) => exists(e.path)).slice(0, 50)
223
+ }
224
+
225
+ /** Собрать текст из итератора llm.stream (text-delta / block-end). */
226
+ export async function collectText(iterable) {
227
+ let out = ''
228
+ let sawDelta = false
229
+ for await (const chunk of iterable) {
230
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') {
231
+ out += chunk.text
232
+ sawDelta = true
233
+ } else if (
234
+ !sawDelta && chunk && chunk.type === 'block-end'
235
+ && chunk.block && chunk.block.type === 'text' && typeof chunk.block.text === 'string'
236
+ ) {
237
+ out += chunk.block.text
238
+ }
239
+ }
240
+ return out.trim()
241
+ }
242
+
243
+ /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
244
+ export async function enhancePrompt(ctx, cfg, prompt, signal) {
245
+ if (!cfg.enhancePrompt) return { prompt, enhanced: false }
246
+ if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
247
+ try {
248
+ const chunks = ctx.llm.stream({
249
+ ...(signal ? { signal } : {}),
250
+ ...(cfg.enhanceModel ? { model: cfg.enhanceModel } : {}),
251
+ messages: [
252
+ { role: 'system', content: 'You expand a short image-generation prompt into a detailed one. Reply with only the expanded prompt, no commentary.' },
253
+ { role: 'user', content: prompt },
254
+ ],
255
+ })
256
+ const text = await collectText(chunks)
257
+ if (!text) return { prompt, enhanced: false }
258
+ return { prompt: text, enhanced: true }
259
+ } catch (e) {
260
+ return { prompt, enhanced: false }
261
+ }
262
+ }
263
+
168
264
  export function apply(ctx, config) {
169
265
 
170
266
  // The Web card edits this namespace; without registering it the card binds to
@@ -242,6 +338,22 @@ export function apply(ctx, config) {
242
338
  }), `dsh-image-gen: image route ${path}`)
243
339
  }
244
340
 
341
+ // История генераций: in-memory список, отфильтрованный по существованию файлов.
342
+ ctx.effect(() => ctx.webServer.register({
343
+ kind: 'exact',
344
+ path: '/dsh-image-gen/history',
345
+ handler: async (req, res) => {
346
+ if (req.method !== 'GET') {
347
+ res.writeHead(405, { 'Content-Type': 'application/json' })
348
+ res.end(JSON.stringify({ error: 'GET only' }))
349
+ return
350
+ }
351
+ const exists = (p) => { try { return require('node:fs').existsSync(p) } catch { return false } }
352
+ res.writeHead(200, { 'Content-Type': 'application/json' })
353
+ res.end(JSON.stringify(filterHistory(history, exists)))
354
+ },
355
+ }), 'dsh-image-gen: history route')
356
+
245
357
  ctx.tools.register(
246
358
  defineTool({
247
359
  name: 'generate_image',
@@ -286,6 +398,18 @@ export function apply(ctx, config) {
286
398
  type: 'number',
287
399
  description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
288
400
  },
401
+ source_image: {
402
+ type: 'string',
403
+ description: 'Optional path to an image file or an attachment id (sha256:...) to edit instead of drawing from scratch. Only providers that support image editing accept it; others refuse with a clear reason.',
404
+ },
405
+ mask: {
406
+ type: 'string',
407
+ description: 'Optional path or attachment id of a mask image (same size as source) to restrict the edit to a region. Only used when source_image is set and the provider supports masks.',
408
+ },
409
+ strength: {
410
+ type: 'number',
411
+ description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
412
+ },
289
413
  },
290
414
  output: {
291
415
  schema: {
@@ -361,9 +485,13 @@ export function apply(ctx, config) {
361
485
  let subscriptionImages
362
486
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
363
487
 
488
+ const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
489
+ const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
490
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
491
+ const effectivePrompt = enhanced.prompt
364
492
  const providers = makeProviders(
365
493
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
366
- { prompt: args.prompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale },
494
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength },
367
495
  )
368
496
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
369
497
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
@@ -375,7 +503,7 @@ export function apply(ctx, config) {
375
503
  const bytes = gen.bytes
376
504
  const mediaType = gen.mediaType
377
505
  const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
378
- const stem = `${slugify(args.output_name || args.prompt)}-${Date.now().toString(36)}-${jobSeed}`
506
+ const stem = `${slugify(args.output_name || effectivePrompt)}-${Date.now().toString(36)}-${jobSeed}`
379
507
  const name = `${stem}.${extension}`
380
508
 
381
509
  const attachment = await ctx.attachments.saveImage({
@@ -401,7 +529,7 @@ export function apply(ctx, config) {
401
529
  await writeFile(
402
530
  path.join(outDir, `${stem}.json`),
403
531
  JSON.stringify(buildSidecar({
404
- prompt: args.prompt,
532
+ prompt: effectivePrompt,
405
533
  size,
406
534
  format,
407
535
  seed: gen.seed,
@@ -412,16 +540,31 @@ export function apply(ctx, config) {
412
540
  mediaType,
413
541
  attachmentId: attachment.attachmentId,
414
542
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
543
+ cost: gen.cost,
415
544
  }), null, 2),
416
545
  )
417
546
 
547
+ history.unshift({
548
+ path: filePath,
549
+ prompt: effectivePrompt,
550
+ provider,
551
+ size,
552
+ format,
553
+ seed: gen.seed,
554
+ createdAt: new Date().toISOString(),
555
+ attachmentId: attachment.attachmentId,
556
+ })
557
+ if (history.length > (cfg.historyLimit || 50)) history.length = cfg.historyLimit || 50
558
+
418
559
  return {
419
560
  path: filePath,
420
561
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
421
562
  width: gen.width || attachment.width,
422
563
  height: gen.height || attachment.height,
423
564
  seed: gen.seed,
424
- prompt: args.prompt,
565
+ prompt: effectivePrompt,
566
+ originalPrompt: enhanced.enhanced ? args.prompt : undefined,
567
+ cost: gen.cost,
425
568
  format: mediaType.replace('image/', ''),
426
569
  attachment: {
427
570
  attachmentId: attachment.attachmentId,
package/lib/providers.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // Сеть приходит параметром (fetchImpl), ключ — через resolveKey, поэтому оба
8
8
  // провайдера проверяются юнит-тестами без единого реального запроса.
9
9
 
10
- export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok']
10
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local']
11
11
 
12
12
  /** Привести count из аргумента инструмента к диапазону 1..4. */
13
13
  /** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
@@ -99,9 +99,9 @@ export function normalizeMediaType(contentType, fallbackFormat) {
99
99
  /** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
100
100
  export function buildSidecar({
101
101
  prompt, size, format, seed, provider, deliverAs,
102
- width, height, mediaType, attachmentId, url, createdAt = new Date().toISOString(),
102
+ width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
103
103
  }) {
104
- return { prompt, size, format, seed, provider, deliverAs, width, height, mediaType, attachmentId, url, createdAt }
104
+ return { prompt, size, format, seed, provider, deliverAs, width, height, mediaType, attachmentId, url, cost, createdAt }
105
105
  }
106
106
 
107
107
  /** Submit a generation job to the FAL queue. */
@@ -157,9 +157,29 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
157
157
  * {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
158
158
  * означают «спроси у службы вложений»: она всё равно измеряет байты сама.
159
159
  */
160
+
161
+ /** Именованный размер -> [width, height] для локальных API. */
162
+ export function sizeToPixels(size) {
163
+ const px = SIZE_PIXELS[size]
164
+ if (!px) return [1024, 1024]
165
+ const [w, h] = px.split('x').map(Number)
166
+ return [w, h]
167
+ }
168
+
169
+ /** Собрать multipart-тело для /images/edits (OpenAI-совместимый edit). */
170
+ export function buildEditForm({ source, mask, prompt, size, strength }) {
171
+ const form = new FormData()
172
+ form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
173
+ if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
174
+ form.append('prompt', prompt)
175
+ if (size) form.append('size', size)
176
+ if (strength !== undefined) form.append('strength', String(strength))
177
+ return form
178
+ }
179
+
160
180
  export function makeProviders(deps, job) {
161
181
  const { fetchImpl, resolveKey, cfg } = deps
162
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale } = job
182
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength } = job
163
183
 
164
184
  async function fal(seedArg = seed) {
165
185
  const key = await resolveKey(cfg.apiKeyEnv)
@@ -193,6 +213,7 @@ export function makeProviders(deps, job) {
193
213
  height: image.height ?? 0,
194
214
  seed: result.seed ?? seedArg ?? 0,
195
215
  sourceUrl: image.url,
216
+ cost: result.cost,
196
217
  }
197
218
  }
198
219
 
@@ -210,17 +231,20 @@ export function makeProviders(deps, job) {
210
231
 
211
232
  // response_format не отправляем: новые модели OpenAI его отвергают, а ответ
212
233
  // всё равно приходит либо base64, либо ссылкой — принимаем оба.
213
- const res = await fetchImpl(`${base}/images/generations`, {
234
+ const endpoint = source ? `${base}/images/edits` : `${base}/images/generations`
235
+ const res = await fetchImpl(endpoint, {
214
236
  method: 'POST',
215
237
  headers,
216
- body: JSON.stringify({
217
- model: cfg.customModel,
218
- prompt,
219
- n: 1,
220
- size: cfg.customSize || SIZE_PIXELS[size] || size,
221
- ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
222
- ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
223
- }),
238
+ body: source
239
+ ? buildEditForm({ source, mask, prompt, size: cfg.customSize || SIZE_PIXELS[size] || size, strength })
240
+ : JSON.stringify({
241
+ model: cfg.customModel,
242
+ prompt,
243
+ n: 1,
244
+ size: cfg.customSize || SIZE_PIXELS[size] || size,
245
+ ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
246
+ ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
247
+ }),
224
248
  signal,
225
249
  })
226
250
  const data = await res.json().catch(() => ({}))
@@ -268,6 +292,9 @@ export function makeProviders(deps, job) {
268
292
  // отдаёт готовую картинку, а не ключ доступа.
269
293
  function subscription(provider) {
270
294
  return async function generate(seedArg = seed) {
295
+ if (source) {
296
+ return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
297
+ }
271
298
  const images = deps.subscriptionImages
272
299
  if (!images || typeof images.generate !== 'function') {
273
300
  return {
@@ -305,5 +332,102 @@ export function makeProviders(deps, job) {
305
332
  }
306
333
  }
307
334
 
308
- return { fal, custom, codex: subscription('codex'), grok: subscription('grok') }
335
+ // Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
336
+ async function local(seedArg = seed) {
337
+ const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
338
+ if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
339
+ const [width, height] = sizeToPixels(size)
340
+ const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
341
+
342
+ if (kind === 'a1111') {
343
+ const body = {
344
+ prompt,
345
+ negative_prompt: negativePrompt,
346
+ width,
347
+ height,
348
+ steps: cfg.localSteps ?? 20,
349
+ cfg_scale: cfg.localCfg ?? 7,
350
+ seed: seedArg ?? -1,
351
+ }
352
+ if (cfg.localModel) body.override_settings = { sd_model_checkpoint: cfg.localModel }
353
+ const res = await fetchImpl(`${base}/sdapi/v1/txt2img`, {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify(body),
357
+ signal,
358
+ })
359
+ if (!res.ok) {
360
+ throw new Error(`Local A1111 failed (HTTP ${res.status}): ${String(await res.text().catch(() => '')).slice(0, 300)}`)
361
+ }
362
+ const data = await res.json().catch(() => ({}))
363
+ const b64 = data.images && data.images[0]
364
+ if (!b64) throw new Error('Local A1111 returned no images')
365
+ return {
366
+ bytes: Buffer.from(b64, 'base64'),
367
+ mediaType: normalizeMediaType('image/png', format),
368
+ width,
369
+ height,
370
+ seed: seedArg ?? 0,
371
+ sourceUrl: '',
372
+ }
373
+ }
374
+
375
+ // ComfyUI: submit через /prompt, опрос /history/{prompt_id} до готовности.
376
+ const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
377
+ const workflow = {
378
+ prompt: {
379
+ '3': { class_type: 'KSampler', inputs: { seed: seedArg ?? 0, steps: cfg.localSteps ?? 20, cfg: cfg.localCfg ?? 7, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] } },
380
+ '4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
381
+ '5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
382
+ '6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['4', 1] } },
383
+ '7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
384
+ '8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
385
+ '9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
386
+ },
387
+ }
388
+ const submit = await fetchImpl(`${base}/prompt`, {
389
+ method: 'POST',
390
+ headers: { 'Content-Type': 'application/json' },
391
+ body: JSON.stringify({ prompt: workflow, client_id: promptId }),
392
+ signal,
393
+ })
394
+ if (!submit.ok) {
395
+ throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
396
+ }
397
+ const submitData = await submit.json().catch(() => ({}))
398
+ const pid = submitData.prompt_id
399
+ if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
400
+
401
+ const deadline = Date.now() + cfg.timeoutMs
402
+ for (;;) {
403
+ if (signal.aborted) throw new Error('Local ComfyUI generation cancelled')
404
+ if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
405
+ await new Promise((resolve) => {
406
+ const timer = setTimeout(resolve, cfg.pollIntervalMs || 2000)
407
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
408
+ })
409
+ const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
410
+ if (!hist.ok) continue
411
+ const histData = await hist.json().catch(() => ({}))
412
+ const entry = histData[pid]
413
+ if (entry && entry.outputs) {
414
+ const outputs = entry.outputs
415
+ const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
416
+ if (img) {
417
+ const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
418
+ if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
419
+ return {
420
+ bytes: Buffer.from(await dl.arrayBuffer()),
421
+ mediaType: normalizeMediaType('image/png', format),
422
+ width,
423
+ height,
424
+ seed: seedArg ?? 0,
425
+ sourceUrl: '',
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local }
309
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.6",
3
+ "version": "0.8.7",
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",