@goodandready/dsh-image-gen 0.8.6 → 0.8.8

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,8 @@
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
+ import os from 'node:os'
19
20
  import path from 'node:path'
20
21
  import {
21
22
  IMAGE_SIZES,
@@ -40,7 +41,7 @@ const NS = 'dsh-image-gen'
40
41
  // настройки лежат под старым именем. Оно читается как запасное и переносится
41
42
  // под новое имя один раз — молча, при первом запуске после обновления.
42
43
  const LEGACY_NS = 'dsh-fal-image-gen'
43
- export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
44
+ export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
44
45
 
45
46
  export const Config = z.object({
46
47
  provider: z
@@ -108,13 +109,86 @@ export const Config = z.object({
108
109
  .description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
109
110
  + 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
110
111
  .default(''),
112
+ localKind: z
113
+ .string()
114
+ .description('provider=local: which local API to use — "comfyui" or "a1111".')
115
+ .default('comfyui'),
116
+ localBaseURL: z
117
+ .string()
118
+ .description('provider=local: server address, e.g. http://127.0.0.1:8188 (ComfyUI) or http://127.0.0.1:7860 (A1111).')
119
+ .default(''),
120
+ localModel: z
121
+ .string()
122
+ .description('provider=local: model id (A1111) or workflow/schema name (ComfyUI). Empty means the server default.')
123
+ .default(''),
124
+ localSteps: z
125
+ .number()
126
+ .description('provider=local: sampling steps.')
127
+ .default(20),
128
+ localCfg: z
129
+ .number()
130
+ .description('provider=local: CFG scale.')
131
+ .default(7),
132
+ seedreamKeyEnv: z
133
+ .string()
134
+ .description('provider=seedream: credential reference / env var holding the API key.')
135
+ .default('SEEDREAM_API_KEY'),
136
+ seedreamModel: z
137
+ .string()
138
+ .description('provider=seedream: model id, e.g. seedream-4.0.')
139
+ .default('seedream-4.0'),
140
+ geminiKeyEnv: z
141
+ .string()
142
+ .description('provider=gemini: credential reference / env var holding the Google API key.')
143
+ .default('GEMINI_API_KEY'),
144
+ geminiModel: z
145
+ .string()
146
+ .description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
147
+ .default('gemini-2.0-flash-exp-image-generation'),
111
148
  outputDir: z
112
149
  .string()
113
150
  .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
114
151
  .default('generated/images'),
152
+ historyLimit: z
153
+ .number()
154
+ .description('How many recent generations to keep in the in-memory history list.')
155
+ .default(50),
156
+ enhancePrompt: z
157
+ .boolean()
158
+ .description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
159
+ .default(false),
160
+ enhanceModel: z
161
+ .string()
162
+ .description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
163
+ .default(''),
164
+ enhanceBelowChars: z
165
+ .number()
166
+ .description('Only enhance prompts shorter than this many characters.')
167
+ .default(200),
168
+ stylePreset: z
169
+ .string()
170
+ .description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
171
+ .default(''),
172
+ cacheBySeed: z
173
+ .boolean()
174
+ .description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
175
+ .default(false),
115
176
  })
116
177
 
117
178
  /** Keep a file stem safe for the filesystem. */
179
+ /** Прочитать исходное изображение для правки: путь к файлу или attachment id. */
180
+ export async function resolveSource(ctx, exec, ref) {
181
+ if (!ref) return undefined
182
+ const sessionCwd = exec.agent?.session?.header?.cwd
183
+ if (ref.startsWith('sha256:')) {
184
+ const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
185
+ return { bytes: Buffer.from(stored.data), mediaType: stored.ref?.mediaType || 'image/png' }
186
+ }
187
+ const p = path.resolve(sessionCwd || process.cwd(), ref)
188
+ const bytes = await readFile(p)
189
+ return { bytes, mediaType: 'image/png' }
190
+ }
191
+
118
192
  export function slugify(input) {
119
193
  const stem = String(input ?? '')
120
194
  .toLowerCase()
@@ -165,6 +239,97 @@ function migrateLegacySettings(sctx, scope) {
165
239
  }
166
240
  }
167
241
 
242
+ /** Каталог истории: общий для всех устройств, переживает рестарт. */
243
+ export function historyFile() {
244
+ return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
245
+ }
246
+
247
+ /** Прочитать историю из файла (пусто, если файла нет). */
248
+ /** Найти запись истории по seed+prompt, если файл ещё существует. */
249
+ export async function findCached(entries, seed, prompt) {
250
+ if (seed === undefined) return undefined
251
+ return entries.find((e) => e.seed === seed && e.prompt === prompt)
252
+ }
253
+
254
+ /** Вернуть запись из кэша, если файл существует; иначе undefined. */
255
+ export async function cachedResult(entry) {
256
+ if (!entry || !entry.path) return undefined
257
+ try { require('node:fs').accessSync(entry.path) } catch (e) { return undefined }
258
+ return {
259
+ path: entry.path,
260
+ url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
261
+ width: 0,
262
+ height: 0,
263
+ seed: entry.seed,
264
+ prompt: entry.prompt,
265
+ format: 'png',
266
+ cached: true,
267
+ attachment: entry.attachmentId ? { attachmentId: entry.attachmentId, mediaType: 'image/png', bytes: 0, width: 0, height: 0, name: '' } : undefined,
268
+ }
269
+ }
270
+
271
+ export async function readHistory() {
272
+ try {
273
+ const raw = await readFile(historyFile(), 'utf-8')
274
+ const parsed = JSON.parse(raw)
275
+ return Array.isArray(parsed) ? parsed : []
276
+ } catch (e) {
277
+ return []
278
+ }
279
+ }
280
+
281
+ /** Записать историю в файл (перезапись целиком). */
282
+ export async function writeHistory(entries) {
283
+ try {
284
+ await mkdir(path.dirname(historyFile()), { recursive: true })
285
+ await writeFile(historyFile(), JSON.stringify(entries, null, 2))
286
+ } catch (e) { /* история не критична */ }
287
+ }
288
+
289
+ /** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
290
+ export function filterHistory(entries, exists) {
291
+ return entries.filter((e) => exists(e.path)).slice(0, 50)
292
+ }
293
+
294
+ /** Собрать текст из итератора llm.stream (text-delta / block-end). */
295
+ export async function collectText(iterable) {
296
+ let out = ''
297
+ let sawDelta = false
298
+ for await (const chunk of iterable) {
299
+ if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') {
300
+ out += chunk.text
301
+ sawDelta = true
302
+ } else if (
303
+ !sawDelta && chunk && chunk.type === 'block-end'
304
+ && chunk.block && chunk.block.type === 'text' && typeof chunk.block.text === 'string'
305
+ ) {
306
+ out += chunk.block.text
307
+ }
308
+ }
309
+ return out.trim()
310
+ }
311
+
312
+ /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
313
+ export async function enhancePrompt(ctx, cfg, prompt, signal) {
314
+ if (!cfg.enhancePrompt) return { prompt, enhanced: false }
315
+ if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
316
+ try {
317
+ const chunks = ctx.llm.stream({
318
+ ...(signal ? { signal } : {}),
319
+ ...(cfg.enhanceModel ? { model: cfg.enhanceModel } : {}),
320
+ messages: [
321
+ { role: 'system', content: 'You expand a short image-generation prompt into a detailed one. Reply with only the expanded prompt, no commentary.' },
322
+ { role: 'user', content: prompt },
323
+ ],
324
+ })
325
+ const text = await collectText(chunks)
326
+ if (!text) return { prompt, enhanced: false }
327
+ return { prompt: text, enhanced: true }
328
+ } catch (e) {
329
+ return { prompt, enhanced: false }
330
+ }
331
+ }
332
+
168
333
  export function apply(ctx, config) {
169
334
 
170
335
  // The Web card edits this namespace; without registering it the card binds to
@@ -242,6 +407,27 @@ export function apply(ctx, config) {
242
407
  }), `dsh-image-gen: image route ${path}`)
243
408
  }
