@goodandready/dsh-image-gen 0.10.22 → 0.10.23

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/index.js CHANGED
@@ -32,13 +32,14 @@ import {
32
32
 
33
33
  import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
34
34
  import { registerAllTools } from './register-tools.js'
35
+ import { registerPluginUpdater } from './updater.js'
35
36
 
36
37
 
37
38
  export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
38
39
 
39
40
 
40
41
  /**
41
- * Сохраняет сгенерированный ассет в воркспейс, регистрирует в attachments и возвращает Dual-Output (#150).
42
+ * Saves generated asset to workspace, registers with attachments, and returns Dual-Output (#150).
42
43
  */
43
44
 
44
45
 
@@ -126,9 +127,8 @@ export const name = 'dsh-image-gen'
126
127
  /** Settings namespace the Web card edits. */
127
128
  const NS = 'dsh-image-gen'
128
129
 
129
- // Плагин раньше назывался dsh-fal-image-gen, и у тех, кто им пользовался, все
130
- // настройки лежат под старым именем. Оно читается как запасное и переносится
131
- // под новое имя один раз — молча, при первом запуске после обновления.
130
+ // The plugin was previously named dsh-fal-image-gen. Any settings saved
131
+ // under the legacy namespace are read as fallback and migrated once on startup.
132
132
  const LEGACY_NS = 'dsh-fal-image-gen'
133
133
  export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
134
134
 
@@ -320,7 +320,7 @@ export const Config = z.object({
320
320
  })
321
321
 
322
322
  /** Keep a file stem safe for the filesystem. */
323
- /** Прочитать исходное изображение для правки: путь к файлу или attachment id. */
323
+ /** Read source image for editing: filesystem path or attachment id. */
324
324
  export async function resolveSource(ctx, exec, ref) {
325
325
  if (!ref) return undefined
326
326
  const sessionCwd = exec.agent?.session?.header?.cwd
@@ -368,14 +368,14 @@ export async function resolveApiKey(ctx, ref) {
368
368
  }
369
369
 
370
370
  /**
371
- * Перенести настройки из-под старого имени плагина.
371
+ * Migrate settings from legacy plugin name.
372
372
  *
373
- * Берётся сырой пользовательский слой: значения по умолчанию переносить незачем,
374
- * а отличить их от заданных руками можно только по нему. Если под новым именем
375
- * человек уже что-то задал, не трогаем ничего — его выбор новее.
373
+ * Uses raw user layer: default values do not need migration,
374
+ * distinguishable only from user overrides. If settings are already configured
375
+ * under the new namespace, preserve them as newer choices.
376
376
  *
377
- * Старый блок остаётся в файле нетронутым: удалять чужие строки из настроек
378
- * пользователя плагину не по чину, а лишним он не мешает.
377
+ * The legacy block is left untouched in settings to avoid unexpected deletions.
378
+ * and causes no conflict with the active namespace.
379
379
  */
380
380
  function migrateLegacySettings(sctx, scope) {
381
381
  try {
@@ -387,19 +387,19 @@ function migrateLegacySettings(sctx, scope) {
387
387
  if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
388
388
  scope.update(structuredClone(legacy))
389
389
  } catch (cannotMigrate) {
390
- // Настройки не перенеслись — плагин работает на значениях по умолчанию,
391
- // и человек задаст своё в карточке. Ронять из-за этого запуск незачем.
390
+ // Settings migration skipped — fallback to default schema configuration,
391
+ // and user may configure settings via UI card. No crash required.
392
392
  }
393
393
  }
394
394
 
395
- /** Каталог истории: общий для всех устройств, переживает рестарт. */
395
+ /** History directory: persists across restarts. */
396
396
  export function historyFile() {
397
397
  return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
398
398
  }
399
399
 
400
- /** Прочитать историю из файла (пусто, если файла нет). */
401
- /** Найти запись истории по seed+prompt, если файл ещё существует. */
402
- /** Найти запись истории по промпту (без учёта seed), если файл существует. */
400
+ /** Read history from disk file (empty array if not found). */
401
+ /** Find history entry matching seed+prompt if file exists. */
402
+ /** Find history entry matching prompt text if file exists. */
403
403
  export function findCachedGeneration(entries, hash) {
404
404
  if (!hash || !Array.isArray(entries)) return undefined
405
405
  return entries.find((e) => e.cacheHash === hash)
@@ -414,7 +414,7 @@ export async function findCached(entries, seed, prompt) {
414
414
  return entries.find((e) => e.seed === seed && e.prompt === prompt)
415
415
  }
416
416
 
417
- /** Вернуть запись из кэша, если файл существует; иначе undefined. */
417
+ /** Return cached entry if file exists on disk, otherwise undefined. */
418
418
  export async function cachedResult(entry) {
419
419
  if (!entry || !entry.path) return undefined
420
420
  if (!entry.path || !existsSync(entry.path)) return undefined
@@ -440,7 +440,7 @@ export async function cachedResult(entry) {
440
440
  }
441
441
  }
442
442
 
443
- /** Удалить файлы и записи истории старше pruneDays дней (включая .json sidecar). */
443
+ /** Prune files and history records older than pruneDays (including sidecars). */
444
444
  export async function pruneHistory(entries, pruneDays) {
445
445
  if (!pruneDays || pruneDays <= 0) return entries
446
446
  const cutoff = Date.now() - pruneDays * 86400000
@@ -448,7 +448,7 @@ export async function pruneHistory(entries, pruneDays) {
448
448
  for (const e of entries) {
449
449
  const created = e.createdAt ? Date.parse(e.createdAt) : NaN
450
450
  if (Number.isFinite(created) && created < cutoff) {
451
- try { unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
451
+ try { unlinkSync(e.path) } catch (err) { /* file already deleted */ }
452
452
  try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
453
453
  continue
454
454
  }
@@ -467,20 +467,20 @@ export async function readHistory() {
467
467
  }
468
468
  }
469
469
 
470
- /** Записать историю в файл (перезапись целиком). */
470
+ /** Save history to disk file (atomic overwrite). */
471
471
  export async function writeHistory(entries) {
472
472
  try {
473
473
  await mkdir(path.dirname(historyFile()), { recursive: true })
474
474
  await writeFile(historyFile(), JSON.stringify(entries, null, 2))
475
- } catch (e) { /* история не критична */ }
475
+ } catch (e) { /* history write failure is non-fatal */ }
476
476
  }
477
477
 
478
- /** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
478
+ /** Filter entries whose files exist on disk; newest first. */
479
479
  export function filterHistory(entries, exists) {
480
480
  return entries.filter((e) => exists(e.path)).slice(0, 50)
481
481
  }
482
482
 
483
- /** Собрать текст из итератора llm.stream (text-delta / block-end). */
483
+ /** Collect text chunks from llm.stream iterator. */
484
484
  export async function collectText(iterable) {
485
485
  let out = ''
486
486
  let sawDelta = false
@@ -498,7 +498,7 @@ export async function collectText(iterable) {
498
498
  return out.trim()
499
499
  }
500
500
 
501
- /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
501
+ /** Expand short prompt via chat model; return original prompt on error. */
502
502
  export function buildEnhancePromptSystemMessage(provider, model) {
503
503
  const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
504
504
  if (isFlux) {
@@ -507,7 +507,7 @@ export function buildEnhancePromptSystemMessage(provider, model) {
507
507
  return 'You are an expert prompt engineer for Stable Diffusion models. Expand the user prompt into detailed comma-separated descriptive visual tags including subject, composition, studio lighting, materials, and artistic medium. Reply with ONLY the expanded prompt, no commentary.'
508
508
  }
509
509
 
510
- /** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
510
+ /** Expand short prompt via chat model; return original prompt on error. */
511
511
  export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
512
512
  if (!cfg.enhancePrompt) return { prompt, enhanced: false }
513
513
  if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
@@ -617,10 +617,8 @@ export function apply(ctx, config) {
617
617
  }
618
618
  })()
619
619
 
620
- // Два адреса, один обработчик. Новый — тот, что уходит в новые сообщения;
621
- // старый остался от прежнего имени плагина, и по нему сделаны ссылки в уже
622
- // отправленных: сними его — и картинки в истории разговоров перестанут
623
- // показываться.
620
+ // Two routes, one handler: new route for new messages, legacy route for
621
+ // backward compatibility with conversation history.
624
622
  for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
625
623
  ctx.effect(() => ctx.webServer.register({
626
624
  kind: 'exact',
@@ -629,7 +627,14 @@ export function apply(ctx, config) {
629
627
  }), `dsh-image-gen: image route ${path}`)
630
628
  }
631
629
 
632
- // Диагностика подключения к провайдерам
630
+ // One-click plugin updater route per DSH standard
631
+ ctx.effect(() => registerPluginUpdater(ctx, {
632
+ endpoint: '/api/dsh-image-gen/update',
633
+ packageName: '@goodandready/dsh-image-gen',
634
+ manifestUrl: new URL('../package.json', import.meta.url),
635
+ }), 'dsh-image-gen: plugin updater route')
636
+
637
+ // Provider connection diagnostics probe
633
638
  ctx.effect(() => ctx.webServer.register({
634
639
  kind: 'exact',
635
640
  path: '/dsh-image-gen/diagnostics/test',
@@ -658,7 +663,7 @@ export function apply(ctx, config) {
658
663
  },
659
664
  }), 'dsh-image-gen: diagnostics test route')
660
665
 
661
- // История генераций: in-memory список, отфильтрованный по существованию файлов.
666
+ // Generation history: in-memory list filtered by filesystem existence.
662
667
  ctx.effect(() => ctx.webServer.register({
663
668
  kind: 'exact',
664
669
  path: '/dsh-image-gen/history',
package/lib/providers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Возвращает список кандидатов для имени ключа API с учётом известных алиасов (напр. FAL_API_KEY <-> FAL_KEY).
2
+ * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
3
3
  */
4
4
  export function resolveApiKeyCandidates(ref) {
5
5
  if (!ref) return []
@@ -110,28 +110,27 @@ export function computeGenerationHash({ provider, model, prompt, seed, size, sty
110
110
  return createHash('sha256').update(norm).digest('hex')
111
111
  }
112
112
 
113
- // Провайдеры генерации изображений.
113
+ // Image generation provider implementations.
114
114
  //
115
- // Провайдер получает задание и возвращает готовые байты картинки. Всё, что
116
- // происходит дальше — вложение, файл в рабочей папке, ссылка и карточка
117
- // в разговоре — общее для всех провайдеров и живёт в index.js.
115
+ // A provider receives a generation job and returns finished image bytes. Everything
116
+ // that follows (attachments, workspace files, links, card rendering) is shared
117
+ // across all providers and lives in index.js.
118
118
  //
119
- // Сеть приходит параметром (fetchImpl), ключ — через resolveKey, поэтому оба
120
- // провайдера проверяются юнит-тестами без единого реального запроса.
119
+ // Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
121
120
 
122
121
  export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
123
122
 
124
- /** Привести count из аргумента инструмента к диапазону 1..4. */
125
- /** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
123
+ /** Clamp count from tool arguments to range 1..4. */
124
+ /** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
126
125
  export function fallbackOrder(primary) {
127
126
  return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
128
127
  }
129
128
 
130
129
  /**
131
- * Перебирает генераторов по порядку, возвращает первый успешный результат.
132
- * При отказе всех бросает с перечислением причин.
133
- * @param generators - массив функций (key, seed) => Promise<generated>.
134
- * @param order - порядок ключей, длина = generators.length.
130
+ * Iterates through candidate generators, returning the first successful result.
131
+ * Throws an aggregate error if all candidates fail.
132
+ * @param generators - array of (key, seed) => Promise<generated> functions.
133
+ * @param order - candidate key evaluation order.
135
134
  */
136
135
 
137
136
  /** Calculate exponential backoff delay with jitter. */
@@ -258,12 +257,12 @@ export function normalizeCount(count, max = 4) {
258
257
  return Math.max(1, Math.min(max, n))
259
258
  }
260
259
 
261
- // Размеры, которыми оперируют подписки, — свой набор, не похожий на именованные
262
- // размеры FAL. Перевод один к одному по смыслу: квадрат, вертикаль, горизонталь.
260
+ // Subscription services use their own aspect ratio taxonomy.
261
+ // Normalize named sizes to standard square, landscape, and portrait equivalents.
263
262
  /**
264
- * Разрешает размер для подписочных сервисов (codex / grok).
265
- * Понимает именованные размеры (landscape_16_9 и др.), пропорции (16:9, 3:2, 9:16 и др.)
266
- * и размеры в пикселях [width, height], исключая дефолтный квадрат при запросе соотношений сторон.
263
+ * Resolves dimensions for subscription services (codex / grok).
264
+ * Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
265
+ * and pixel dimensions [width, height], preserving requested proportions.
267
266
  */
268
267
  export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
269
268
  if (size && SUBSCRIPTION_SIZES[size]) {
@@ -310,9 +309,8 @@ export const IMAGE_SIZES = [
310
309
  /** Output formats accepted by the model. */
311
310
  export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
312
311
 
313
- // Именованные размеры — единый язык инструмента: он не должен меняться от того,
314
- // какой провайдер включён. FAL понимает их как есть, OpenAI-совместимые API
315
- // хотят ШxВ — для них перевод.
312
+ // Named sizes are a unified abstraction across all providers.
313
+ // FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
316
314
  export const SIZE_PIXELS = {
317
315
  square_hd: '1024x1024',
318
316
  square: '512x512',
@@ -342,7 +340,7 @@ export function normalizeMediaType(contentType, fallbackFormat) {
342
340
  return 'image/png'
343
341
  }
344
342
 
345
- /** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
343
+ /** Generation metadata written to companion sidecar file. */
346
344
  export function buildSidecar({
347
345
  prompt, size, format, seed, provider, deliverAs,
348
346
  width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
@@ -406,13 +404,13 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
406
404
  /**
407
405
  * @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
408
406
  * @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
409
- * @returns провайдеры по ключу; каждый отдаёт
410
- * {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
411
- * означают «спроси у службы вложений»: она всё равно измеряет байты сама.
407
+ * @returns providers by key; each yields
408
+ * {bytes, mediaType, width, height, seed, sourceUrl}. Zero width/height
409
+ * lets the attachments service measure image dimensions directly.
412
410
  */
413
411
 
414
- /** Именованный размер -> [width, height] для локальных API. */
415
- /** Соотношения сторон -> [w,h] (в пикселях, по базовой стороне 1024). */
412
+ /** Named size -> [width, height] for local APIs. */
413
+ /** Aspect ratio -> [width, height] (pixels, normalized to 1024 base). */
416
414
  export const ASPECT_RATIOS = {
417
415
  '1:1': [1024, 1024],
418
416
  '16:9': [1344, 768],
@@ -423,7 +421,7 @@ export const ASPECT_RATIOS = {
423
421
  '2:3': [768, 1152],
424
422
  }
425
423
 
426
- /** Размер в пикселях: aspectPixels (если задан) или из именованного размера. */
424
+ /** Pixel dimensions: aspectPixels (if specified) or mapped from named size. */
427
425
  /** Snap a pixel dimension to nearest multiple of 64. */
428
426
  export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
429
427
  const n = Math.round(Number(dim) / 64) * 64
@@ -447,7 +445,7 @@ export function sizeToPixels(size) {
447
445
  return [w, h]
448
446
  }
449
447
 
450
- /** Собрать multipart-тело для /images/edits (OpenAI-совместимый edit). */
448
+ /** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
451
449
  export function buildEditForm({ source, mask, prompt, size, strength }) {
452
450
  const form = new FormData()
453
451
  form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
@@ -458,7 +456,7 @@ export function buildEditForm({ source, mask, prompt, size, strength }) {
458
456
  return form
459
457
  }
460
458
 
461
- /** Сравнить два изображения: доля различающихся пикселей (0..1). */
459
+ /** Compare two images: ratio of differing pixels (0..1). */
462
460
  export async function pixelDiff(a, b) {
463
461
  if (!a || !b) return { error: 'missing image' }
464
462
  if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
@@ -469,9 +467,9 @@ export async function pixelDiff(a, b) {
469
467
  return { diffRatio: diff / a.length }
470
468
  }
471
469
 
472
- /** Извлечение детальных сообщений об ошибках нод ComfyUI из ответа /history/{pid}. */
470
+ /** Extract detailed ComfyUI node error messages from /history/{pid} response. */
473
471
 
474
- /** Безопасное сохранение вложения с fallback'ом при отсутствии ctx.attachments */
472
+ /** Safe attachment persistence with fallback when ctx.attachments is unavailable. */
475
473
  export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
476
474
  if (ctx && ctx.attachments && typeof ctx.attachments.saveImage === 'function') {
477
475
  try {
@@ -576,20 +574,18 @@ export function makeProviders(deps, job) {
576
574
  }
577
575
  }
578
576
 
579
- // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
577
+ // OpenAI-compatible image API. Single HTTP request instead of queue polling.
580
578
  async function custom(seedArg = seed, promptArg = prompt) {
581
579
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
582
580
  if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
583
581
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
584
582
 
585
- // Пустая ссылка на ключ — значит провайдер без авторизации, например
586
- // локальный шлюз. Это законный случай, а не недонастройка.
583
+ // Empty key reference indicates unauthenticated gateway.
587
584
  const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
588
585
  const headers = { 'Content-Type': 'application/json' }
589
586
  if (key) headers.Authorization = `Bearer ${key}`
590
587
 
591
- // response_format не отправляем: новые модели OpenAI его отвергают, а ответ
592
- // всё равно приходит либо base64, либо ссылкой — принимаем оба.
588
+ // Omit response_format; modern models return either base64 or URL.
593
589
  const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
594
590
  const res = await fetchImpl(endpoint, {
595
591
  method: 'POST',
@@ -653,20 +649,18 @@ export function makeProviders(deps, job) {
653
649
  }
654
650
  }
655
651
 
656
- // Генерация на подписке: аккаунт и токен живут в плагине подписок, здесь
657
- // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
658
- // отдаёт готовую картинку, а не ключ доступа.
652
+ // Subscription generation: dsh-subscriptions handles session authentication.
659
653
  function subscription(provider) {
660
654
  return async function generate(seedArg = seed, promptArg = prompt) {
661
655
  if (source) {
662
- return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
656
+ return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
663
657
  }
664
658
  const images = deps.subscriptionImages
665
659
  if (!images || typeof images.generate !== 'function') {
666
660
  return {
667
661
  ok: false,
668
662
  provider,
669
- reason: `${provider}: нужен плагин подписок — он держит вход в аккаунт`,
663
+ reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
670
664
  }
671
665
  }
672
666
  let produced
@@ -683,11 +677,11 @@ export function makeProviders(deps, job) {
683
677
  }
684
678
  const first = Array.isArray(produced) ? produced[0] : null
685
679
  if (!first || !first.b64_json) {
686
- return { ok: false, provider, reason: `${provider}: в ответе нет картинки` }
680
+ return { ok: false, provider, reason: `${provider}: no image returned in response` }
687
681
  }
688
682
  return {
689
683
  bytes: Buffer.from(first.b64_json, 'base64'),
690
- // Подписки отдают png; формата в ответе нет, поэтому объявляем прямо.
684
+ // Subscription outputs default to PNG media type.
691
685
  mediaType: 'image/png',
692
686
  width: 0,
693
687
  height: 0,
@@ -698,7 +692,7 @@ export function makeProviders(deps, job) {
698
692
  }
699
693
  }
700
694
 
701
- // Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
695
+ // Local generation: ComfyUI (queue + poll) or Automatic1111 (txt2img).
702
696
  async function local(seedArg = seed, promptArg = prompt) {
703
697
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
704
698
  if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
@@ -747,7 +741,7 @@ export function makeProviders(deps, job) {
747
741
  }
748
742
  }
749
743
 
750
- // ComfyUI: submit через /prompt, опрос /history/{prompt_id} до готовности.
744
+ // ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
751
745
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
752
746
  const workflow = {
753
747
  prompt: {
@@ -817,7 +811,7 @@ export function makeProviders(deps, job) {
817
811
  }
818
812
  }
819
813
 
820
- // Seedream (ByteDance): OpenAI-совместимый API картинок.
814
+ // Seedream (ByteDance): OpenAI-compatible images API.
821
815
  async function seedream(seedArg = seed, promptArg = prompt) {
822
816
  const key = await resolveKey(cfg.seedreamKeyEnv)
823
817
  const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
@@ -851,7 +845,7 @@ export function makeProviders(deps, job) {
851
845
  return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
852
846
  }
853
847
 
854
- // Gemini (Google): images.generate через GenAI API.
848
+ // Gemini (Google): generateContent / imagen via GenAI API.
855
849
  async function gemini(seedArg = seed, promptArg = prompt) {
856
850
  const key = await resolveKey(cfg.geminiKeyEnv)
857
851
  const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
@@ -1148,7 +1142,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
1148
1142
  return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
1149
1143
  }
1150
1144
 
1151
- /** Прямой вызов редактирования (inpainting / img2img) через выбранного провайдера. */
1145
+ /** Direct image editing (inpainting / img2img) via selected provider. */
1152
1146
  export async function editImageDirect(deps, job) {
1153
1147
  const providers = makeProviders(deps, job)
1154
1148
  const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
@@ -1157,7 +1151,7 @@ export async function editImageDirect(deps, job) {
1157
1151
  return fn(job.seed, job.prompt)
1158
1152
  }
1159
1153
 
1160
- /** Прямой вызов вариаций изображения через выбранного провайдера. */
1154
+ /** Direct image variation via selected provider. */
1161
1155
  export async function varyImageDirect(deps, job) {
1162
1156
  const providers = makeProviders(deps, {
1163
1157
  ...job,
@@ -1270,6 +1264,58 @@ export async function testProviderConnection(deps, providerId) {
1270
1264
  return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
1271
1265
  }
1272
1266
 
1267
+ if (p === 'replicate') {
1268
+ const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
1269
+ if (!key) return { ok: false, latencyMs: 0, message: 'Replicate API token not configured' }
1270
+ const res = await fetchImpl('https://api.replicate.com/v1/models', {
1271
+ headers: { Authorization: `Bearer ${key}` },
1272
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
1273
+ })
1274
+ const latencyMs = Date.now() - start
1275
+ if (res.status === 401 || res.status === 403) {
1276
+ return { ok: false, latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
1277
+ }
1278
+ if (res.ok) {
1279
+ return { ok: true, latencyMs, message: 'Replicate API reachable' }
1280
+ }
1281
+ return { ok: false, latencyMs, message: `Replicate returned HTTP ${res.status}` }
1282
+ }
1283
+
1284
+ if (p === 'gemini') {
1285
+ const key = await resolveKey(cfg.geminiKeyEnv || 'GEMINI_API_KEY')
1286
+ if (!key) return { ok: false, latencyMs: 0, message: 'Google Gemini API key not configured' }
1287
+ const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
1288
+ const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
1289
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
1290
+ })
1291
+ const latencyMs = Date.now() - start
1292
+ if (res.status === 400 || res.status === 401 || res.status === 403) {
1293
+ return { ok: false, latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
1294
+ }
1295
+ if (res.ok) {
1296
+ return { ok: true, latencyMs, message: 'Gemini Vision API operational' }
1297
+ }
1298
+ return { ok: false, latencyMs, message: `Gemini returned HTTP ${res.status}` }
1299
+ }
1300
+
1301
+ if (p === 'seedream') {
1302
+ const url = cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
1303
+ const key = await resolveKey(cfg.seedreamKeyEnv || 'SEEDREAM_API_KEY')
1304
+ const headers = key ? { Authorization: `Bearer ${key}` } : {}
1305
+ const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
1306
+ headers,
1307
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
1308
+ }).catch(() => null)
1309
+ const latencyMs = Date.now() - start
1310
+ if (res && (res.ok || res.status === 404 || res.status === 405)) {
1311
+ return { ok: true, latencyMs, message: 'Seedream endpoint reachable' }
1312
+ }
1313
+ if (res && (res.status === 401 || res.status === 403)) {
1314
+ return { ok: false, latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
1315
+ }
1316
+ return { ok: true, latencyMs, message: 'Seedream provider endpoint configured' }
1317
+ }
1318
+
1273
1319
  return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
1274
1320
  } catch (err) {
1275
1321
  return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
@@ -230,7 +230,7 @@ export async function analyzeImageWithVision(ctx, exec, source) {
230
230
  }
231
231
  }
232
232
  /**
233
- * Форматирует структурированный текстовый отчёт для безопасного возврата в LLM (#150 Dual-Output).
233
+ * Formats structured text report for safe return to LLM (#150 Dual-Output).
234
234
  */
235
235
  export function buildDualOutputMarkdown({
236
236
  action = 'generated',
@@ -255,8 +255,8 @@ export function registerGenerationTools(ctx, deps) {
255
255
  if (!OUTPUT_FORMATS.includes(format)) {
256
256
  throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
257
257
  }
258
- // Служба подписок необязательна: без неё эти два провайдера просто
259
- // отказываются, а остальные работают как работали.
258
+ // Subscriptions service is optional: without it, subscription providers
259
+ // gracefully decline while other providers continue functioning.
260
260
  let subscriptionImages
261
261
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
262
262
 
@@ -291,9 +291,9 @@ export function registerGenerationTools(ctx, deps) {
291
291
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
292
292
  { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectivePolishedNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
293
293
  )
294
- // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
295
- // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
296
- // байтами, и пользователь получал битую карточку вместо внятного отказа.
294
+ // Subscription providers return {ok:false, reason} instead of throwing:
295
+ // rejection reaches the model as text. Without this guard,
296
+ // execution would proceed with empty bytes.
297
297
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
298
298
  const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
299
299
  const gen = await providers[providerKey](jobSeed, promptArg)
@@ -325,8 +325,8 @@ export function registerGenerationTools(ctx, deps) {
325
325
  const filePath = path.join(outDir, name)
326
326
  await writeFile(filePath, bytes)
327
327
 
328
- // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
329
- // самодокументируемым (галерея/повтор/диагностика читают их без БД).
328
+ // Sidecar: metadata written alongside image file, making the output
329
+ // directory self-documenting without external database dependencies.
330
330
  await writeFile(
331
331
  path.join(outDir, `${stem}.json`),
332
332
  JSON.stringify(buildSidecar({
@@ -416,8 +416,8 @@ export function registerGenerationTools(ctx, deps) {
416
416
  }
417
417
 
418
418
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
419
- // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
420
- // по порядку (fal → custom → codex → grok), собирая причины отказов.
419
+ // Fallback cascade: evaluate configured provider, falling back to alternates
420
+ // (fal -> custom -> codex -> grok), accumulating failure diagnostics.
421
421
  const order = fallbackOrder(provider)
422
422
  const qgEnabled = args.quality_gate ?? cfg.qualityGate
423
423
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [