@goodandready/dsh-image-gen 0.8.0 → 0.8.2

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 dsh-fal-image-gen contributors
3
+ Copyright (c) 2026 dsh-image-gen contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/lib/client.js CHANGED
@@ -796,30 +796,46 @@ window.__ModuleLoader__.load({
796
796
  FalImageCard,
797
797
  ),
798
798
  )
799
- // Свой раздел в боковом списке настроек, а не карточка во вкладке
800
- // «Настройки плагинов».
799
+ // Карточка во вкладке «Настройки плагинов» вместо строки в боковом
800
+ // списке: список плоский и перестал помещаться, а карточки вкладки
801
+ // сворачиваются. Ключ слота обязан равняться пространству настроек:
802
+ // вкладка перебирает именно их, с чужим ключом карточка исчезла бы молча.
803
+ // Заголовок, пояснение и сворачивание рисует сама карточка — ядро даёт
804
+ // только рамку списка.
801
805
  //
802
- // Ту вкладку рисует ядро, и в ней карточка появляется только если
803
- // namespace успел попасть в общий вид настроек. У части установок эта
804
- // вкладка остаётся пустой — плагины при этом грузятся штатно, ошибок нет,
805
- // и повлиять на неё из плагина нечем. Собственный раздел рисуем мы сами,
806
- // рядом с Vision и Голосом, и он от этой вкладки не зависит.
807
- //
808
- // Заодно отпадает возня с keyed-слотом: settings.section принимает id на
809
- // всех версиях ядра, и держать сразу id и key больше не нужно.
810
- ctx.slots.inject('settings.section', () =>
806
+ // Запасной путь: слот объявлен пакетом настроек ядра. Если в сборке его
807
+ // нет, регистрация бросает — тогда остаётся прежний раздел в боковом
808
+ // списке, чтобы настройки не пропали.
809
+ let placedInPluginsTab = false
810
+ try {
811
811
  ctx.slots.register(
812
812
  {
813
- name: 'settings.section',
814
- id: '@goodandready/dsh-image-gen',
815
- order: 34,
813
+ name: 'settings.plugin.item',
814
+ key: SETTINGS_NS,
816
815
  locale: NS,
817
- label: () => t('settings.navLabel'),
818
816
  inject: () => cardOnce().inject(),
819
817
  },
820
818
  FalSettingsCard,
821
- ),
822
- )
819
+ )
820
+ placedInPluginsTab = true
821
+ } catch (noPluginItemSlot) {
822
+ placedInPluginsTab = false
823
+ }
824
+ if (!placedInPluginsTab) {
825
+ ctx.slots.inject('settings.section', () =>
826
+ ctx.slots.register(
827
+ {
828
+ name: 'settings.section',
829
+ id: '@goodandready/dsh-image-gen',
830
+ order: 34,
831
+ locale: NS,
832
+ label: () => t('settings.navLabel'),
833
+ inject: () => cardOnce().inject(),
834
+ },
835
+ FalSettingsCard,
836
+ ),
837
+ )
838
+ }
823
839
  }
824
840
  exports.apply = apply
825
841
  exports.inject = inject
package/lib/index.js CHANGED
@@ -21,11 +21,13 @@ import {
21
21
  IMAGE_SIZES,
22
22
  OUTPUT_FORMATS,
23
23
  PROVIDER_KEYS,
24
+ buildSidecar,
24
25
  makeProviders,
26
+ normalizeCount,
25
27
  normalizeMediaType,
26
28
  } from './providers.js'
27
29
 
28
- export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, normalizeMediaType }
30
+ export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType }
29
31
 
30
32
  export const name = 'dsh-image-gen'
31
33
 
