@goodandready/dsh-image-gen 0.8.1 → 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/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  PROVIDER_KEYS,
24
24
  buildSidecar,
25
25
  makeProviders,
26
+ normalizeCount,
26
27
  normalizeMediaType,
27
28
  } from './providers.js'
28
29
 
@@ -247,7 +248,7 @@ export function apply(ctx, config) {
247
248
  + 'Saves the image to the session workspace and shows it in the conversation. '
248
249
  + 'Depending on how the deployment is configured the result carries either the image itself or a link to it; '
249
250
  + 'when you only get a link, answer from the prompt and the link rather than claiming to see the picture. '
250
- + '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).',
251
252
  parameters: {
252
253
  prompt: {
253
254
  type: 'string',
@@ -271,6 +272,12 @@ export function apply(ctx, config) {
271
272
  type: 'string',
272
273
  description: 'Optional file name stem for the saved image (defaults to a slug of the prompt).',
273
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
+ },
274
281
  },
275
282
  output: {
276
283
  schema: {
@@ -296,19 +303,34 @@ export function apply(ctx, config) {
296
303
  name: { type: 'string' },
297
304
  },
298
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
+ },
299
322
  },
300
323
  },
301
- render(args, value) {
302
- const summary = `Image generated (${value.width}×${value.height}, ${value.format}, seed ${value.seed}): ${value.path}`
303
- // "link": the result stays text-only, so a chat model without vision
304
- // reads it fine; the card turns the link back into a picture.
305
- // "image": the picture travels in the result itself, which only a
306
- // 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')
307
328
  if (live().deliverAs !== 'image') {
308
- 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 }]
309
331
  }
310
332
  const blocks = [{ type: 'text', text: summary }]
311
- 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 })
312
334
  return blocks
313
335
  },
314
336
  },
@@ -335,77 +357,83 @@ export function apply(ctx, config) {
335
357
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
336
358
  { prompt: args.prompt, size, format, seed: args.seed, signal: exec.signal },
337
359
  )
338
- const generated = await providers[provider]()
339
360
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
340
361
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
341
362
  // байтами, и пользователь получал битую карточку вместо внятного отказа.
