@goodandready/dsh-image-gen 0.8.6 → 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 +24 -3
- package/lib/index.js +149 -6
- package/lib/providers.js +138 -14
- 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,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,
|
|
@@ -40,7 +40,7 @@ const NS = 'dsh-image-gen'
|
|
|
40
40
|
// настройки лежат под старым именем. Оно читается как запасное и переносится
|
|
41
41
|
// под новое имя один раз — молча, при первом запуске после обновления.
|
|
42
42
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
43
|
-
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
|
|
43
|
+
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
|
|
44
44
|
|
|
45
45
|
export const Config = z.object({
|
|
46
46
|
provider: z
|
|
@@ -108,13 +108,62 @@ export const Config = z.object({
|
|
|
108
108
|
.description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
|
|
109
109
|
+ 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
|
|
110
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),
|
|
111
131
|
outputDir: z
|
|
112
132
|
.string()
|
|
113
133
|
.description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
|
|
114
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),
|
|
115
151
|
})
|
|
116
152
|
|
|
117
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
|
+
|
|
118
167
|
export function slugify(input) {
|
|
119
168
|
const stem = String(input ?? '')
|
|
120
169
|
.toLowerCase()
|
|
@@ -165,6 +214,53 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
165
214
|
}
|
|
166
215
|
}
|
|
167
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
|
+
|
|
168
264
|
export function apply(ctx, config) {
|
|
169
265
|
|
|
170
266
|
// The Web card edits this namespace; without registering it the card binds to
|
|
@@ -242,6 +338,22 @@ export function apply(ctx, config) {
|
|
|
242
338
|
}), `dsh-image-gen: image route ${path}`)
|
|
243
339
|
}
|
|
244
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
|
+
|
|
245
357
|
ctx.tools.register(
|
|
246
358
|
defineTool({
|
|
247
359
|
name: 'generate_image',
|
|
@@ -286,6 +398,18 @@ export function apply(ctx, config) {
|
|
|
286
398
|
type: 'number',
|
|
287
399
|
description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
|
|
288
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
|
+
},
|
|
289
413
|
},
|
|
290
414
|
output: {
|
|
291
415
|
schema: {
|
|
@@ -361,9 +485,13 @@ export function apply(ctx, config) {
|
|
|
361
485
|
let subscriptionImages
|
|
362
486
|
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
|
|
363
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
|
|
364
492
|
const providers = makeProviders(
|
|
365
493
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
366
|
-
{ prompt:
|
|
494
|
+
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength },
|
|
367
495
|
)
|
|
368
496
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
369
497
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
@@ -375,7 +503,7 @@ export function apply(ctx, config) {
|
|
|
375
503
|
const bytes = gen.bytes
|
|
376
504
|
const mediaType = gen.mediaType
|
|
377
505
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
378
|
-
const stem = `${slugify(args.output_name ||
|
|
506
|
+
const stem = `${slugify(args.output_name || effectivePrompt)}-${Date.now().toString(36)}-${jobSeed}`
|
|
379
507
|
const name = `${stem}.${extension}`
|
|
380
508
|
|
|
381
509
|
const attachment = await ctx.attachments.saveImage({
|
|
@@ -401,7 +529,7 @@ export function apply(ctx, config) {
|
|
|
401
529
|
await writeFile(
|
|
402
530
|
path.join(outDir, `${stem}.json`),
|
|
403
531
|
JSON.stringify(buildSidecar({
|
|
404
|
-
prompt:
|
|
532
|
+
prompt: effectivePrompt,
|
|
405
533
|
size,
|
|
406
534
|
format,
|
|
407
535
|
seed: gen.seed,
|
|
@@ -412,16 +540,31 @@ export function apply(ctx, config) {
|
|
|
412
540
|
mediaType,
|
|
413
541
|
attachmentId: attachment.attachmentId,
|
|
414
542
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
543
|
+
cost: gen.cost,
|
|
415
544
|
}), null, 2),
|
|
416
545
|
)
|
|
417
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
|
+
|
|
418
559
|
return {
|
|
419
560
|
path: filePath,
|
|
420
561
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
421
562
|
width: gen.width || attachment.width,
|
|
422
563
|
height: gen.height || attachment.height,
|
|
423
564
|
seed: gen.seed,
|
|
424
|
-
prompt:
|
|
565
|
+
prompt: effectivePrompt,
|
|
566
|
+
originalPrompt: enhanced.enhanced ? args.prompt : undefined,
|
|
567
|
+
cost: gen.cost,
|
|
425
568
|
format: mediaType.replace('image/', ''),
|
|
426
569
|
attachment: {
|
|
427
570
|
attachmentId: attachment.attachmentId,
|
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']
|
|
11
11
|
|
|
12
12
|
/** Привести count из аргумента инструмента к диапазону 1..4. */
|
|
13
13
|
/** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
|
|
@@ -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,9 +157,29 @@ 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 } = job
|
|
163
183
|
|
|
164
184
|
async function fal(seedArg = seed) {
|
|
165
185
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
@@ -193,6 +213,7 @@ 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
|
|
|
@@ -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, 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
|
+
}),
|
|
224
248
|
signal,
|
|
225
249
|
})
|
|
226
250
|
const data = await res.json().catch(() => ({}))
|
|
@@ -268,6 +292,9 @@ export function makeProviders(deps, job) {
|
|
|
268
292
|
// отдаёт готовую картинку, а не ключ доступа.
|
|
269
293
|
function subscription(provider) {
|
|
270
294
|
return async function generate(seedArg = seed) {
|
|
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 {
|
|
@@ -305,5 +332,102 @@ export function makeProviders(deps, job) {
|
|
|
305
332
|
}
|
|
306
333
|
}
|
|
307
334
|
|
|
308
|
-
|
|
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 }
|
|
309
433
|
}
|
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.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",
|