@@ -246,7 +248,7 @@ export function apply(ctx, config) {
246
248
  + 'Saves the image to the session workspace and shows it in the conversation. '
247
249
  + 'Depending on how the deployment is configured the result carries either the image itself or a link to it; '
248
250
  + 'when you only get a link, answer from the prompt and the link rather than claiming to see the picture. '
249
- + 'Use for any text-to-image request.',
251
+ + 'Use for any text-to-image request. Pass count (1-4) to generate several variations in one call; cost scales with count (default 1).',
250
252
  parameters: {
251
253
  prompt: {
252
254
  type: 'string',
@@ -270,6 +272,12 @@ export function apply(ctx, config) {
270
272
  type: 'string',
271
273
  description: 'Optional file name stem for the saved image (defaults to a slug of the prompt).',
272
274
  },
275
+ count: {
276
+ type: 'integer',
277
+ minimum: 1,
278
+ maximum: 4,
279
+ description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
280
+ },
273
281
  },
274
282
  output: {
275
283
  schema: {
@@ -295,19 +303,34 @@ export function apply(ctx, config) {
295
303
  name: { type: 'string' },
296
304
  },
297
305
  },
306
+ images: {
307
+ type: 'array',
308
+ items: {
309
+ type: 'object',
310
+ additionalProperties: false,
311
+ properties: {
312
+ path: { type: 'string' },
313
+ url: { type: 'string' },
314
+ width: { type: 'integer' },
315
+ height: { type: 'integer' },
316
+ seed: { type: 'integer' },
317
+ prompt: { type: 'string' },
318
+ format: { type: 'string' },
319
+ },
320
+ },
321
+ },
298
322
  },
299
323
  },
300
- render(args, value) {
301
- const summary = `Image generated (${value.width}×${value.height}, ${value.format}, seed ${value.seed}): ${value.path}`
302
- // "link": the result stays text-only, so a chat model without vision
303
- // reads it fine; the card turns the link back into a picture.
304
- // "image": the picture travels in the result itself, which only a
305
- // vision-capable model — or dsh-vision-bridge — can handle.
324
+ render(args, value) {
325
+ const list = value.images || [value]
326
+ const lines = list.map((img) => `Image generated (${img.width}×${img.height}, ${img.format}, seed ${img.seed}): ${img.path}`)
327
+ const summary = lines.join('\n')
306
328
  if (live().deliverAs !== 'image') {
307
- return [{ type: 'text', text: value.url ? `${summary}\n${value.url}` : summary }]
329
+ const urls = list.map((img) => img.url).filter(Boolean)
330
+ return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
308
331
  }
309
332
  const blocks = [{ type: 'text', text: summary }]
310
- if (value.attachment) blocks.push({ type: 'image', attachment: value.attachment })
333
+ for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
311
334
  return blocks
312
335
  },
313
336
  },
@@ -334,55 +357,83 @@ export function apply(ctx, config) {
334
357
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
335
358
  { prompt: args.prompt, size, format, seed: args.seed, signal: exec.signal },
336
359
  )
337
- const generated = await providers[provider]()
338
- const bytes = generated.bytes
360
+ // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
361
+ // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
362
+ // байтами, и пользователь получал битую карточку вместо внятного отказа.
363
+ const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
364
+ const one = async (jobSeed) => {
365
+ const gen = await providers[provider](jobSeed)
366
+ guard(gen)
367
+ const bytes = gen.bytes
368
+ const mediaType = gen.mediaType
369
+ const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
370
+ const stem = `${slugify(args.output_name || args.prompt)}-${Date.now().toString(36)}-${jobSeed}`
371
+ const name = `${stem}.${extension}`
339
372
 
340
- const mediaType = generated.mediaType
341
- const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
342
- const stem = `${slugify(args.output_name || args.prompt)}-${Date.now().toString(36)}`
343
- const name = `${stem}.${extension}`
373
+ const attachment = await ctx.attachments.saveImage({
374
+ data: new Uint8Array(bytes),
375
+ mediaType,
376
+ name,
377
+ })
344
378
 
345
- const attachment = await ctx.attachments.saveImage({
346
- data: new Uint8Array(bytes),
347
- mediaType,
348
- name,
349
- })
379
+ const sessionCwd = exec.agent?.session?.header?.cwd
380
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir)
381
+ await mkdir(outDir, { recursive: true })
382
+ const filePath = path.join(outDir, name)
383
+ await writeFile(filePath, bytes)
350
384
 
351
- // Relative outputDir belongs to the session's workspace, not to the
352
- // directory the harness happens to be started from.
353
- const sessionCwd = exec.agent?.session?.header?.cwd
354
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir)
355
- await mkdir(outDir, { recursive: true })
356
- const filePath = path.join(outDir, name)
357
- await writeFile(filePath, bytes)
385
+ const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
386
+ + '&mt=' + encodeURIComponent(attachment.mediaType)
387
+ + '&b=' + encodeURIComponent(String(attachment.bytes))
388
+ + '&w=' + encodeURIComponent(String(attachment.width))
389
+ + '&h=' + encodeURIComponent(String(attachment.height))
358
390
 