342
- if (generated && generated.ok === false) throw new Error(generated.reason)
343
- const bytes = generated.bytes
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}`
344
372
 
345
- const mediaType = generated.mediaType
346
- const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
347
- const stem = `${slugify(args.output_name || args.prompt)}-${Date.now().toString(36)}`
348
- const name = `${stem}.${extension}`
373
+ const attachment = await ctx.attachments.saveImage({
374
+ data: new Uint8Array(bytes),
375
+ mediaType,
376
+ name,
377
+ })
349
378
 
350
- const attachment = await ctx.attachments.saveImage({
351
- data: new Uint8Array(bytes),
352
- mediaType,
353
- name,
354
- })
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)
355
384
 
356
- // Relative outputDir belongs to the session's workspace, not to the
357
- // directory the harness happens to be started from.
358
- const sessionCwd = exec.agent?.session?.header?.cwd
359
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir)
360
- await mkdir(outDir, { recursive: true })
361
- const filePath = path.join(outDir, name)
362
- await writeFile(filePath, bytes)
363
- // Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
364
- // самодокументируемым (галерея/повтор/диагностика читают их без БД).
365
- await writeFile(
366
- path.join(outDir, `${stem}.json`),
367
- JSON.stringify(buildSidecar({
368
- prompt: args.prompt,
369
- size,
370
- format,
371
- seed: generated.seed,
372
- provider,
373
- deliverAs,
374
- width: generated.width || attachment.width,
375
- height: generated.height || attachment.height,
376
- mediaType,
377
- attachmentId: attachment.attachmentId,
378
- url: deliverAs === 'image' && generated.sourceUrl ? generated.sourceUrl : localUrl,
379
- }), null, 2),
380
- )
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))
381
390
 
382
- // The plugin's own route outlives the fal.media link, and the card
383
- // needs the reference anyway to verify the bytes.
384
- const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
385
- + '&mt=' + encodeURIComponent(attachment.mediaType)
386
- + '&b=' + encodeURIComponent(String(attachment.bytes))
387
- + '&w=' + encodeURIComponent(String(attachment.width))
388
- + '&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
+ )
389
409
 
390
- return {
391
- path: filePath,
392
- // Прямой ссылки может и не быть: провайдер, отдающий base64, ничего
393
- // не публикует. Тогда и в режиме "image" остаётся наша ссылка.
394
- url: deliverAs === 'image' && generated.sourceUrl ? generated.sourceUrl : localUrl,
395
- width: generated.width || attachment.width,
396
- height: generated.height || attachment.height,
397
- seed: generated.seed,
398
- prompt: args.prompt,
399
- format: mediaType.replace('image/', ''),
400
- attachment: {
401
- attachmentId: attachment.attachmentId,
402
- mediaType: attachment.mediaType,
403
- bytes: attachment.bytes,
404
- width: attachment.width,
405
- height: attachment.height,
406
- name: attachment.name,
407
- },
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))
408
434
  }
435
+ const first = images[0]
436
+ return { ...first, images }
409
437
  },
410
438
  }),
411
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 = {
@@ -130,10 +137,10 @@ export function makeProviders(deps, job) {
130
137
  const { fetchImpl, resolveKey, cfg } = deps
131
138
  const { prompt, size, format, seed, signal } = job
132
139
 
133
- async function fal() {
140
+ async function fal(seedArg = seed) {
134
141
  const key = await resolveKey(cfg.apiKeyEnv)
135
142
  const body = { prompt, image_size: size, num_images: 1 }
136
- if (seed !== undefined) body.seed = seed
143
+ if (seedArg !== undefined) body.seed = seedArg
137
144
  if (format !== 'png') body.output_format = format
138
145
 
139
146
  const submit = await submitJob(fetchImpl, cfg.baseURL, cfg.model, key, body, signal)
@@ -158,13 +165,13 @@ export function makeProviders(deps, job) {
158
165
  mediaType: normalizeMediaType(image.content_type, format),
159
166
  width: image.width ?? 0,
160
167
  height: image.height ?? 0,
161
- seed: result.seed ?? seed ?? 0,
168
+ seed: result.seed ?? seedArg ?? 0,
162
169
  sourceUrl: image.url,
163
170
  }
164
171
  }
165
172
 
166
173
  // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
167
- async function custom() {
174
+ async function custom(seedArg = seed) {
168
175
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
169
176
  if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
170
177
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
@@ -204,7 +211,7 @@ export function makeProviders(deps, job) {
204
211
  mediaType: normalizeMediaType(data.output_format || '', format),
205
212
  width: 0,
206
213
  height: 0,
207
- seed: seed ?? 0,
214
+ seed: seedArg ?? 0,
208
215
  sourceUrl: '',
209
216
  }
210
217
  }
@@ -223,7 +230,7 @@ export function makeProviders(deps, job) {
223
230
  mediaType: normalizeMediaType(contentType, format),
224
231
  width: 0,
225
232
  height: 0,
226
- seed: seed ?? 0,
233
+ seed: seedArg ?? 0,
227
234
  sourceUrl: item.url,
228
235
  }
229
236
  }
@@ -232,7 +239,7 @@ export function makeProviders(deps, job) {
232
239
  // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
233
240
  // отдаёт готовую картинку, а не ключ доступа.
234
241
  function subscription(provider) {
235
- return async function generate() {
242
+ return async function generate(seedArg = seed) {
236
243
  const images = deps.subscriptionImages
237
244
  if (!images || typeof images.generate !== 'function') {
238
245
  return {
@@ -263,7 +270,7 @@ export function makeProviders(deps, job) {
263
270
  mediaType: 'image/png',
264
271
  width: 0,
265
272
  height: 0,
266
- seed: seed ?? 0,
273
+ seed: seedArg ?? 0,
267
274
  sourceUrl: '',
268
275
  revisedPrompt: first.revisedPrompt || '',
269
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.1",
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",