@goodandready/dsh-image-gen 0.8.5 → 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
@@ -99,7 +99,7 @@ window.__ModuleLoader__.load({
99
99
  function PluginSettingsCard(props) {
100
100
  // Карточка живёт в собственном разделе настроек и на странице одна, так
101
101
  // что открыта сразу: свёрнутая выглядела бы как пустая страница.
102
- const [open, setOpen] = react.useState(true)
102
+ const [open, setOpen] = react.useState(false)
103
103
  const { state } = props
104
104
  // Раньше здесь было `return null`, и раздел оказывался пустым, если хост
105
105
  // ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
@@ -407,17 +407,27 @@ window.__ModuleLoader__.load({
407
407
 
408
408
  // ------------------------------------------------------------- fields
409
409
  const IMAGE_SIZES = ['square_hd', 'square', 'portrait_4_3', 'portrait_16_9', 'landscape_4_3', 'landscape_16_9']
410
+ // Перевод именованного размера в пиксели для подсказки в карточке
411
+ // (значения совпадают с SIZE_PIXELS в хостовой половине).
412
+ const SIZE_PIXELS = {
413
+ square_hd: '1024x1024',
414
+ square: '512x512',
415
+ portrait_4_3: '768x1024',
416
+ portrait_16_9: '576x1024',
417
+ landscape_4_3: '1024x768',
418
+ landscape_16_9: '1024x576',
419
+ }
410
420
  const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
411
421
  const DELIVERY_MODES = ['link', 'image']
412
- const PROVIDERS = ['fal', 'custom', 'codex', 'grok']
422
+ const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local']
413
423
 
414
424
  /** Field specs in display order. */
415
425
  const FIELDS = [
416
- { 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: 'адрес сервера' } },
417
427
  { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
418
428
  { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
419
429
  { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
420
- { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint' },
430
+ { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS },
421
431
  { field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
422
432
  { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs' },
423
433
  { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs' },
@@ -446,7 +456,10 @@ window.__ModuleLoader__.load({
446
456
  onReset,
447
457
  }
448
458
  if (entry.kind === 'select') {
449
- return react_jsx_runtime.jsx(SelectField, { ...base, options: entry.options, inheritLabel: t('f.inherit') })
459
+ const px = entry.sizePixels && entry.sizePixels[state.text] ? ` — ${entry.sizePixels[state.text]}` : ''
460
+ const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ` (нужно: ${entry.providerNeeds[state.text]})` : ''
461
+ const hint = base.hint + px + need
462
+ return react_jsx_runtime.jsx(SelectField, { ...base, hint, options: entry.options, inheritLabel: t('f.inherit') })
450
463
  }
451
464
  return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
452
465
  }
@@ -511,7 +524,7 @@ window.__ModuleLoader__.load({
511
524
  /** Settings namespace the card edits (the Host plugin registers it). */
512
525
  const SETTINGS_NS = 'dsh-image-gen'
513
526
  /** Required client services. */
514
- const inject = ['slots', 'settingsScope', 'locale']
527
+ const inject = ['slots', 'settingsScope', 'locale', 'sessions']
515
528
 
516
529
  const en = {
517
530
  'settings.navLabel': 'Image generation',
@@ -636,7 +649,7 @@ window.__ModuleLoader__.load({
636
649
  // Карточки инструментов не рисуют image-блоки (это умеют только сообщения
637
650
  // ассистента), поэтому сгенерированная картинка показывается здесь —
638
651
  // keyed-запись слота tool.call.toolview по имени инструмента.
639
- const CARD_CSS = '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}'
652
+ const CARD_CSS = '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}' + '.fal-repeat{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border-radius:8px;padding:5px 14px;font-size:13px}' + '.fal-repeat:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}' + '.fal-repeat:disabled{opacity:.5;cursor:default}'
640
653
  + '.fal-card .fal-head{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}'
641
654
  + '.fal-card img{max-width:100%;border-radius:8px;display:block}'
642
655
  + '.fal-card .fal-prompt{font-size:12px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}'
@@ -697,8 +710,42 @@ window.__ModuleLoader__.load({
697
710
  const head = react.createElement('div', { className: 'fal-head' },
698
711
  running ? 'Генерирую изображение…' : failed ? 'Генерация не удалась' : 'Изображение')
699
712
 
713
+ // «Повторить генерацию»: отправляет в чат новое сообщение с теми же
714
+ // промптом и параметрами, извлечёнными из исходного вызова инструмента.
715
+ // Дальше модель сама решает, как сгенерировать, — это просто повторный
716
+ // запрос, а не вызов инструмента из карточки.
717
+ let args = {}
718
+ try {
719
+ const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
720
+ if (raw) args = JSON.parse(raw)
721
+ } catch (e) { /* ignore */ }
722
+ const repeatText = (() => {
723
+ const parts = [String(args.prompt || '').trim()]
724
+ const size = args.image_size || args.size
725
+ const seed = args.seed
726
+ if (size) parts.push(`Размер: ${size}`)
727
+ if (seed !== undefined) parts.push(`Сид: ${seed}`)
728
+ return parts.filter(Boolean).join(', ')
729
+ })()
730
+ const repeat = async () => {
731
+ try {
732
+ const sessions = props.sessions
733
+ const current = sessions && sessions.list && sessions.list.current
734
+ const binding = current && sessions.binding(current)
735
+ const session = binding && binding.session
736
+ if (!session || !session.prompt) {
737
+ return
738
+ }
739
+ const text = repeatText || String(args.prompt || '')
740
+ await session.prompt([{ type: 'text', text }], 'queue')
741
+ } catch (e) { /* контур сам покажет ошибку отправки */ }
742
+ }
743
+
700
744
  const body = []
701
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
+ }
702
749
  if (failed) {
703
750
  body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || 'неизвестная ошибка'))
704
751
  } else if (!parsed.attachment && parsed.url) {
@@ -729,6 +776,44 @@ window.__ModuleLoader__.load({
729
776
  body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
730
777
  }
731
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
+
788
+ // Диагностика: метаданные генерации (prompt/seed/size/format), чтобы было
789
+ // понятно, по каким параметрам получилась именно эта картинка.
790
+ const metaParts = []
791
+ if (args.image_size || args.size) metaParts.push(`size ${args.image_size || args.size}`)
792
+ if (args.output_format || args.format) metaParts.push(`format ${args.output_format || args.format}`)
793
+ if (args.seed !== undefined) metaParts.push(`seed ${args.seed}`)
794
+ const meta = metaParts.join(' · ')
795
+
796
+ // Кнопка повтора — только когда генерация завершена (не в процессе и не ошибка).
797
+ if (!running && !failed && meta) {
798
+ body.push(react.createElement('div', { key: 'meta', className: 'fal-meta' }, meta))
799
+ }
800
+ if (!running && !failed && repeatText) {
801
+ body.push(react.createElement('button', {
802
+ key: 'repeat',
803
+ type: 'button',
804
+ className: 'fal-repeat',
805
+ onClick: repeat,
806
+ }, 'Повторить генерацию'))
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
+ }
816
+
732
817
  return react.createElement('div', { className: 'fal-card' }, head, body)
733
818
  }
734
819
 
@@ -810,7 +895,7 @@ window.__ModuleLoader__.load({
810
895
  }
811
896
  ctx.slots.inject('tool.call.toolview', () =>
812
897
  ctx.slots.register(
813
- { name: 'tool.call.toolview', key: 'generate_image', locale: NS },
898
+ { name: 'tool.call.toolview', key: 'generate_image', locale: NS, inject: () => ({ sessions: ctx.sessions }) },
814
899
  FalImageCard,
815
900
  ),
816
901
  )
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,
@@ -24,6 +24,8 @@ import {
24
24
  buildSidecar,
25
25
  makeProviders,
26
26
  normalizeCount,
27
+ tryGenerate,
28
+ fallbackOrder,
27
29
  normalizeMediaType,
28
30
  } from './providers.js'
29
31
 
@@ -38,7 +40,7 @@ const NS = 'dsh-image-gen'
38
40
  // настройки лежат под старым именем. Оно читается как запасное и переносится
39
41
  // под новое имя один раз — молча, при первом запуске после обновления.
40
42
  const LEGACY_NS = 'dsh-fal-image-gen'
41
- export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
43
+ export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
42
44
 
43
45
  export const Config = z.object({
44
46
  provider: z
@@ -106,13 +108,62 @@ export const Config = z.object({
106
108
  .description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
107
109
  + 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
108
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),
109
131
  outputDir: z
110
132
  .string()
111
133
  .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
112
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),
113
151
  })
114
152
 
115
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
+
116
167
  export function slugify(input) {
117
168
  const stem = String(input ?? '')
118
169
  .toLowerCase()
@@ -163,6 +214,53 @@ function migrateLegacySettings(sctx, scope) {
163
214
  }
164
215
  }
165
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
+
166
264
  export function apply(ctx, config) {
167
265
 
168
266
  // The Web card edits this namespace; without registering it the card binds to
@@ -240,6 +338,22 @@ export function apply(ctx, config) {
240
338
  }), `dsh-image-gen: image route ${path}`)
241
339
  }
242
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
+
243
357
  ctx.tools.register(
244
358
  defineTool({
245
359
  name: 'generate_image',
@@ -276,6 +390,26 @@ export function apply(ctx, config) {
276
390
  type: 'integer',
277
391
  description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
278
392
  },
393
+ negative_prompt: {
394
+ type: 'string',
395
+ description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
396
+ },
397
+ guidance_scale: {
398
+ type: 'number',
399
+ description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
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
+ },
279
413
  },
280
414
  output: {
281
415
  schema: {
@@ -351,21 +485,25 @@ export function apply(ctx, config) {
351
485
  let subscriptionImages
352
486
  try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
353
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
354
492
  const providers = makeProviders(
355
493
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
356
- { prompt: args.prompt, size, format, seed: args.seed, signal: exec.signal },
494
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength },
357
495
  )
358
496
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
359
497
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
360
498
  // байтами, и пользователь получал битую карточку вместо внятного отказа.
361
499
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
362
- const one = async (jobSeed) => {
363
- const gen = await providers[provider](jobSeed)
500
+ const one = async (jobSeed, providerKey) => {
501
+ const gen = await providers[providerKey](jobSeed)
364
502
  guard(gen)
365
503
  const bytes = gen.bytes
366
504
  const mediaType = gen.mediaType
367
505
  const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
368
- 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}`
369
507
  const name = `${stem}.${extension}`
370
508
 
371
509
  const attachment = await ctx.attachments.saveImage({
@@ -391,7 +529,7 @@ export function apply(ctx, config) {
391
529
  await writeFile(
392
530
  path.join(outDir, `${stem}.json`),
393
531
  JSON.stringify(buildSidecar({
394
- prompt: args.prompt,
532
+ prompt: effectivePrompt,
395
533
  size,
396
534
  format,
397
535
  seed: gen.seed,
@@ -402,16 +540,31 @@ export function apply(ctx, config) {
402
540
  mediaType,
403
541
  attachmentId: attachment.attachmentId,
404
542
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
543
+ cost: gen.cost,
405
544
  }), null, 2),
406
545
  )
407
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
+
408
559
  return {
409
560
  path: filePath,
410
561
  url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
411
562
  width: gen.width || attachment.width,
412
563
  height: gen.height || attachment.height,
413
564
  seed: gen.seed,
414
- prompt: args.prompt,
565
+ prompt: effectivePrompt,
566
+ originalPrompt: enhanced.enhanced ? args.prompt : undefined,
567
+ cost: gen.cost,
415
568
  format: mediaType.replace('image/', ''),
416
569
  attachment: {
417
570
  attachmentId: attachment.attachmentId,
@@ -426,9 +579,13 @@ export function apply(ctx, config) {
426
579
 
427
580
  const count = normalizeCount(args.count)
428
581
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
582
+ // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
583
+ // по порядку (fal → custom → codex → grok), собирая причины отказов.
584
+ const order = fallbackOrder(provider)
585
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
429
586
  const images = []
430
587
  for (let i = 0; i < count; i += 1) {
431
- images.push(await one(seedBase + i))
588
+ images.push(await tryGenerate(generators, order, seedBase + i))
432
589
  }
433
590
  const first = images[0]
434
591
  return { ...first, images }
package/lib/providers.js CHANGED
@@ -7,9 +7,33 @@
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
+ /** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
14
+ export function fallbackOrder(primary) {
15
+ return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
16
+ }
17
+
18
+ /**
19
+ * Перебирает генераторов по порядку, возвращает первый успешный результат.
20
+ * При отказе всех бросает с перечислением причин.
21
+ * @param generators - массив функций (key, seed) => Promise<generated>.
22
+ * @param order - порядок ключей, длина = generators.length.
23
+ */
24
+ export async function tryGenerate(generators, order, seed) {
25
+ const refusals = []
26
+ for (const key of order) {
27
+ try {
28
+ const produced = await generators[key](seed)
29
+ return produced
30
+ } catch (e) {
31
+ refusals.push(`${key}: ${e.message || String(e)}`)
32
+ }
33
+ }
34
+ throw new Error(`Image generation failed on all providers. ${refusals.join('; ')}`)
35
+ }
36
+
13
37
  export function normalizeCount(count) {
14
38
  const n = Math.floor(Number(count))
15
39
  if (!Number.isFinite(n)) return 1
@@ -75,9 +99,9 @@ export function normalizeMediaType(contentType, fallbackFormat) {
75
99
  /** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
76
100
  export function buildSidecar({
77
101
  prompt, size, format, seed, provider, deliverAs,
78
- width, height, mediaType, attachmentId, url, createdAt = new Date().toISOString(),
102
+ width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
79
103
  }) {
80
- 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 }
81
105
  }
82
106
 
83
107
  /** Submit a generation job to the FAL queue. */
@@ -133,14 +157,36 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
133
157
  * {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
134
158
  * означают «спроси у службы вложений»: она всё равно измеряет байты сама.
135
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
+
136
180
  export function makeProviders(deps, job) {
137
181
  const { fetchImpl, resolveKey, cfg } = deps
138
- const { prompt, size, format, seed, signal } = job
182
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength } = job
139
183
 
140
184
  async function fal(seedArg = seed) {
141
185
  const key = await resolveKey(cfg.apiKeyEnv)
142
186
  const body = { prompt, image_size: size, num_images: 1 }
143
187
  if (seedArg !== undefined) body.seed = seedArg
188
+ if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
189
+ if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
144
190
  if (format !== 'png') body.output_format = format
145
191
 
146
192
  const submit = await submitJob(fetchImpl, cfg.baseURL, cfg.model, key, body, signal)
@@ -167,6 +213,7 @@ export function makeProviders(deps, job) {
167
213
  height: image.height ?? 0,
168
214
  seed: result.seed ?? seedArg ?? 0,
169
215
  sourceUrl: image.url,
216
+ cost: result.cost,
170
217
  }
171
218
  }
172
219
 
@@ -184,15 +231,20 @@ export function makeProviders(deps, job) {
184
231
 
185
232
  // response_format не отправляем: новые модели OpenAI его отвергают, а ответ
186
233
  // всё равно приходит либо base64, либо ссылкой — принимаем оба.
187
- const res = await fetchImpl(`${base}/images/generations`, {
234
+ const endpoint = source ? `${base}/images/edits` : `${base}/images/generations`
235
+ const res = await fetchImpl(endpoint, {
188
236
  method: 'POST',
189
237
  headers,
190
- body: JSON.stringify({
191
- model: cfg.customModel,
192
- prompt,
193
- n: 1,
194
- size: cfg.customSize || SIZE_PIXELS[size] || size,
195
- }),
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
+ }),
196
248
  signal,
197
249
  })
198
250
  const data = await res.json().catch(() => ({}))
@@ -240,6 +292,9 @@ export function makeProviders(deps, job) {
240
292
  // отдаёт готовую картинку, а не ключ доступа.
241
293
  function subscription(provider) {
242
294
  return async function generate(seedArg = seed) {
295
+ if (source) {
296
+ return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
297
+ }
243
298
  const images = deps.subscriptionImages
244
299
  if (!images || typeof images.generate !== 'function') {
245
300
  return {
@@ -277,5 +332,102 @@ export function makeProviders(deps, job) {
277
332
  }
278
333
  }
279
334
 
280
- 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 }
281
433
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.5",
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",