@goodandready/dsh-image-gen 0.8.5 → 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 +93 -8
- package/lib/index.js +166 -9
- package/lib/providers.js +164 -12
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -99,7 +99,7 @@ window.__ModuleLoader__.load({
|
|
|
99
99
|
function PluginSettingsCard(props) {
|
|
100
100
|
// Карточка живёт в собственном разделе настроек и на странице одна, так
|
|
101
101
|
// что открыта сразу: свёрнутая выглядела бы как пустая страница.
|
|
102
|
-
const [open, setOpen] = react.useState(
|
|
102
|
+
const [open, setOpen] = react.useState(false)
|
|
103
103
|
const { state } = props
|
|
104
104
|
// Раньше здесь было `return null`, и раздел оказывался пустым, если хост
|
|
105
105
|
// ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
|
|
@@ -407,17 +407,27 @@ window.__ModuleLoader__.load({
|
|
|
407
407
|
|
|
408
408
|
// ------------------------------------------------------------- fields
|
|
409
409
|
const IMAGE_SIZES = ['square_hd', 'square', 'portrait_4_3', 'portrait_16_9', 'landscape_4_3', 'landscape_16_9']
|
|
410
|
+
// Перевод именованного размера в пиксели для подсказки в карточке
|
|
411
|
+
// (значения совпадают с SIZE_PIXELS в хостовой половине).
|
|
412
|
+
const SIZE_PIXELS = {
|
|
413
|
+
square_hd: '1024x1024',
|
|
414
|
+
square: '512x512',
|
|
415
|
+
portrait_4_3: '768x1024',
|
|
416
|
+
portrait_16_9: '576x1024',
|
|
417
|
+
landscape_4_3: '1024x768',
|
|
418
|
+
landscape_16_9: '1024x576',
|
|
419
|
+
}
|
|
410
420
|
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
411
421
|
const DELIVERY_MODES = ['link', 'image']
|
|
412
|
-
const PROVIDERS = ['fal', 'custom', 'codex', 'grok']
|
|
422
|
+
const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local']
|
|
413
423
|
|
|
414
424
|
/** Field specs in display order. */
|
|
415
425
|
const FIELDS = [
|
|
416
|
-
{ 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: 'адрес сервера' } },
|
|
417
427
|
{ field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
|
|
418
428
|
{ field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
|
|
419
429
|
{ field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
|
|
420
|
-
{ field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint' },
|
|
430
|
+
{ field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS },
|
|
421
431
|
{ field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
|
|
422
432
|
{ field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs' },
|
|
423
433
|
{ field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs' },
|
|
@@ -446,7 +456,10 @@ window.__ModuleLoader__.load({
|
|
|
446
456
|
onReset,
|
|
447
457
|
}
|
|
448
458
|
if (entry.kind === 'select') {
|
|
449
|
-
|
|
459
|
+
const px = entry.sizePixels && entry.sizePixels[state.text] ? ` — ${entry.sizePixels[state.text]}` : ''
|
|
460
|
+
const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ` (нужно: ${entry.providerNeeds[state.text]})` : ''
|
|
461
|
+
const hint = base.hint + px + need
|
|
462
|
+
return react_jsx_runtime.jsx(SelectField, { ...base, hint, options: entry.options, inheritLabel: t('f.inherit') })
|
|
450
463
|
}
|
|
451
464
|
return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
|
|
452
465
|
}
|
|
@@ -511,7 +524,7 @@ window.__ModuleLoader__.load({
|
|
|
511
524
|
/** Settings namespace the card edits (the Host plugin registers it). */
|
|
512
525
|
const SETTINGS_NS = 'dsh-image-gen'
|
|
513
526
|
/** Required client services. */
|
|
514
|
-
const inject = ['slots', 'settingsScope', 'locale']
|
|
527
|
+
const inject = ['slots', 'settingsScope', 'locale', 'sessions']
|
|
515
528
|
|
|
516
529
|
const en = {
|
|
517
530
|
'settings.navLabel': 'Image generation',
|
|
@@ -636,7 +649,7 @@ window.__ModuleLoader__.load({
|
|
|
636
649
|
// Карточки инструментов не рисуют image-блоки (это умеют только сообщения
|
|
637
650
|
// ассистента), поэтому сгенерированная картинка показывается здесь —
|
|
638
651
|
// keyed-запись слота tool.call.toolview по имени инструмента.
|
|
639
|
-
const CARD_CSS = '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}'
|
|
652
|
+
const CARD_CSS = '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}' + '.fal-repeat{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border-radius:8px;padding:5px 14px;font-size:13px}' + '.fal-repeat:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}' + '.fal-repeat:disabled{opacity:.5;cursor:default}'
|
|
640
653
|
+ '.fal-card .fal-head{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}'
|
|
641
654
|
+ '.fal-card img{max-width:100%;border-radius:8px;display:block}'
|
|
642
655
|
+ '.fal-card .fal-prompt{font-size:12px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}'
|
|
@@ -697,8 +710,42 @@ window.__ModuleLoader__.load({
|
|
|
697
710
|
const head = react.createElement('div', { className: 'fal-head' },
|
|
698
711
|
running ? 'Генерирую изображение…' : failed ? 'Генерация не удалась' : 'Изображение')
|
|
699
712
|
|
|
713
|
+
// «Повторить генерацию»: отправляет в чат новое сообщение с теми же
|
|
714
|
+
// промптом и параметрами, извлечёнными из исходного вызова инструмента.
|
|
715
|
+
// Дальше модель сама решает, как сгенерировать, — это просто повторный
|
|
716
|
+
// запрос, а не вызов инструмента из карточки.
|
|
717
|
+
let args = {}
|
|
718
|
+
try {
|
|
719
|
+
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
720
|
+
if (raw) args = JSON.parse(raw)
|
|
721
|
+
} catch (e) { /* ignore */ }
|
|
722
|
+
const repeatText = (() => {
|
|
723
|
+
const parts = [String(args.prompt || '').trim()]
|
|
724
|
+
const size = args.image_size || args.size
|
|
725
|
+
const seed = args.seed
|
|
726
|
+
if (size) parts.push(`Размер: ${size}`)
|
|
727
|
+
if (seed !== undefined) parts.push(`Сид: ${seed}`)
|
|
728
|
+
return parts.filter(Boolean).join(', ')
|
|
729
|
+
})()
|
|
730
|
+
const repeat = async () => {
|
|
731
|
+
try {
|
|
732
|
+
const sessions = props.sessions
|
|
733
|
+
const current = sessions && sessions.list && sessions.list.current
|
|
734
|
+
const binding = current && sessions.binding(current)
|
|
735
|
+
const session = binding && binding.session
|
|
736
|
+
if (!session || !session.prompt) {
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
const text = repeatText || String(args.prompt || '')
|
|
740
|
+
await session.prompt([{ type: 'text', text }], 'queue')
|
|
741
|
+
} catch (e) { /* контур сам покажет ошибку отправки */ }
|
|
742
|
+
}
|
|
743
|
+
|
|
700
744
|
const body = []
|
|
701
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
|
+
}
|
|
702
749
|
if (failed) {
|
|
703
750
|
body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || 'неизвестная ошибка'))
|
|
704
751
|
} else if (!parsed.attachment && parsed.url) {
|
|
@@ -729,6 +776,44 @@ window.__ModuleLoader__.load({
|
|
|
729
776
|
body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
|
|
730
777
|
}
|
|
731
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
|
+
|
|
788
|
+
// Диагностика: метаданные генерации (prompt/seed/size/format), чтобы было
|
|
789
|
+
// понятно, по каким параметрам получилась именно эта картинка.
|
|
790
|
+
const metaParts = []
|
|
791
|
+
if (args.image_size || args.size) metaParts.push(`size ${args.image_size || args.size}`)
|
|
792
|
+
if (args.output_format || args.format) metaParts.push(`format ${args.output_format || args.format}`)
|
|
793
|
+
if (args.seed !== undefined) metaParts.push(`seed ${args.seed}`)
|
|
794
|
+
const meta = metaParts.join(' · ')
|
|
795
|
+
|
|
796
|
+
// Кнопка повтора — только когда генерация завершена (не в процессе и не ошибка).
|
|
797
|
+
if (!running && !failed && meta) {
|
|
798
|
+
body.push(react.createElement('div', { key: 'meta', className: 'fal-meta' }, meta))
|
|
799
|
+
}
|
|
800
|
+
if (!running && !failed && repeatText) {
|
|
801
|
+
body.push(react.createElement('button', {
|
|
802
|
+
key: 'repeat',
|
|
803
|
+
type: 'button',
|
|
804
|
+
className: 'fal-repeat',
|
|
805
|
+
onClick: repeat,
|
|
806
|
+
}, 'Повторить генерацию'))
|
|
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
|
+
}
|
|
816
|
+
|
|
732
817
|
return react.createElement('div', { className: 'fal-card' }, head, body)
|
|
733
818
|
}
|
|
734
819
|
|
|
@@ -810,7 +895,7 @@ window.__ModuleLoader__.load({
|
|
|
810
895
|
}
|
|
811
896
|
ctx.slots.inject('tool.call.toolview', () =>
|
|
812
897
|
ctx.slots.register(
|
|
813
|
-
{ name: 'tool.call.toolview', key: 'generate_image', locale: NS },
|
|
898
|
+
{ name: 'tool.call.toolview', key: 'generate_image', locale: NS, inject: () => ({ sessions: ctx.sessions }) },
|
|
814
899
|
FalImageCard,
|
|
815
900
|
),
|
|
816
901
|
)
|
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,
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
buildSidecar,
|
|
25
25
|
makeProviders,
|
|
26
26
|
normalizeCount,
|
|
27
|
+
tryGenerate,
|
|
28
|
+
fallbackOrder,
|
|
27
29
|
normalizeMediaType,
|
|
28
30
|
} from './providers.js'
|
|
29
31
|
|
|
@@ -38,7 +40,7 @@ const NS = 'dsh-image-gen'
|
|
|
38
40
|
// настройки лежат под старым именем. Оно читается как запасное и переносится
|
|
39
41
|
// под новое имя один раз — молча, при первом запуске после обновления.
|
|
40
42
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
41
|
-
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings']
|
|
43
|
+
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm']
|
|
42
44
|
|
|
43
45
|
export const Config = z.object({
|
|
44
46
|
provider: z
|
|
@@ -106,13 +108,62 @@ export const Config = z.object({
|
|
|
106
108
|
.description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
|
|
107
109
|
+ 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
|
|
108
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),
|
|
109
131
|
outputDir: z
|
|
110
132
|
.string()
|
|
111
133
|
.description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
|
|
112
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),
|
|
113
151
|
})
|
|
114
152
|
|
|
115
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
|
+
|
|
116
167
|
export function slugify(input) {
|
|
117
168
|
const stem = String(input ?? '')
|
|
118
169
|
.toLowerCase()
|
|
@@ -163,6 +214,53 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
163
214
|
}
|
|
164
215
|
}
|
|
165
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
|
+
|
|
166
264
|
export function apply(ctx, config) {
|
|
167
265
|
|
|
168
266
|
// The Web card edits this namespace; without registering it the card binds to
|
|
@@ -240,6 +338,22 @@ export function apply(ctx, config) {
|
|
|
240
338
|
}), `dsh-image-gen: image route ${path}`)
|
|
241
339
|
}
|
|
242
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
|
+
|
|
243
357
|
ctx.tools.register(
|
|
244
358
|
defineTool({
|
|
245
359
|
name: 'generate_image',
|
|
@@ -276,6 +390,26 @@ export function apply(ctx, config) {
|
|
|
276
390
|
type: 'integer',
|
|
277
391
|
description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
|
|
278
392
|
},
|
|
393
|
+
negative_prompt: {
|
|
394
|
+
type: 'string',
|
|
395
|
+
description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
|
|
396
|
+
},
|
|
397
|
+
guidance_scale: {
|
|
398
|
+
type: 'number',
|
|
399
|
+
description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
|
|
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
|
+
},
|
|
279
413
|
},
|
|
280
414
|
output: {
|
|
281
415
|
schema: {
|
|
@@ -351,21 +485,25 @@ export function apply(ctx, config) {
|
|
|
351
485
|
let subscriptionImages
|
|
352
486
|
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
|
|
353
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
|
|
354
492
|
const providers = makeProviders(
|
|
355
493
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
356
|
-
{ 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 },
|
|
357
495
|
)
|
|
358
496
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
359
497
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
360
498
|
// байтами, и пользователь получал битую карточку вместо внятного отказа.
|
|
361
499
|
const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
|
|
362
|
-
const one = async (jobSeed) => {
|
|
363
|
-
const gen = await providers[
|
|
500
|
+
const one = async (jobSeed, providerKey) => {
|
|
501
|
+
const gen = await providers[providerKey](jobSeed)
|
|
364
502
|
guard(gen)
|
|
365
503
|
const bytes = gen.bytes
|
|
366
504
|
const mediaType = gen.mediaType
|
|
367
505
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
368
|
-
const stem = `${slugify(args.output_name ||
|
|
506
|
+
const stem = `${slugify(args.output_name || effectivePrompt)}-${Date.now().toString(36)}-${jobSeed}`
|
|
369
507
|
const name = `${stem}.${extension}`
|
|
370
508
|
|
|
371
509
|
const attachment = await ctx.attachments.saveImage({
|
|
@@ -391,7 +529,7 @@ export function apply(ctx, config) {
|
|
|
391
529
|
await writeFile(
|
|
392
530
|
path.join(outDir, `${stem}.json`),
|
|
393
531
|
JSON.stringify(buildSidecar({
|
|
394
|
-
prompt:
|
|
532
|
+
prompt: effectivePrompt,
|
|
395
533
|
size,
|
|
396
534
|
format,
|
|
397
535
|
seed: gen.seed,
|
|
@@ -402,16 +540,31 @@ export function apply(ctx, config) {
|
|
|
402
540
|
mediaType,
|
|
403
541
|
attachmentId: attachment.attachmentId,
|
|
404
542
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
543
|
+
cost: gen.cost,
|
|
405
544
|
}), null, 2),
|
|
406
545
|
)
|
|
407
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
|
+
|
|
408
559
|
return {
|
|
409
560
|
path: filePath,
|
|
410
561
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
411
562
|
width: gen.width || attachment.width,
|
|
412
563
|
height: gen.height || attachment.height,
|
|
413
564
|
seed: gen.seed,
|
|
414
|
-
prompt:
|
|
565
|
+
prompt: effectivePrompt,
|
|
566
|
+
originalPrompt: enhanced.enhanced ? args.prompt : undefined,
|
|
567
|
+
cost: gen.cost,
|
|
415
568
|
format: mediaType.replace('image/', ''),
|
|
416
569
|
attachment: {
|
|
417
570
|
attachmentId: attachment.attachmentId,
|
|
@@ -426,9 +579,13 @@ export function apply(ctx, config) {
|
|
|
426
579
|
|
|
427
580
|
const count = normalizeCount(args.count)
|
|
428
581
|
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
582
|
+
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
583
|
+
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
584
|
+
const order = fallbackOrder(provider)
|
|
585
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
|
|
429
586
|
const images = []
|
|
430
587
|
for (let i = 0; i < count; i += 1) {
|
|
431
|
-
images.push(await
|
|
588
|
+
images.push(await tryGenerate(generators, order, seedBase + i))
|
|
432
589
|
}
|
|
433
590
|
const first = images[0]
|
|
434
591
|
return { ...first, images }
|
package/lib/providers.js
CHANGED
|
@@ -7,9 +7,33 @@
|
|
|
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
|
+
/** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
|
|
14
|
+
export function fallbackOrder(primary) {
|
|
15
|
+
return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Перебирает генераторов по порядку, возвращает первый успешный результат.
|
|
20
|
+
* При отказе всех бросает с перечислением причин.
|
|
21
|
+
* @param generators - массив функций (key, seed) => Promise<generated>.
|
|
22
|
+
* @param order - порядок ключей, длина = generators.length.
|
|
23
|
+
*/
|
|
24
|
+
export async function tryGenerate(generators, order, seed) {
|
|
25
|
+
const refusals = []
|
|
26
|
+
for (const key of order) {
|
|
27
|
+
try {
|
|
28
|
+
const produced = await generators[key](seed)
|
|
29
|
+
return produced
|
|
30
|
+
} catch (e) {
|
|
31
|
+
refusals.push(`${key}: ${e.message || String(e)}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Image generation failed on all providers. ${refusals.join('; ')}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
13
37
|
export function normalizeCount(count) {
|
|
14
38
|
const n = Math.floor(Number(count))
|
|
15
39
|
if (!Number.isFinite(n)) return 1
|
|
@@ -75,9 +99,9 @@ export function normalizeMediaType(contentType, fallbackFormat) {
|
|
|
75
99
|
/** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
|
|
76
100
|
export function buildSidecar({
|
|
77
101
|
prompt, size, format, seed, provider, deliverAs,
|
|
78
|
-
width, height, mediaType, attachmentId, url, createdAt = new Date().toISOString(),
|
|
102
|
+
width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
|
|
79
103
|
}) {
|
|
80
|
-
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 }
|
|
81
105
|
}
|
|
82
106
|
|
|
83
107
|
/** Submit a generation job to the FAL queue. */
|
|
@@ -133,14 +157,36 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
133
157
|
* {bytes, mediaType, width, height, seed, sourceUrl}. Нулевые width/height
|
|
134
158
|
* означают «спроси у службы вложений»: она всё равно измеряет байты сама.
|
|
135
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
|
+
|
|
136
180
|
export function makeProviders(deps, job) {
|
|
137
181
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
138
|
-
const { prompt, size, format, seed, signal } = job
|
|
182
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength } = job
|
|
139
183
|
|
|
140
184
|
async function fal(seedArg = seed) {
|
|
141
185
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
142
186
|
const body = { prompt, image_size: size, num_images: 1 }
|
|
143
187
|
if (seedArg !== undefined) body.seed = seedArg
|
|
188
|
+
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
189
|
+
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
144
190
|
if (format !== 'png') body.output_format = format
|
|
145
191
|
|
|
146
192
|
const submit = await submitJob(fetchImpl, cfg.baseURL, cfg.model, key, body, signal)
|
|
@@ -167,6 +213,7 @@ export function makeProviders(deps, job) {
|
|
|
167
213
|
height: image.height ?? 0,
|
|
168
214
|
seed: result.seed ?? seedArg ?? 0,
|
|
169
215
|
sourceUrl: image.url,
|
|
216
|
+
cost: result.cost,
|
|
170
217
|
}
|
|
171
218
|
}
|
|
172
219
|
|
|
@@ -184,15 +231,20 @@ export function makeProviders(deps, job) {
|
|
|
184
231
|
|
|
185
232
|
// response_format не отправляем: новые модели OpenAI его отвергают, а ответ
|
|
186
233
|
// всё равно приходит либо base64, либо ссылкой — принимаем оба.
|
|
187
|
-
const
|
|
234
|
+
const endpoint = source ? `${base}/images/edits` : `${base}/images/generations`
|
|
235
|
+
const res = await fetchImpl(endpoint, {
|
|
188
236
|
method: 'POST',
|
|
189
237
|
headers,
|
|
190
|
-
body:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
+
}),
|
|
196
248
|
signal,
|
|
197
249
|
})
|
|
198
250
|
const data = await res.json().catch(() => ({}))
|
|
@@ -240,6 +292,9 @@ export function makeProviders(deps, job) {
|
|
|
240
292
|
// отдаёт готовую картинку, а не ключ доступа.
|
|
241
293
|
function subscription(provider) {
|
|
242
294
|
return async function generate(seedArg = seed) {
|
|
295
|
+
if (source) {
|
|
296
|
+
return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
|
|
297
|
+
}
|
|
243
298
|
const images = deps.subscriptionImages
|
|
244
299
|
if (!images || typeof images.generate !== 'function') {
|
|
245
300
|
return {
|
|
@@ -277,5 +332,102 @@ export function makeProviders(deps, job) {
|
|
|
277
332
|
}
|
|
278
333
|
}
|
|
279
334
|
|
|
280
|
-
|
|
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 }
|
|
281
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",
|