@goodandready/dsh-image-gen 0.8.6 → 0.8.8
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 +24 -3
- package/lib/index.js +253 -12
- package/lib/providers.js +201 -21
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -419,11 +419,11 @@ window.__ModuleLoader__.load({
|
|
|
419
419
|
}
|
|
420
420
|
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
421
421
|
const DELIVERY_MODES = ['link', 'image']
|
|
422
|
-
const PROVIDERS = ['fal', 'custom', 'codex', 'grok']
|
|
422
|
+
const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local']
|
|
423
423
|
|
|
424
424
|
/** Field specs in display order. */
|
|
425
425
|
const FIELDS = [
|
|
426
|
-
{ 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: 'адрес сервера' } },
|
|
427
427
|
{ field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
|
|
428
428
|
{ field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
|
|
429
429
|
{ field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
|
|
@@ -457,7 +457,8 @@ window.__ModuleLoader__.load({
|
|
|
457
457
|
}
|
|
458
458
|
if (entry.kind === 'select') {
|
|
459
459
|
const px = entry.sizePixels && entry.sizePixels[state.text] ? ` — ${entry.sizePixels[state.text]}` : ''
|
|
460
|
-
const
|
|
460
|
+
const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ` (нужно: ${entry.providerNeeds[state.text]})` : ''
|
|
461
|
+
const hint = base.hint + px + need
|
|
461
462
|
return react_jsx_runtime.jsx(SelectField, { ...base, hint, options: entry.options, inheritLabel: t('f.inherit') })
|
|
462
463
|
}
|
|
463
464
|
return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
|
|
@@ -742,6 +743,9 @@ window.__ModuleLoader__.load({
|
|
|
742
743
|
|
|
743
744
|
const body = []
|
|
744
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
|
+
}
|
|
745
749
|
if (failed) {
|
|
746
750
|
body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || 'неизвестная ошибка'))
|
|
747
751
|
} else if (!parsed.attachment && parsed.url) {
|
|
@@ -772,6 +776,15 @@ window.__ModuleLoader__.load({
|
|
|
772
776
|
body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
|
|
773
777
|
}
|
|
774
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
|
+
|
|
775
788
|
// Диагностика: метаданные генерации (prompt/seed/size/format), чтобы было
|
|
776
789
|
// понятно, по каким параметрам получилась именно эта картинка.
|
|
777
790
|
const metaParts = []
|
|
@@ -792,6 +805,14 @@ window.__ModuleLoader__.load({
|
|
|
792
805
|
onClick: repeat,
|
|
793
806
|
}, 'Повторить генерацию'))
|
|
794
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
|
+
}
|
|
795
816
|
|
|
796
817
|
return react.createElement('div', { className: 'fal-card' }, head, body)
|
|
797
818
|
}
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
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
|
+
import os from 'node:os'
|
|
19
20
|
import path from 'node:path'
|
|
20
21
|
import {
|
|
21
22
|
IMAGE_SIZES,
|
|
@@ -40,7 +41,7 @@ const NS = 'dsh-image-gen'
|
|
|
40
41
|
// настройки лежат под старым именем. Оно читается как запасное и переносится
|
|
41
42
|
// под новое имя один раз — молча, при первом запуске после обновления.
|
|
42
43
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
43
|
-
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
|
|
44
|
+
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
|
|
44
45
|
|
|
45
46
|
export const Config = z.object({
|
|
46
47
|
provider: z
|
|
@@ -108,13 +109,86 @@ export const Config = z.object({
|
|
|
108
109
|
.description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
|
|
109
110
|
+ 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
|
|
110
111
|
.default(''),
|
|
112
|
+
localKind: z
|
|
113
|
+
.string()
|
|
114
|
+
.description('provider=local: which local API to use — "comfyui" or "a1111".')
|
|
115
|
+
.default('comfyui'),
|
|
116
|
+
localBaseURL: z
|
|
117
|
+
.string()
|
|
118
|
+
.description('provider=local: server address, e.g. http://127.0.0.1:8188 (ComfyUI) or http://127.0.0.1:7860 (A1111).')
|
|
119
|
+
.default(''),
|
|
120
|
+
localModel: z
|
|
121
|
+
.string()
|
|
122
|
+
.description('provider=local: model id (A1111) or workflow/schema name (ComfyUI). Empty means the server default.')
|
|
123
|
+
.default(''),
|
|
124
|
+
localSteps: z
|
|
125
|
+
.number()
|
|
126
|
+
.description('provider=local: sampling steps.')
|
|
127
|
+
.default(20),
|
|
128
|
+
localCfg: z
|
|
129
|
+
.number()
|
|
130
|
+
.description('provider=local: CFG scale.')
|
|
131
|
+
.default(7),
|
|
132
|
+
seedreamKeyEnv: z
|
|
133
|
+
.string()
|
|
134
|
+
.description('provider=seedream: credential reference / env var holding the API key.')
|
|
135
|
+
.default('SEEDREAM_API_KEY'),
|
|
136
|
+
seedreamModel: z
|
|
137
|
+
.string()
|
|
138
|
+
.description('provider=seedream: model id, e.g. seedream-4.0.')
|
|
139
|
+
.default('seedream-4.0'),
|
|
140
|
+
geminiKeyEnv: z
|
|
141
|
+
.string()
|
|
142
|
+
.description('provider=gemini: credential reference / env var holding the Google API key.')
|
|
143
|
+
.default('GEMINI_API_KEY'),
|
|
144
|
+
geminiModel: z
|
|
145
|
+
.string()
|
|
146
|
+
.description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
|
|
147
|
+
.default('gemini-2.0-flash-exp-image-generation'),
|
|
111
148
|
outputDir: z
|
|
112
149
|
.string()
|
|
113
150
|
.description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
|
|
114
151
|
.default('generated/images'),
|
|
152
|
+
historyLimit: z
|
|
153
|
+
.number()
|
|
154
|
+
.description('How many recent generations to keep in the in-memory history list.')
|
|
155
|
+
.default(50),
|
|
156
|
+
enhancePrompt: z
|
|
157
|
+
.boolean()
|
|
158
|
+
.description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
|
|
159
|
+
.default(false),
|
|
160
|
+
enhanceModel: z
|
|
161
|
+
.string()
|
|
162
|
+
.description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
|
|
163
|
+
.default(''),
|
|
164
|
+
enhanceBelowChars: z
|
|
165
|
+
.number()
|
|
166
|
+
.description('Only enhance prompts shorter than this many characters.')
|
|
167
|
+
.default(200),
|
|
168
|
+
stylePreset: z
|
|
169
|
+
.string()
|
|
170
|
+
.description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
|
|
171
|
+
.default(''),
|
|
172
|
+
cacheBySeed: z
|
|
173
|
+
.boolean()
|
|
174
|
+
.description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
175
|
+
.default(false),
|
|
115
176
|
})
|
|
116
177
|
|
|
117
178
|
/** Keep a file stem safe for the filesystem. */
|
|
179
|
+
/** Прочитать исходное изображение для правки: путь к файлу или attachment id. */
|
|
180
|
+
export async function resolveSource(ctx, exec, ref) {
|
|
181
|
+
if (!ref) return undefined
|
|
182
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
183
|
+
if (ref.startsWith('sha256:')) {
|
|
184
|
+
const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
|
|
185
|
+
return { bytes: Buffer.from(stored.data), mediaType: stored.ref?.mediaType || 'image/png' }
|
|
186
|
+
}
|
|
187
|
+
const p = path.resolve(sessionCwd || process.cwd(), ref)
|
|
188
|
+
const bytes = await readFile(p)
|
|
189
|
+
return { bytes, mediaType: 'image/png' }
|
|
190
|
+
}
|
|
191
|
+
|
|
118
192
|
export function slugify(input) {
|
|
119
193
|
const stem = String(input ?? '')
|
|
120
194
|
.toLowerCase()
|
|
@@ -165,6 +239,97 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
165
239
|
}
|
|
166
240
|
}
|
|
167
241
|
|
|
242
|
+
/** Каталог истории: общий для всех устройств, переживает рестарт. */
|
|
243
|
+
export function historyFile() {
|
|
244
|
+
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Прочитать историю из файла (пусто, если файла нет). */
|
|
248
|
+
/** Найти запись истории по seed+prompt, если файл ещё существует. */
|
|
249
|
+
export async function findCached(entries, seed, prompt) {
|
|
250
|
+
if (seed === undefined) return undefined
|
|
251
|
+
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Вернуть запись из кэша, если файл существует; иначе undefined. */
|
|
255
|
+
export async function cachedResult(entry) {
|
|
256
|
+
if (!entry || !entry.path) return undefined
|
|
257
|
+
try { require('node:fs').accessSync(entry.path) } catch (e) { return undefined }
|
|
258
|
+
return {
|
|
259
|
+
path: entry.path,
|
|
260
|
+
url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
|
|
261
|
+
width: 0,
|
|
262
|
+
height: 0,
|
|
263
|
+
seed: entry.seed,
|
|
264
|
+
prompt: entry.prompt,
|
|
265
|
+
format: 'png',
|
|
266
|
+
cached: true,
|
|
267
|
+
attachment: entry.attachmentId ? { attachmentId: entry.attachmentId, mediaType: 'image/png', bytes: 0, width: 0, height: 0, name: '' } : undefined,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function readHistory() {
|
|
272
|
+
try {
|
|
273
|
+
const raw = await readFile(historyFile(), 'utf-8')
|
|
274
|
+
const parsed = JSON.parse(raw)
|
|
275
|
+
return Array.isArray(parsed) ? parsed : []
|
|
276
|
+
} catch (e) {
|
|
277
|
+
return []
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Записать историю в файл (перезапись целиком). */
|
|
282
|
+
export async function writeHistory(entries) {
|
|
283
|
+
try {
|
|
284
|
+
await mkdir(path.dirname(historyFile()), { recursive: true })
|
|
285
|
+
await writeFile(historyFile(), JSON.stringify(entries, null, 2))
|
|
286
|
+
} catch (e) { /* история не критична */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
|
|
290
|
+
export function filterHistory(entries, exists) {
|
|
291
|
+
return entries.filter((e) => exists(e.path)).slice(0, 50)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Собрать текст из итератора llm.stream (text-delta / block-end). */
|
|
295
|
+
export async function collectText(iterable) {
|
|
296
|
+
let out = ''
|
|
297
|
+
let sawDelta = false
|
|
298
|
+
for await (const chunk of iterable) {
|
|
299
|
+
if (chunk && chunk.type === 'text-delta' && typeof chunk.text === 'string') {
|
|
300
|
+
out += chunk.text
|
|
301
|
+
sawDelta = true
|
|
302
|
+
} else if (
|
|
303
|
+
!sawDelta && chunk && chunk.type === 'block-end'
|
|
304
|
+
&& chunk.block && chunk.block.type === 'text' && typeof chunk.block.text === 'string'
|
|
305
|
+
) {
|
|
306
|
+
out += chunk.block.text
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return out.trim()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
|
|
313
|
+
export async function enhancePrompt(ctx, cfg, prompt, signal) {
|
|
314
|
+
if (!cfg.enhancePrompt) return { prompt, enhanced: false }
|
|
315
|
+
if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
|
|
316
|
+
try {
|
|
317
|
+
const chunks = ctx.llm.stream({
|
|
318
|
+
...(signal ? { signal } : {}),
|
|
319
|
+
...(cfg.enhanceModel ? { model: cfg.enhanceModel } : {}),
|
|
320
|
+
messages: [
|
|
321
|
+
{ role: 'system', content: 'You expand a short image-generation prompt into a detailed one. Reply with only the expanded prompt, no commentary.' },
|
|
322
|
+
{ role: 'user', content: prompt },
|
|
323
|
+
],
|
|
324
|
+
})
|
|
325
|
+
const text = await collectText(chunks)
|
|
326
|
+
if (!text) return { prompt, enhanced: false }
|
|
327
|
+
return { prompt: text, enhanced: true }
|
|
328
|
+
} catch (e) {
|
|
329
|
+
return { prompt, enhanced: false }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
168
333
|
export function apply(ctx, config) {
|
|
169
334
|
|
|
170
335
|
// The Web card edits this namespace; without registering it the card binds to
|
|
@@ -242,6 +407,27 @@ export function apply(ctx, config) {
|
|
|
242
407
|
}), `dsh-image-gen: image route ${path}`)
|
|
243
408
|
}
|
|
244
409
|
|
|
410
|
+
// История генераций: in-memory список, отфильтрованный по существованию файлов.
|
|
411
|
+
ctx.effect(() => ctx.webServer.register({
|
|
412
|
+
kind: 'exact',
|
|
413
|
+
path: '/dsh-image-gen/history',
|
|
414
|
+
handler: async (req, res) => {
|
|
415
|
+
if (req.method !== 'GET') {
|
|
416
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
417
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const exists = (p) => { try { return require('node:fs').existsSync(p) } catch { return false } }
|
|
421
|
+
const entries = await readHistory()
|
|
422
|
+
const withThumbs = filterHistory(entries, exists).map((e) => ({
|
|
423
|
+
...e,
|
|
424
|
+
thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
|
|
425
|
+
}))
|
|
426
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
427
|
+
res.end(JSON.stringify(withThumbs))
|
|
428
|
+
},
|
|
429
|
+
}), 'dsh-image-gen: history route')
|
|
430
|
+
|
|
245
431
|
ctx.tools.register(
|
|
246
432
|
defineTool({
|
|
247
433
|
name: 'generate_image',
|
|
@@ -278,6 +464,11 @@ export function apply(ctx, config) {
|
|
|
278
464
|
type: 'integer',
|
|
279
465
|
description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
|
|
280
466
|
},
|
|
467
|
+
prompts: {
|
|
468
|
+
type: 'array',
|
|
469
|
+
items: { type: 'string' },
|
|
470
|
+
description: 'Optional list of prompts; one image is generated per prompt. When set, overrides count and the single prompt.',
|
|
471
|
+
},
|
|
281
472
|
negative_prompt: {
|
|
282
473
|
type: 'string',
|
|
283
474
|
description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
|
|
@@ -286,6 +477,23 @@ export function apply(ctx, config) {
|
|
|
286
477
|
type: 'number',
|
|
287
478
|
description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
|
|
288
479
|
},
|
|
480
|
+
quality: {
|
|
481
|
+
type: 'string',
|
|
482
|
+
enum: ['low', 'medium', 'high'],
|
|
483
|
+
description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
484
|
+
},
|
|
485
|
+
source_image: {
|
|
486
|
+
type: 'string',
|
|
487
|
+
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.',
|
|
488
|
+
},
|
|
489
|
+
mask: {
|
|
490
|
+
type: 'string',
|
|
491
|
+
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.',
|
|
492
|
+
},
|
|
493
|
+
strength: {
|
|
494
|
+
type: 'number',
|
|
495
|
+
description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
|
|
496
|
+
},
|
|
289
497
|
},
|
|
290
498
|
output: {
|
|
291
499
|
schema: {
|
|
@@ -361,21 +569,26 @@ export function apply(ctx, config) {
|
|
|
361
569
|
let subscriptionImages
|
|
362
570
|
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
|
|
363
571
|
|
|
572
|
+
const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
|
|
573
|
+
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
574
|
+
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
|
|
575
|
+
const styleSuffix = cfg.stylePreset ? `, ${cfg.stylePreset}` : ''
|
|
576
|
+
const effectivePrompt = enhanced.prompt + styleSuffix
|
|
364
577
|
const providers = makeProviders(
|
|
365
578
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
366
|
-
{ prompt:
|
|
579
|
+
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength, quality: args.quality },
|
|
367
580
|
)
|
|
368
581
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
369
582
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
370
583
|
// байтами, и пользователь получал битую карточку вместо внятного отказа.
|
|
371
584
|
const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
|
|
372
|
-
const one = async (jobSeed, providerKey) => {
|
|
373
|
-
const gen = await providers[providerKey](jobSeed)
|
|
585
|
+
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
586
|
+
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
374
587
|
guard(gen)
|
|
375
588
|
const bytes = gen.bytes
|
|
376
589
|
const mediaType = gen.mediaType
|
|
377
590
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
378
|
-
const stem = `${slugify(args.output_name ||
|
|
591
|
+
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
379
592
|
const name = `${stem}.${extension}`
|
|
380
593
|
|
|
381
594
|
const attachment = await ctx.attachments.saveImage({
|
|
@@ -401,7 +614,7 @@ export function apply(ctx, config) {
|
|
|
401
614
|
await writeFile(
|
|
402
615
|
path.join(outDir, `${stem}.json`),
|
|
403
616
|
JSON.stringify(buildSidecar({
|
|
404
|
-
prompt:
|
|
617
|
+
prompt: promptArg,
|
|
405
618
|
size,
|
|
406
619
|
format,
|
|
407
620
|
seed: gen.seed,
|
|
@@ -412,16 +625,34 @@ export function apply(ctx, config) {
|
|
|
412
625
|
mediaType,
|
|
413
626
|
attachmentId: attachment.attachmentId,
|
|
414
627
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
628
|
+
cost: gen.cost,
|
|
415
629
|
}), null, 2),
|
|
416
630
|
)
|
|
417
631
|
|
|
632
|
+
const entry = {
|
|
633
|
+
path: filePath,
|
|
634
|
+
prompt: promptArg,
|
|
635
|
+
provider,
|
|
636
|
+
size,
|
|
637
|
+
format,
|
|
638
|
+
seed: gen.seed,
|
|
639
|
+
createdAt: new Date().toISOString(),
|
|
640
|
+
attachmentId: attachment.attachmentId,
|
|
641
|
+
}
|
|
642
|
+
const current = await readHistory()
|
|
643
|
+
current.unshift(entry)
|
|
644
|
+
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
645
|
+
await writeHistory(current)
|
|
646
|
+
|
|
418
647
|
return {
|
|
419
648
|
path: filePath,
|
|
420
649
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
421
650
|
width: gen.width || attachment.width,
|
|
422
651
|
height: gen.height || attachment.height,
|
|
423
652
|
seed: gen.seed,
|
|
424
|
-
prompt:
|
|
653
|
+
prompt: promptArg,
|
|
654
|
+
originalPrompt: enhanced.enhanced ? args.prompt : undefined,
|
|
655
|
+
cost: gen.cost,
|
|
425
656
|
format: mediaType.replace('image/', ''),
|
|
426
657
|
attachment: {
|
|
427
658
|
attachmentId: attachment.attachmentId,
|
|
@@ -434,15 +665,25 @@ export function apply(ctx, config) {
|
|
|
434
665
|
}
|
|
435
666
|
}
|
|
436
667
|
|
|
437
|
-
const count = normalizeCount(args.count)
|
|
438
668
|
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
439
669
|
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
440
670
|
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
441
671
|
const order = fallbackOrder(provider)
|
|
442
|
-
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
|
|
672
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
|
|
443
673
|
const images = []
|
|
444
|
-
|
|
445
|
-
|
|
674
|
+
const historyEntries = cfg.cacheBySeed ? await readHistory() : []
|
|
675
|
+
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
676
|
+
if (batchPrompts) {
|
|
677
|
+
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
678
|
+
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, batchPrompts[i])) : undefined
|
|
679
|
+
images.push(cached || await tryGenerate(generators, order, seedBase + i, batchPrompts[i]))
|
|
680
|
+
}
|
|
681
|
+
} else {
|
|
682
|
+
const count = normalizeCount(args.count)
|
|
683
|
+
for (let i = 0; i < count; i += 1) {
|
|
684
|
+
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : undefined
|
|
685
|
+
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
686
|
+
}
|
|
446
687
|
}
|
|
447
688
|
const first = images[0]
|
|
448
689
|
return { ...first, images }
|
package/lib/providers.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
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', 'seedream', 'gemini']
|
|
11
11
|
|
|
12
12
|
/** Привести count из аргумента инструмента к диапазону 1..4. */
|
|
13
13
|
/** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
|
|
@@ -21,11 +21,11 @@ export function fallbackOrder(primary) {
|
|
|
21
21
|
* @param generators - массив функций (key, seed) => Promise<generated>.
|
|
22
22
|
* @param order - порядок ключей, длина = generators.length.
|
|
23
23
|
*/
|
|
24
|
-
export async function tryGenerate(generators, order, seed) {
|
|
24
|
+
export async function tryGenerate(generators, order, seed, promptArg) {
|
|
25
25
|
const refusals = []
|
|
26
26
|
for (const key of order) {
|
|
27
27
|
try {
|
|
28
|
-
const produced = await generators[key](seed)
|
|
28
|
+
const produced = await generators[key](seed, promptArg)
|
|
29
29
|
return produced
|
|
30
30
|
} catch (e) {
|
|
31
31
|
refusals.push(`${key}: ${e.message || String(e)}`)
|
|
@@ -99,9 +99,9 @@ export function normalizeMediaType(contentType, fallbackFormat) {
|
|
|
99
99
|
/** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
|
|
100
100
|
export function buildSidecar({
|
|
101
101
|
prompt, size, format, seed, provider, deliverAs,
|
|
102
|
-
width, height, mediaType, attachmentId, url, createdAt = new Date().toISOString(),
|
|
102
|
+
width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
|
|
103
103
|
}) {
|
|
104
|
-
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 }
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/** Submit a generation job to the FAL queue. */
|
|
@@ -157,13 +157,33 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
157
157
|
* {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
|
|
158
158
|
* означают «спроси у службы вложений»: она всё равно измеряет байты сама.
|
|
159
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
|
+
|
|
160
180
|
export function makeProviders(deps, job) {
|
|
161
181
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
162
|
-
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale } = job
|
|
182
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality } = job
|
|
163
183
|
|
|
164
|
-
async function fal(seedArg = seed) {
|
|
184
|
+
async function fal(seedArg = seed, promptArg = prompt) {
|
|
165
185
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
166
|
-
const body = { prompt, image_size: size, num_images: 1 }
|
|
186
|
+
const body = { prompt: promptArg, image_size: size, num_images: 1 }
|
|
167
187
|
if (seedArg !== undefined) body.seed = seedArg
|
|
168
188
|
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
169
189
|
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
@@ -193,11 +213,12 @@ export function makeProviders(deps, job) {
|
|
|
193
213
|
height: image.height ?? 0,
|
|
194
214
|
seed: result.seed ?? seedArg ?? 0,
|
|
195
215
|
sourceUrl: image.url,
|
|
216
|
+
cost: result.cost,
|
|
196
217
|
}
|
|
197
218
|
}
|
|
198
219
|
|
|
199
220
|
// Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
|
|
200
|
-
async function custom(seedArg = seed) {
|
|
221
|
+
async function custom(seedArg = seed, promptArg = prompt) {
|
|
201
222
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
202
223
|
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
203
224
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
@@ -210,17 +231,20 @@ export function makeProviders(deps, job) {
|
|
|
210
231
|
|
|
211
232
|
// response_format не отправляем: новые модели OpenAI его отвергают, а ответ
|
|
212
233
|
// всё равно приходит либо base64, либо ссылкой — принимаем оба.
|
|
213
|
-
const
|
|
234
|
+
const endpoint = source ? `${base}/images/edits` : `${base}/images/generations`
|
|
235
|
+
const res = await fetchImpl(endpoint, {
|
|
214
236
|
method: 'POST',
|
|
215
237
|
headers,
|
|
216
|
-
body:
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
body: source
|
|
239
|
+
? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || SIZE_PIXELS[size] || size, strength })
|
|
240
|
+
: JSON.stringify({
|
|
241
|
+
model: cfg.customModel,
|
|
242
|
+
prompt: promptArg,
|
|
243
|
+
n: 1,
|
|
244
|
+
size: cfg.customSize || SIZE_PIXELS[size] || size,
|
|
245
|
+
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
246
|
+
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
247
|
+
}),
|
|
224
248
|
signal,
|
|
225
249
|
})
|
|
226
250
|
const data = await res.json().catch(() => ({}))
|
|
@@ -267,7 +291,10 @@ export function makeProviders(deps, job) {
|
|
|
267
291
|
// только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
|
|
268
292
|
// отдаёт готовую картинку, а не ключ доступа.
|
|
269
293
|
function subscription(provider) {
|
|
270
|
-
return async function generate(seedArg = seed) {
|
|
294
|
+
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
295
|
+
if (source) {
|
|
296
|
+
return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
|
|
297
|
+
}
|
|
271
298
|
const images = deps.subscriptionImages
|
|
272
299
|
if (!images || typeof images.generate !== 'function') {
|
|
273
300
|
return {
|
|
@@ -280,7 +307,7 @@ export function makeProviders(deps, job) {
|
|
|
280
307
|
try {
|
|
281
308
|
produced = await images.generate({
|
|
282
309
|
provider,
|
|
283
|
-
prompt,
|
|
310
|
+
prompt: promptArg,
|
|
284
311
|
size: SUBSCRIPTION_SIZES[size] || '1024x1024',
|
|
285
312
|
quality: cfg.subscriptionQuality || undefined,
|
|
286
313
|
signal,
|
|
@@ -305,5 +332,158 @@ export function makeProviders(deps, job) {
|
|
|
305
332
|
}
|
|
306
333
|
}
|
|
307
334
|
|
|
308
|
-
|
|
335
|
+
// Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
|
|
336
|
+
async function local(seedArg = seed, promptArg = prompt) {
|
|
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: promptArg,
|
|
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: promptArg, 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
|
+
// Seedream (ByteDance): OpenAI-совместимый API картинок.
|
|
433
|
+
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
434
|
+
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
435
|
+
const base = 'https://api.bytedanceapi.com/v1'
|
|
436
|
+
const res = await fetchImpl(`${base}/images/generations`, {
|
|
437
|
+
method: 'POST',
|
|
438
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
439
|
+
body: JSON.stringify({
|
|
440
|
+
model: cfg.seedreamModel || 'seedream-4.0',
|
|
441
|
+
prompt: promptArg,
|
|
442
|
+
n: 1,
|
|
443
|
+
size: SIZE_PIXELS[size] || size,
|
|
444
|
+
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
445
|
+
...(quality !== undefined ? { quality } : {}),
|
|
446
|
+
}),
|
|
447
|
+
signal,
|
|
448
|
+
})
|
|
449
|
+
const data = await res.json().catch(() => ({}))
|
|
450
|
+
if (!res.ok) {
|
|
451
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
452
|
+
throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
|
|
453
|
+
}
|
|
454
|
+
const item = data?.data?.[0]
|
|
455
|
+
if (!item) throw new Error('Seedream returned no images')
|
|
456
|
+
if (item.b64_json) {
|
|
457
|
+
return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
458
|
+
}
|
|
459
|
+
if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
|
|
460
|
+
const dl = await fetchImpl(item.url, { signal })
|
|
461
|
+
if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
|
|
462
|
+
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Gemini (Google): images.generate через GenAI API.
|
|
466
|
+
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
467
|
+
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
468
|
+
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
469
|
+
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
470
|
+
method: 'POST',
|
|
471
|
+
headers: { 'Content-Type': 'application/json' },
|
|
472
|
+
body: JSON.stringify({
|
|
473
|
+
contents: [{ parts: [{ text: promptArg }] }],
|
|
474
|
+
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}) },
|
|
475
|
+
}),
|
|
476
|
+
signal,
|
|
477
|
+
})
|
|
478
|
+
const data = await res.json().catch(() => ({}))
|
|
479
|
+
if (!res.ok) {
|
|
480
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
481
|
+
throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
|
|
482
|
+
}
|
|
483
|
+
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
484
|
+
if (!part) throw new Error('Gemini returned no image')
|
|
485
|
+
return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini }
|
|
309
489
|
}
|
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.8",
|
|
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",
|