359
- // The plugin's own route outlives the fal.media link, and the card
360
- // needs the reference anyway to verify the bytes.
361
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
362
- + '&mt=' + encodeURIComponent(attachment.mediaType)
363
- + '&b=' + encodeURIComponent(String(attachment.bytes))
364
- + '&w=' + encodeURIComponent(String(attachment.width))
365
- + '&h=' + encodeURIComponent(String(attachment.height))
391
+ // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
392
+ // самодокументируемым (галерея/повтор/диагностика читают их без БД).
393
+ await writeFile(
394
+ path.join(outDir, `${stem}.json`),
395
+ JSON.stringify(buildSidecar({
396
+ prompt: args.prompt,
397
+ size,
398
+ format,
399
+ seed: gen.seed,
400
+ provider,
401
+ deliverAs,
402
+ width: gen.width || attachment.width,
403
+ height: gen.height || attachment.height,
404
+ mediaType,
405
+ attachmentId: attachment.attachmentId,
406
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
407
+ }), null, 2),
408
+ )
366
409
 
367
- return {
368
- path: filePath,
369
- // Прямой ссылки может и не быть: провайдер, отдающий base64, ничего
370
- // не публикует. Тогда и в режиме "image" остаётся наша ссылка.
371
- url: deliverAs === 'image' && generated.sourceUrl ? generated.sourceUrl : localUrl,
372
- width: generated.width || attachment.width,
373
- height: generated.height || attachment.height,
374
- seed: generated.seed,
375
- prompt: args.prompt,
376
- format: mediaType.replace('image/', ''),
377
- attachment: {
378
- attachmentId: attachment.attachmentId,
379
- mediaType: attachment.mediaType,
380
- bytes: attachment.bytes,
381
- width: attachment.width,
382
- height: attachment.height,
383
- name: attachment.name,
384
- },
410
+ return {
411
+ path: filePath,
412
+ url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
413
+ width: gen.width || attachment.width,
414
+ height: gen.height || attachment.height,
415
+ seed: gen.seed,
416
+ prompt: args.prompt,
417
+ format: mediaType.replace('image/', ''),
418
+ attachment: {
419
+ attachmentId: attachment.attachmentId,
420
+ mediaType: attachment.mediaType,
421
+ bytes: attachment.bytes,
422
+ width: attachment.width,
423
+ height: attachment.height,
424
+ name: attachment.name,
425
+ },
426
+ }
427
+ }
428
+
429
+ const count = normalizeCount(args.count)
430
+ const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
431
+ const images = []
432
+ for (let i = 0; i < count; i += 1) {
433
+ images.push(await one(seedBase + i))
385
434
  }
435
+ const first = images[0]
436
+ return { ...first, images }
386
437
  },
387
438
  }),
388
439
  )
package/lib/providers.js CHANGED
@@ -9,6 +9,13 @@
9
9
 
10
10
  export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok']
11
11
 