244
409
 
410
+ // История генераций: in-memory список, отфильтрованный по существованию файлов.
411
+ ctx.effect(() => ctx.webServer.register({
412
+ kind: 'exact',
413
+ path: '/dsh-image-gen/history',
414
+ handler: async (req, res) => {
415
+ if (req.method !== 'GET') {
416
+ res.writeHead(405, { 'Content-Type': 'application/json' })
417
+ res.end(JSON.stringify({ error: 'GET only' }))
418
+ return
419
+ }
420
+ const exists = (p) => { try { return require('node:fs').existsSync(p) } catch { return false } }
421
+ const entries = await readHistory()
422
+ const withThumbs = filterHistory(entries, exists).map((e) => ({
423
+ ...e,
424
+ thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
425
+ }))
426
+ res.writeHead(200, { 'Content-Type': 'application/json' })
427
+ res.end(JSON.stringify(withThumbs))
428
+ },
429
+ }), 'dsh-image-gen: history route')
430
+
245
431
  ctx.tools.register(
246
432
  defineTool({
247
433
  name: 'generate_image',
@@ -278,6 +464,11 @@ export function apply(ctx, config) {
278
464
  type: 'integer',
279
465
  description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
280
466
  },
467
+ prompts: {
468
+ type: 'array',
469
+ items: { type: 'string' },
470
+ description: 'Optional list of prompts; one image is generated per prompt. When set, overrides count and the single prompt.',
471
+ },
281
472
  negative_prompt: {
282
473
  type: 'string',
283
474
  description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
@@ -286,6 +477,23 @@ export function apply(ctx, config) {
286
477
  type: 'number',
287
478
  description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
288
479
  },
480
+ quality: {
481
+ type: 'string',
482
+ enum: ['low', 'medium', 'high'],
483
+ description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
484
+ },
485
+ source_image: {
486
+ type: 'string',
487
+ 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.',
488
+ },
489
+ mask: {
490
+ type: 'string',
491
+ 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.',
492
+ },
493
+ strength: {
494
+ type: 'number',
495
+ description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
496
+ },
289
497
  },
290
498
  output: {
291
499
  schema: {
@@ -361,21 +569,26 @@ export function apply(ctx, config) {
361
569
  let subscriptionImages
362
570
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
363
571
 
572
+ const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
573
+ const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
574
+ const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
575
+ const styleSuffix = cfg.stylePreset ? `, ${cfg.stylePreset}` : ''
576
+ const effectivePrompt = enhanced.prompt + styleSuffix
364
577
  const providers = makeProviders(
365
578
  { 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 },
579
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength, quality: args.quality },
367
580
  )
368
581
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
369
582
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
370
583
  // байтами, и пользователь получал битую карточку вместо внятного отказа.
371
584
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
372
- const one = async (jobSeed, providerKey) => {
373
- const gen = await providers[providerKey](jobSeed)
585
+ const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
586
+ const gen = await providers[providerKey](jobSeed, promptArg)
374
587
  guard(gen)
375
588
  const bytes = gen.bytes
376
589
  const mediaType = gen.mediaType
377
590
  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}`
591
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
379
592
  const name = `${stem}.${extension}`
380
593
 
381
594
  const attachment = await ctx.attachments.saveImage({
@@ -401,7 +614,7 @@ export function apply(ctx, config) {
401
614
  await writeFile(
402
615
  path.join(outDir, `${stem}.json`),
403
616
  JSON.stringify(buildSidecar({
404
- prompt: args.prompt,
617
+ prompt: promptArg,
405
618
  size,
406
619
  format,
407
620
  seed: gen.seed,
@@ -412,16 +625,34 @@ export function apply(ctx, config) {
412
625
  mediaType,
413
626
  attachmentId: attachment.attachmentId,
414
627
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
628
+ cost: gen.cost,
415
629
  }), null, 2),
416
630
  )
417
631
 
632
+ const entry = {
633
+ path: filePath,
634
+ prompt: promptArg,
635
+ provider,
636
+ size,
637
+ format,
638
+ seed: gen.seed,
639
+ createdAt: new Date().toISOString(),
640
+ attachmentId: attachment.attachmentId,
641
+ }
642
+ const current = await readHistory()
643
+ current.unshift(entry)
644
+ if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
645
+ await writeHistory(current)
646
+
418
647
  return {
419
648
  path: filePath,
420
649
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
421
650
  width: gen.width || attachment.width,
422
651
  height: gen.height || attachment.height,
423
652
  seed: gen.seed,
424
- prompt: args.prompt,
653
+ prompt: promptArg,
654
+ originalPrompt: enhanced.enhanced ? args.prompt : undefined,
655
+ cost: gen.cost,
425
656
  format: mediaType.replace('image/', ''),
426
657
  attachment: {
427
658
  attachmentId: attachment.attachmentId,
@@ -434,15 +665,25 @@ export function apply(ctx, config) {
434
665
  }
435
666
  }
436
667
 
437
- const count = normalizeCount(args.count)
438
668
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
439
669
  // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
440
670
  // по порядку (fal → custom → codex → grok), собирая причины отказов.
441
671
  const order = fallbackOrder(provider)
442
- const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
672
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
443
673
  const images = []
444
- for (let i = 0; i < count; i += 1) {
445
- images.push(await tryGenerate(generators, order, seedBase + i))
674
+ const historyEntries = cfg.cacheBySeed ? await readHistory() : []
675
+ const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
676
+ if (batchPrompts) {
677
+ for (let i = 0; i < batchPrompts.length; i += 1) {
678
+ const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, batchPrompts[i])) : undefined
679
+ images.push(cached || await tryGenerate(generators, order, seedBase + i, batchPrompts[i]))
680
+ }
681
+ } else {
682
+ const count = normalizeCount(args.count)
683
+ for (let i = 0; i < count; i += 1) {
684
+ const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : undefined
685
+ images.push(cached || await tryGenerate(generators, order, seedBase + i))
686
+ }
446
687
  }
447
688
  const first = images[0]
448
689
  return { ...first, images }
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', 'seedream', 'gemini']
11
11
 
12
12
  /** Привести count из аргумента инструмента к диапазону 1..4. */
13
13
  /** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
@@ -21,11 +21,11 @@ export function fallbackOrder(primary) {
21
21
  * @param generators - массив функций (key, seed) => Promise<generated>.
22
22
  * @param order - порядок ключей, длина = generators.length.
23
23
  */
24
- export async function tryGenerate(generators, order, seed) {
24
+ export async function tryGenerate(generators, order, seed, promptArg) {
25
25
  const refusals = []
26
26
  for (const key of order) {
27
27
  try {
28
- const produced = await generators[key](seed)
28
+ const produced = await generators[key](seed, promptArg)
29
29
  return produced
30
30
  } catch (e) {
31
31
  refusals.push(`${key}: ${e.message || String(e)}`)
@@ -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,13 +157,33 @@ 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, quality } = job
163
183
 
164
- async function fal(seedArg = seed) {
184
+ async function fal(seedArg = seed, promptArg = prompt) {
165
185
  const key = await resolveKey(cfg.apiKeyEnv)
166
- const body = { prompt, image_size: size, num_images: 1 }
186
+ const body = { prompt: promptArg, image_size: size, num_images: 1 }
167
187
  if (seedArg !== undefined) body.seed = seedArg
168
188
  if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
169
189
  if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
@@ -193,11 +213,12 @@ 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
 
199
220
  // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
200
- async function custom(seedArg = seed) {
221
+ async function custom(seedArg = seed, promptArg = prompt) {
201
222
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
202
223
  if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
203
224
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
@@ -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: promptArg, size: cfg.customSize || SIZE_PIXELS[size] || size, strength })
240
+ : JSON.stringify({
241
+ model: cfg.customModel,
242
+ prompt: promptArg,
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(() => ({}))
@@ -267,7 +291,10 @@ export function makeProviders(deps, job) {
267
291
  // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
268
292
  // отдаёт готовую картинку, а не ключ доступа.
269
293
  function subscription(provider) {
270
- return async function generate(seedArg = seed) {
294
+ return async function generate(seedArg = seed, promptArg = prompt) {
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 {
@@ -280,7 +307,7 @@ export function makeProviders(deps, job) {
280
307
  try {
281
308
  produced = await images.generate({
282
309
  provider,
283
- prompt,
310
+ prompt: promptArg,
284
311
  size: SUBSCRIPTION_SIZES[size] || '1024x1024',
285
312
  quality: cfg.subscriptionQuality || undefined,
286
313
  signal,
@@ -305,5 +332,158 @@ 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, promptArg = prompt) {
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: promptArg,
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: promptArg, 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
+ // Seedream (ByteDance): OpenAI-совместимый API картинок.
433
+ async function seedream(seedArg = seed, promptArg = prompt) {
434
+ const key = await resolveKey(cfg.seedreamKeyEnv)
435
+ const base = 'https://api.bytedanceapi.com/v1'
436
+ const res = await fetchImpl(`${base}/images/generations`, {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
439
+ body: JSON.stringify({
440
+ model: cfg.seedreamModel || 'seedream-4.0',
441
+ prompt: promptArg,
442
+ n: 1,
443
+ size: SIZE_PIXELS[size] || size,
444
+ ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
445
+ ...(quality !== undefined ? { quality } : {}),
446
+ }),
447
+ signal,
448
+ })
449
+ const data = await res.json().catch(() => ({}))
450
+ if (!res.ok) {
451
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
452
+ throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
453
+ }
454
+ const item = data?.data?.[0]
455
+ if (!item) throw new Error('Seedream returned no images')
456
+ if (item.b64_json) {
457
+ return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
458
+ }
459
+ if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
460
+ const dl = await fetchImpl(item.url, { signal })
461
+ if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
462
+ return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
463
+ }
464
+
465
+ // Gemini (Google): images.generate через GenAI API.
466
+ async function gemini(seedArg = seed, promptArg = prompt) {
467
+ const key = await resolveKey(cfg.geminiKeyEnv)
468
+ const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
469
+ const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
470
+ method: 'POST',
471
+ headers: { 'Content-Type': 'application/json' },
472
+ body: JSON.stringify({
473
+ contents: [{ parts: [{ text: promptArg }] }],
474
+ generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}) },
475
+ }),
476
+ signal,
477
+ })
478
+ const data = await res.json().catch(() => ({}))
479
+ if (!res.ok) {
480
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
481
+ throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
482
+ }
483
+ const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
484
+ if (!part) throw new Error('Gemini returned no image')
485
+ return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
486
+ }
487
+
488
+ return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini }
309
489
  }
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.8",
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",