@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 +1 -1
- package/lib/client.js +33 -17
- package/lib/index.js +104 -53
- package/lib/providers.js +23 -8
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 dsh-
|
|
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
|
-
//
|
|
804
|
-
//
|
|
805
|
-
|
|
806
|
-
|
|
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.
|
|
814
|
-
|
|
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
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
373
|
+
const attachment = await ctx.attachments.saveImage({
|
|
374
|
+
data: new Uint8Array(bytes),
|
|
375
|
+
mediaType,
|
|
376
|
+
name,
|
|
377
|
+
})
|
|
344
378
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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 (
|
|
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 ??
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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",
|