12
+ /** Привести count из аргумента инструмента к диапазону 1..4. */
13
+ export function normalizeCount(count) {
14
+ const n = Math.floor(Number(count))
15
+ if (!Number.isFinite(n)) return 1
16
+ return Math.max(1, Math.min(4, n))
17
+ }
18
+
12
19
  // Размеры, которыми оперируют подписки, — свой набор, не похожий на именованные
13
20
  // размеры FAL. Перевод один к одному по смыслу: квадрат, вертикаль, горизонталь.
14
21
  export const SUBSCRIPTION_SIZES = {
@@ -65,6 +72,14 @@ export function normalizeMediaType(contentType, fallbackFormat) {
65
72
  return 'image/png'
66
73
  }
67
74
 
75
+ /** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
76
+ export function buildSidecar({
77
+ prompt, size, format, seed, provider, deliverAs,
78
+ width, height, mediaType, attachmentId, url, createdAt = new Date().toISOString(),
79
+ }) {
80
+ return { prompt, size, format, seed, provider, deliverAs, width, height, mediaType, attachmentId, url, createdAt }
81
+ }
82
+
68
83
  /** Submit a generation job to the FAL queue. */
69
84
  export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
70
85
  const res = await fetchImpl(`${baseURL}/${model}`, {
@@ -122,10 +137,10 @@ export function makeProviders(deps, job) {
122
137
  const { fetchImpl, resolveKey, cfg } = deps
123
138
  const { prompt, size, format, seed, signal } = job
124
139
 
125
- async function fal() {
140
+ async function fal(seedArg = seed) {
126
141
  const key = await resolveKey(cfg.apiKeyEnv)
127
142
  const body = { prompt, image_size: size, num_images: 1 }
128
- if (seed !== undefined) body.seed = seed
143
+ if (seedArg !== undefined) body.seed = seedArg
129
144
  if (format !== 'png') body.output_format = format
130
145
 
131
146
  const submit = await submitJob(fetchImpl, cfg.baseURL, cfg.model, key, body, signal)
@@ -150,13 +165,13 @@ export function makeProviders(deps, job) {
150
165
  mediaType: normalizeMediaType(image.content_type, format),
151
166
  width: image.width ?? 0,
152
167
  height: image.height ?? 0,
153
- seed: result.seed ?? seed ?? 0,
168
+ seed: result.seed ?? seedArg ?? 0,
154
169
  sourceUrl: image.url,
155
170
  }
156
171
  }
157
172
 
158
173
  // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
159
- async function custom() {
174
+ async function custom(seedArg = seed) {
160
175
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
161
176
  if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
162
177
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
@@ -196,7 +211,7 @@ export function makeProviders(deps, job) {
196
211
  mediaType: normalizeMediaType(data.output_format || '', format),
197
212
  width: 0,
198
213
  height: 0,
199
- seed: seed ?? 0,
214
+ seed: seedArg ?? 0,
200
215
  sourceUrl: '',
201
216
  }
202
217
  }
@@ -215,7 +230,7 @@ export function makeProviders(deps, job) {
215
230
  mediaType: normalizeMediaType(contentType, format),
216
231
  width: 0,
217
232
  height: 0,
218
- seed: seed ?? 0,
233
+ seed: seedArg ?? 0,
219
234
  sourceUrl: item.url,
220
235
  }
221
236
  }
@@ -224,7 +239,7 @@ export function makeProviders(deps, job) {
224
239
  // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
225
240
  // отдаёт готовую картинку, а не ключ доступа.
226
241
  function subscription(provider) {
227
- return async function generate() {
242
+ return async function generate(seedArg = seed) {
228
243
  const images = deps.subscriptionImages
229
244
  if (!images || typeof images.generate !== 'function') {
230
245
  return {
@@ -255,7 +270,7 @@ export function makeProviders(deps, job) {
255
270
  mediaType: 'image/png',
256
271
  width: 0,
257
272
  height: 0,
258
- seed: seed ?? 0,
273
+ seed: seedArg ?? 0,
259
274
  sourceUrl: '',
260
275
  revisedPrompt: first.revisedPrompt || '',
261
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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",