@goodandready/dsh-image-gen 0.8.7 → 0.8.9
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 +148 -12
- package/lib/index.js +194 -20
- package/lib/providers.js +100 -17
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -18,6 +18,24 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
let react_jsx_runtime = require('react/jsx-runtime')
|
|
19
19
|
let runtime = require('@deepseek-ai/dsh-client-runtime/client')
|
|
20
20
|
|
|
21
|
+
// Ядровый шеврон раскрытия; в урезанной сборке — запасной SVG той же формы.
|
|
22
|
+
let ChevronIcon = null
|
|
23
|
+
try {
|
|
24
|
+
const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
|
|
25
|
+
ChevronIcon = primitives && primitives.IconChevronDownOutline14
|
|
26
|
+
} catch (noPrimitives) {
|
|
27
|
+
ChevronIcon = null
|
|
28
|
+
}
|
|
29
|
+
const ChevronIconNode = ChevronIcon || react.createElement('svg', {
|
|
30
|
+
width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true,
|
|
31
|
+
style: { display: 'block' },
|
|
32
|
+
}, react.createElement('path', {
|
|
33
|
+
d: 'M3 5.5 L7 9.5 L11 5.5',
|
|
34
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
|
|
35
|
+
strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
|
|
21
39
|
// ---------------------------------------------------------------- css
|
|
22
40
|
const css =
|
|
23
41
|
'.fal_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
|
|
@@ -55,7 +73,12 @@ window.__ModuleLoader__.load({
|
|
|
55
73
|
'.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
|
|
56
74
|
'.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
|
|
57
75
|
'.fal_invalid{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}' +
|
|
58
|
-
'.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
|
|
76
|
+
'.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}' +
|
|
77
|
+
'.fal_gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-top:12px}' +
|
|
78
|
+
'.fal_galleryItem{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden}' +
|
|
79
|
+
'.fal_galleryImg{width:100%;height:90px;object-fit:cover;display:block}' +
|
|
80
|
+
'.fal_galleryMeta{font-size:11px;color:var(--dsw-alias-label-secondary);padding:4px 6px}' +
|
|
81
|
+
'.fal_galleryEmpty{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:12px}'
|
|
59
82
|
const tagId = 'dsh-image-gen/settings-card.module.css'
|
|
60
83
|
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
|
|
61
84
|
const tag = document.createElement('style')
|
|
@@ -92,6 +115,11 @@ window.__ModuleLoader__.load({
|
|
|
92
115
|
save: 'fal_save',
|
|
93
116
|
select: 'fal_select',
|
|
94
117
|
sep: 'fal_sep',
|
|
118
|
+
gallery: 'fal_gallery',
|
|
119
|
+
galleryItem: 'fal_galleryItem',
|
|
120
|
+
galleryImg: 'fal_galleryImg',
|
|
121
|
+
galleryMeta: 'fal_galleryMeta',
|
|
122
|
+
galleryEmpty: 'fal_galleryEmpty',
|
|
95
123
|
}
|
|
96
124
|
|
|
97
125
|
// -------------------------------------------------------------- chrome
|
|
@@ -134,7 +162,7 @@ window.__ModuleLoader__.load({
|
|
|
134
162
|
],
|
|
135
163
|
}),
|
|
136
164
|
state.dirty ? react_jsx_runtime.jsx('span', { className: cssDefault.pending, children: props.t('settings.unsaved') }) : null,
|
|
137
|
-
react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children:
|
|
165
|
+
react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children: ChevronIconNode }),
|
|
138
166
|
],
|
|
139
167
|
}),
|
|
140
168
|
open
|
|
@@ -424,18 +452,18 @@ window.__ModuleLoader__.load({
|
|
|
424
452
|
/** Field specs in display order. */
|
|
425
453
|
const FIELDS = [
|
|
426
454
|
{ 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
|
-
{ field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
|
|
428
|
-
{ field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
|
|
429
|
-
{ field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
|
|
455
|
+
{ field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model', when: ['fal'] },
|
|
456
|
+
{ field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv', when: ['fal'] },
|
|
457
|
+
{ field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL', when: ['fal'] },
|
|
430
458
|
{ field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS },
|
|
431
459
|
{ field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
|
|
432
|
-
{ field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs' },
|
|
433
|
-
{ field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs' },
|
|
460
|
+
{ field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs', when: ['fal'] },
|
|
461
|
+
{ field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs', when: ['fal'] },
|
|
434
462
|
{ field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
|
|
435
|
-
{ field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL' },
|
|
436
|
-
{ field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel' },
|
|
437
|
-
{ field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv' },
|
|
438
|
-
{ field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize' },
|
|
463
|
+
{ field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL', when: ['custom'] },
|
|
464
|
+
{ field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel', when: ['custom'] },
|
|
465
|
+
{ field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv', when: ['custom'] },
|
|
466
|
+
{ field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize', when: ['custom'] },
|
|
439
467
|
{ field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
|
|
440
468
|
]
|
|
441
469
|
|
|
@@ -465,10 +493,68 @@ window.__ModuleLoader__.load({
|
|
|
465
493
|
}
|
|
466
494
|
|
|
467
495
|
/** Render the plugin settings card. */
|
|
496
|
+
/** Галерея истории генераций: тянет /history и рендерит миниатюры. */
|
|
497
|
+
function HistoryGallery(props) {
|
|
498
|
+
const [entries, setEntries] = react.useState([])
|
|
499
|
+
const [loading, setLoading] = react.useState(true)
|
|
500
|
+
const [filter, setFilter] = react.useState('')
|
|
501
|
+
react.useEffect(() => {
|
|
502
|
+
let cancelled = false
|
|
503
|
+
fetch('/dsh-image-gen/history')
|
|
504
|
+
.then((r) => r.json())
|
|
505
|
+
.then((data) => { if (!cancelled) { setEntries(Array.isArray(data) ? data : []); setLoading(false) } })
|
|
506
|
+
.catch(() => { if (!cancelled) setLoading(false) })
|
|
507
|
+
return () => { cancelled = true }
|
|
508
|
+
}, [])
|
|
509
|
+
const t = props.t
|
|
510
|
+
if (loading) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.loading') })
|
|
511
|
+
if (!entries.length) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.empty') })
|
|
512
|
+
const providers = [...new Set(entries.map((e) => e.provider).filter(Boolean))]
|
|
513
|
+
const filtered = filter ? entries.filter((e) => e.provider === filter) : entries
|
|
514
|
+
return react_jsx_runtime.jsxs('div', {
|
|
515
|
+
className: cssDefault.gallery,
|
|
516
|
+
children: [
|
|
517
|
+
react_jsx_runtime.jsx('select', {
|
|
518
|
+
className: cssDefault.select,
|
|
519
|
+
value: filter,
|
|
520
|
+
onChange: (e) => setFilter(e.target.value),
|
|
521
|
+
children: [
|
|
522
|
+
react_jsx_runtime.jsx('option', { value: '', children: t('history.all') }),
|
|
523
|
+
providers.map((p) => react_jsx_runtime.jsx('option', { value: p, children: p }, p)),
|
|
524
|
+
],
|
|
525
|
+
}),
|
|
526
|
+
filtered.map((e) =>
|
|
527
|
+
react_jsx_runtime.jsxs('a', {
|
|
528
|
+
className: cssDefault.galleryItem,
|
|
529
|
+
key: e.path,
|
|
530
|
+
href: e.thumbnailUrl || '#',
|
|
531
|
+
target: '_blank',
|
|
532
|
+
rel: 'noreferrer',
|
|
533
|
+
children: [
|
|
534
|
+
e.thumbnailUrl ? react_jsx_runtime.jsx('img', { className: cssDefault.galleryImg, src: e.thumbnailUrl, alt: e.prompt || '', loading: 'lazy' }) : null,
|
|
535
|
+
react_jsx_runtime.jsx('div', { className: cssDefault.galleryMeta, children: `${e.provider || ''} · ${e.size || ''}` }),
|
|
536
|
+
e.seed !== undefined ? react_jsx_runtime.jsx('button', {
|
|
537
|
+
type: 'button',
|
|
538
|
+
className: 'fal-repeat',
|
|
539
|
+
onClick: (ev) => {
|
|
540
|
+
ev.preventDefault()
|
|
541
|
+
ev.stopPropagation()
|
|
542
|
+
try { navigator.clipboard.writeText(String(e.seed)) } catch (err) { /* нет доступа к буферу */ }
|
|
543
|
+
},
|
|
544
|
+
}, `seed ${e.seed}`) : null,
|
|
545
|
+
],
|
|
546
|
+
}),
|
|
547
|
+
),
|
|
548
|
+
],
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
|
|
468
552
|
function FalSettingsCard(props) {
|
|
469
553
|
const { t } = props
|
|
470
554
|
const state = props.useFalSettingsCard((snapshot) => snapshot)
|
|
471
555
|
const disabled = !state.writable
|
|
556
|
+
const currentProvider = (state.provider && state.provider.text) || 'fal'
|
|
557
|
+
const visibleFields = FIELDS.filter((entry) => !entry.when || entry.when.includes(currentProvider))
|
|
472
558
|
const fieldProps = {
|
|
473
559
|
overriddenLabel: t('settings.overridden'),
|
|
474
560
|
resetLabel: t('settings.reset'),
|
|
@@ -484,7 +570,7 @@ window.__ModuleLoader__.load({
|
|
|
484
570
|
onDiscard: props.discard,
|
|
485
571
|
children: [
|
|
486
572
|
react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: t('settings.credentialHint') }),
|
|
487
|
-
|
|
573
|
+
visibleFields.map((entry) =>
|
|
488
574
|
react_jsx_runtime.jsx(
|
|
489
575
|
Field,
|
|
490
576
|
{
|
|
@@ -498,6 +584,9 @@ window.__ModuleLoader__.load({
|
|
|
498
584
|
entry.field,
|
|
499
585
|
),
|
|
500
586
|
),
|
|
587
|
+
react_jsx_runtime.jsx('div', { className: cssDefault.sep, key: 'hsep' }),
|
|
588
|
+
react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'hlabel', children: t('history.title') }),
|
|
589
|
+
react_jsx_runtime.jsx(HistoryGallery, { t, key: 'history' }),
|
|
501
590
|
],
|
|
502
591
|
})
|
|
503
592
|
}
|
|
@@ -583,6 +672,10 @@ window.__ModuleLoader__.load({
|
|
|
583
672
|
'p.customKeyEnv': 'Example: OPENAI_API_KEY',
|
|
584
673
|
'p.customSize': 'Example: 1024x1024',
|
|
585
674
|
'p.outputDir': 'Example: generated/images',
|
|
675
|
+
'history.title': 'Generation history',
|
|
676
|
+
'history.all': 'All providers',
|
|
677
|
+
'history.empty': 'No generations yet',
|
|
678
|
+
'history.loading': 'Loading…',
|
|
586
679
|
}
|
|
587
680
|
|
|
588
681
|
const ru = {
|
|
@@ -642,6 +735,10 @@ window.__ModuleLoader__.load({
|
|
|
642
735
|
'p.customKeyEnv': 'Пример: OPENAI_API_KEY',
|
|
643
736
|
'p.customSize': 'Пример: 1024x1024',
|
|
644
737
|
'p.outputDir': 'Пример: generated/images',
|
|
738
|
+
'history.title': 'История генераций',
|
|
739
|
+
'history.all': 'Все провайдеры',
|
|
740
|
+
'history.empty': 'Ещё нет генераций',
|
|
741
|
+
'history.loading': 'Загрузка…',
|
|
645
742
|
}
|
|
646
743
|
|
|
647
744
|
// ── карточка результата generate_image ────────────────────────────────
|
|
@@ -701,6 +798,8 @@ window.__ModuleLoader__.load({
|
|
|
701
798
|
const running = !('kind' in (block || {}))
|
|
702
799
|
const failed = block && (block.isError || block.error !== undefined)
|
|
703
800
|
const parsed = readResult(block)
|
|
801
|
+
const [editing, setEditing] = react.useState(false)
|
|
802
|
+
const [editText, setEditText] = react.useState('')
|
|
704
803
|
let prompt = ''
|
|
705
804
|
try {
|
|
706
805
|
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
@@ -805,6 +904,43 @@ window.__ModuleLoader__.load({
|
|
|
805
904
|
onClick: repeat,
|
|
806
905
|
}, 'Повторить генерацию'))
|
|
807
906
|
}
|
|
907
|
+
if (!running && !failed) {
|
|
908
|
+
if (editing) {
|
|
909
|
+
body.push(react.createElement('input', {
|
|
910
|
+
key: 'editinput',
|
|
911
|
+
type: 'text',
|
|
912
|
+
className: 'fal-input',
|
|
913
|
+
value: editText,
|
|
914
|
+
placeholder: prompt || 'Новый промпт',
|
|
915
|
+
onChange: (e) => setEditText(e.target.value),
|
|
916
|
+
}))
|
|
917
|
+
body.push(react.createElement('button', {
|
|
918
|
+
key: 'editsend',
|
|
919
|
+
type: 'button',
|
|
920
|
+
className: 'fal-repeat',
|
|
921
|
+
onClick: async () => {
|
|
922
|
+
const text = (editText || prompt || '').trim()
|
|
923
|
+
if (!text) return
|
|
924
|
+
try {
|
|
925
|
+
const sessions = props.sessions
|
|
926
|
+
const current = sessions && sessions.list && sessions.list.current
|
|
927
|
+
const binding = current && sessions.binding(current)
|
|
928
|
+
const session = binding && binding.session
|
|
929
|
+
if (session && session.prompt) await session.prompt([{ type: 'text', text }], 'queue')
|
|
930
|
+
} catch (e) { /* контур сам покажет */ }
|
|
931
|
+
setEditing(false)
|
|
932
|
+
setEditText('')
|
|
933
|
+
},
|
|
934
|
+
}, 'Отправить'))
|
|
935
|
+
} else {
|
|
936
|
+
body.push(react.createElement('button', {
|
|
937
|
+
key: 'edit',
|
|
938
|
+
type: 'button',
|
|
939
|
+
className: 'fal-repeat',
|
|
940
|
+
onClick: () => { setEditText(prompt || ''); setEditing(true) },
|
|
941
|
+
}, 'Изменить промпт'))
|
|
942
|
+
}
|
|
943
|
+
}
|
|
808
944
|
if (!running && !failed && downloadUrl) {
|
|
809
945
|
body.push(react.createElement('a', {
|
|
810
946
|
key: 'download',
|
package/lib/index.js
CHANGED
|
@@ -16,6 +16,7 @@ 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
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,
|
|
@@ -23,6 +24,8 @@ import {
|
|
|
23
24
|
PROVIDER_KEYS,
|
|
24
25
|
buildSidecar,
|
|
25
26
|
makeProviders,
|
|
27
|
+
ASPECT_RATIOS,
|
|
28
|
+
pixelDiff,
|
|
26
29
|
normalizeCount,
|
|
27
30
|
tryGenerate,
|
|
28
31
|
fallbackOrder,
|
|
@@ -128,6 +131,22 @@ export const Config = z.object({
|
|
|
128
131
|
.number()
|
|
129
132
|
.description('provider=local: CFG scale.')
|
|
130
133
|
.default(7),
|
|
134
|
+
seedreamKeyEnv: z
|
|
135
|
+
.string()
|
|
136
|
+
.description('provider=seedream: credential reference / env var holding the API key.')
|
|
137
|
+
.default('SEEDREAM_API_KEY'),
|
|
138
|
+
seedreamModel: z
|
|
139
|
+
.string()
|
|
140
|
+
.description('provider=seedream: model id, e.g. seedream-4.0.')
|
|
141
|
+
.default('seedream-4.0'),
|
|
142
|
+
geminiKeyEnv: z
|
|
143
|
+
.string()
|
|
144
|
+
.description('provider=gemini: credential reference / env var holding the Google API key.')
|
|
145
|
+
.default('GEMINI_API_KEY'),
|
|
146
|
+
geminiModel: z
|
|
147
|
+
.string()
|
|
148
|
+
.description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
|
|
149
|
+
.default('gemini-2.0-flash-exp-image-generation'),
|
|
131
150
|
outputDir: z
|
|
132
151
|
.string()
|
|
133
152
|
.description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
|
|
@@ -136,6 +155,10 @@ export const Config = z.object({
|
|
|
136
155
|
.number()
|
|
137
156
|
.description('How many recent generations to keep in the in-memory history list.')
|
|
138
157
|
.default(50),
|
|
158
|
+
pruneDays: z
|
|
159
|
+
.number()
|
|
160
|
+
.description('Delete generated files and history entries older than this many days. 0 (default) disables pruning.')
|
|
161
|
+
.default(0),
|
|
139
162
|
enhancePrompt: z
|
|
140
163
|
.boolean()
|
|
141
164
|
.description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
|
|
@@ -148,6 +171,18 @@ export const Config = z.object({
|
|
|
148
171
|
.number()
|
|
149
172
|
.description('Only enhance prompts shorter than this many characters.')
|
|
150
173
|
.default(200),
|
|
174
|
+
stylePreset: z
|
|
175
|
+
.string()
|
|
176
|
+
.description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
|
|
177
|
+
.default(''),
|
|
178
|
+
cacheBySeed: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
181
|
+
.default(false),
|
|
182
|
+
cacheByPrompt: z
|
|
183
|
+
.boolean()
|
|
184
|
+
.description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
185
|
+
.default(false),
|
|
151
186
|
})
|
|
152
187
|
|
|
153
188
|
/** Keep a file stem safe for the filesystem. */
|
|
@@ -214,8 +249,73 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
214
249
|
}
|
|
215
250
|
}
|
|
216
251
|
|
|
217
|
-
/**
|
|
218
|
-
|
|
252
|
+
/** Каталог истории: общий для всех устройств, переживает рестарт. */
|
|
253
|
+
export function historyFile() {
|
|
254
|
+
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Прочитать историю из файла (пусто, если файла нет). */
|
|
258
|
+
/** Найти запись истории по seed+prompt, если файл ещё существует. */
|
|
259
|
+
/** Найти запись истории по промпту (без учёта seed), если файл существует. */
|
|
260
|
+
export async function findCachedByPrompt(entries, prompt) {
|
|
261
|
+
return entries.find((e) => e.prompt === prompt)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function findCached(entries, seed, prompt) {
|
|
265
|
+
if (seed === undefined) return undefined
|
|
266
|
+
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Вернуть запись из кэша, если файл существует; иначе undefined. */
|
|
270
|
+
export async function cachedResult(entry) {
|
|
271
|
+
if (!entry || !entry.path) return undefined
|
|
272
|
+
try { require('node:fs').accessSync(entry.path) } catch (e) { return undefined }
|
|
273
|
+
return {
|
|
274
|
+
path: entry.path,
|
|
275
|
+
url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
|
|
276
|
+
width: 0,
|
|
277
|
+
height: 0,
|
|
278
|
+
seed: entry.seed,
|
|
279
|
+
prompt: entry.prompt,
|
|
280
|
+
format: 'png',
|
|
281
|
+
cached: true,
|
|
282
|
+
attachment: entry.attachmentId ? { attachmentId: entry.attachmentId, mediaType: 'image/png', bytes: 0, width: 0, height: 0, name: '' } : undefined,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Удалить файлы и записи истории старше pruneDays дней. */
|
|
287
|
+
export async function pruneHistory(entries, pruneDays) {
|
|
288
|
+
if (!pruneDays || pruneDays <= 0) return entries
|
|
289
|
+
const cutoff = Date.now() - pruneDays * 86400000
|
|
290
|
+
const kept = []
|
|
291
|
+
for (const e of entries) {
|
|
292
|
+
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
293
|
+
if (Number.isFinite(created) && created < cutoff) {
|
|
294
|
+
try { require('node:fs').unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
kept.push(e)
|
|
298
|
+
}
|
|
299
|
+
return kept
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function readHistory() {
|
|
303
|
+
try {
|
|
304
|
+
const raw = await readFile(historyFile(), 'utf-8')
|
|
305
|
+
const parsed = JSON.parse(raw)
|
|
306
|
+
return Array.isArray(parsed) ? parsed : []
|
|
307
|
+
} catch (e) {
|
|
308
|
+
return []
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Записать историю в файл (перезапись целиком). */
|
|
313
|
+
export async function writeHistory(entries) {
|
|
314
|
+
try {
|
|
315
|
+
await mkdir(path.dirname(historyFile()), { recursive: true })
|
|
316
|
+
await writeFile(historyFile(), JSON.stringify(entries, null, 2))
|
|
317
|
+
} catch (e) { /* история не критична */ }
|
|
318
|
+
}
|
|
219
319
|
|
|
220
320
|
/** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
|
|
221
321
|
export function filterHistory(entries, exists) {
|
|
@@ -349,8 +449,13 @@ export function apply(ctx, config) {
|
|
|
349
449
|
return
|
|
350
450
|
}
|
|
351
451
|
const exists = (p) => { try { return require('node:fs').existsSync(p) } catch { return false } }
|
|
452
|
+
const entries = await readHistory()
|
|
453
|
+
const withThumbs = filterHistory(entries, exists).map((e) => ({
|
|
454
|
+
...e,
|
|
455
|
+
thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
|
|
456
|
+
}))
|
|
352
457
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
353
|
-
res.end(JSON.stringify(
|
|
458
|
+
res.end(JSON.stringify(withThumbs))
|
|
354
459
|
},
|
|
355
460
|
}), 'dsh-image-gen: history route')
|
|
356
461
|
|
|
@@ -373,6 +478,11 @@ export function apply(ctx, config) {
|
|
|
373
478
|
type: 'string',
|
|
374
479
|
description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
|
|
375
480
|
},
|
|
481
|
+
aspect_ratio: {
|
|
482
|
+
type: 'string',
|
|
483
|
+
enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
|
|
484
|
+
description: 'Optional aspect ratio; overrides image_size when both are given. Sent as width/height to providers that accept pixels.',
|
|
485
|
+
},
|
|
376
486
|
seed: {
|
|
377
487
|
type: 'integer',
|
|
378
488
|
description: 'Optional seed for reproducible output.',
|
|
@@ -390,6 +500,16 @@ export function apply(ctx, config) {
|
|
|
390
500
|
type: 'integer',
|
|
391
501
|
description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
|
|
392
502
|
},
|
|
503
|
+
prompts: {
|
|
504
|
+
type: 'array',
|
|
505
|
+
items: {
|
|
506
|
+
oneOf: [
|
|
507
|
+
{ type: 'string' },
|
|
508
|
+
{ type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
description: 'Optional list of prompts (strings or {text}); one image is generated per prompt. When set, overrides count and the single prompt.',
|
|
512
|
+
},
|
|
393
513
|
negative_prompt: {
|
|
394
514
|
type: 'string',
|
|
395
515
|
description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
|
|
@@ -398,6 +518,15 @@ export function apply(ctx, config) {
|
|
|
398
518
|
type: 'number',
|
|
399
519
|
description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
|
|
400
520
|
},
|
|
521
|
+
quality: {
|
|
522
|
+
type: 'string',
|
|
523
|
+
enum: ['low', 'medium', 'high'],
|
|
524
|
+
description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
525
|
+
},
|
|
526
|
+
style: {
|
|
527
|
+
type: 'string',
|
|
528
|
+
description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
529
|
+
},
|
|
401
530
|
source_image: {
|
|
402
531
|
type: 'string',
|
|
403
532
|
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.',
|
|
@@ -472,10 +601,11 @@ export function apply(ctx, config) {
|
|
|
472
601
|
const cfg = live()
|
|
473
602
|
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
474
603
|
const deliverAs = cfg.deliverAs
|
|
475
|
-
const size = args.image_size ?? cfg.defaultSize
|
|
476
|
-
if (!IMAGE_SIZES.includes(size)) {
|
|
604
|
+
const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
|
|
605
|
+
if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
|
|
477
606
|
throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
|
|
478
607
|
}
|
|
608
|
+
const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
|
|
479
609
|
const format = args.output_format ?? cfg.defaultFormat
|
|
480
610
|
if (!OUTPUT_FORMATS.includes(format)) {
|
|
481
611
|
throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
|
|
@@ -488,22 +618,23 @@ export function apply(ctx, config) {
|
|
|
488
618
|
const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
|
|
489
619
|
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
490
620
|
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
|
|
491
|
-
const
|
|
621
|
+
const styleSuffix = cfg.stylePreset ? `, ${cfg.stylePreset}` : ''
|
|
622
|
+
const effectivePrompt = enhanced.prompt + styleSuffix
|
|
492
623
|
const providers = makeProviders(
|
|
493
624
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
494
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength },
|
|
625
|
+
{ 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, style: args.style, aspectPixels },
|
|
495
626
|
)
|
|
496
627
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
497
628
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
498
629
|
// байтами, и пользователь получал битую карточку вместо внятного отказа.
|
|
499
630
|
const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
|
|
500
|
-
const one = async (jobSeed, providerKey) => {
|
|
501
|
-
const gen = await providers[providerKey](jobSeed)
|
|
631
|
+
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
632
|
+
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
502
633
|
guard(gen)
|
|
503
634
|
const bytes = gen.bytes
|
|
504
635
|
const mediaType = gen.mediaType
|
|
505
636
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
506
|
-
const stem = `${slugify(args.output_name ||
|
|
637
|
+
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
507
638
|
const name = `${stem}.${extension}`
|
|
508
639
|
|
|
509
640
|
const attachment = await ctx.attachments.saveImage({
|
|
@@ -529,7 +660,7 @@ export function apply(ctx, config) {
|
|
|
529
660
|
await writeFile(
|
|
530
661
|
path.join(outDir, `${stem}.json`),
|
|
531
662
|
JSON.stringify(buildSidecar({
|
|
532
|
-
prompt:
|
|
663
|
+
prompt: promptArg,
|
|
533
664
|
size,
|
|
534
665
|
format,
|
|
535
666
|
seed: gen.seed,
|
|
@@ -544,17 +675,21 @@ export function apply(ctx, config) {
|
|
|
544
675
|
}), null, 2),
|
|
545
676
|
)
|
|
546
677
|
|
|
547
|
-
|
|
678
|
+
const entry = {
|
|
548
679
|
path: filePath,
|
|
549
|
-
prompt:
|
|
680
|
+
prompt: promptArg,
|
|
550
681
|
provider,
|
|
551
682
|
size,
|
|
552
683
|
format,
|
|
553
684
|
seed: gen.seed,
|
|
554
685
|
createdAt: new Date().toISOString(),
|
|
555
686
|
attachmentId: attachment.attachmentId,
|
|
556
|
-
}
|
|
557
|
-
|
|
687
|
+
}
|
|
688
|
+
let current = await readHistory()
|
|
689
|
+
current = await pruneHistory(current, cfg.pruneDays)
|
|
690
|
+
current.unshift(entry)
|
|
691
|
+
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
692
|
+
await writeHistory(current)
|
|
558
693
|
|
|
559
694
|
return {
|
|
560
695
|
path: filePath,
|
|
@@ -562,7 +697,7 @@ export function apply(ctx, config) {
|
|
|
562
697
|
width: gen.width || attachment.width,
|
|
563
698
|
height: gen.height || attachment.height,
|
|
564
699
|
seed: gen.seed,
|
|
565
|
-
prompt:
|
|
700
|
+
prompt: promptArg,
|
|
566
701
|
originalPrompt: enhanced.enhanced ? args.prompt : undefined,
|
|
567
702
|
cost: gen.cost,
|
|
568
703
|
format: mediaType.replace('image/', ''),
|
|
@@ -577,19 +712,58 @@ export function apply(ctx, config) {
|
|
|
577
712
|
}
|
|
578
713
|
}
|
|
579
714
|
|
|
580
|
-
const count = normalizeCount(args.count)
|
|
581
715
|
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
582
716
|
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
583
717
|
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
584
718
|
const order = fallbackOrder(provider)
|
|
585
|
-
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
|
|
719
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
|
|
586
720
|
const images = []
|
|
587
|
-
|
|
588
|
-
|
|
721
|
+
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
722
|
+
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
723
|
+
if (batchPrompts) {
|
|
724
|
+
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
725
|
+
const item = batchPrompts[i]
|
|
726
|
+
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
727
|
+
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, text)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, text)) : undefined)
|
|
728
|
+
images.push(cached || await tryGenerate(generators, order, seedBase + i, text))
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
const count = normalizeCount(args.count)
|
|
732
|
+
for (let i = 0; i < count; i += 1) {
|
|
733
|
+
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, effectivePrompt)) : undefined)
|
|
734
|
+
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
735
|
+
}
|
|
589
736
|
}
|
|
590
737
|
const first = images[0]
|
|
591
738
|
return { ...first, images }
|
|
592
739
|
},
|
|
593
740
|
}),
|
|
594
741
|
)
|
|
742
|
+
|
|
743
|
+
// Сравнение двух изображений: доля различающихся пикселей.
|
|
744
|
+
ctx.tools.register(
|
|
745
|
+
defineTool({
|
|
746
|
+
name: 'compare_images',
|
|
747
|
+
description: 'Compare two images (paths or attachment ids) and report the fraction of differing pixels. Use to check how much an edit changed the original.',
|
|
748
|
+
parameters: {
|
|
749
|
+
image_a: { type: 'string', required: true, description: 'Path or attachment id of the first image.' },
|
|
750
|
+
image_b: { type: 'string', required: true, description: 'Path or attachment id of the second image.' },
|
|
751
|
+
},
|
|
752
|
+
output: {
|
|
753
|
+
schema: {
|
|
754
|
+
type: 'object',
|
|
755
|
+
additionalProperties: false,
|
|
756
|
+
properties: {
|
|
757
|
+
diffRatio: { type: 'number' },
|
|
758
|
+
error: { type: 'string' },
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
},
|
|
762
|
+
async execute(args, exec) {
|
|
763
|
+
const a = await resolveSource(ctx, exec, args.image_a)
|
|
764
|
+
const b = await resolveSource(ctx, exec, args.image_b)
|
|
765
|
+
return pixelDiff(a && a.bytes, b && b.bytes)
|
|
766
|
+
},
|
|
767
|
+
}),
|
|
768
|
+
)
|
|
595
769
|
}
|
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', 'local']
|
|
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)}`)
|
|
@@ -159,6 +159,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
159
159
|
*/
|
|
160
160
|
|
|
161
161
|
/** Именованный размер -> [width, height] для локальных API. */
|
|
162
|
+
/** Соотношения сторон -> [w,h] (в пикселях, по базовой стороне 1024). */
|
|
163
|
+
export const ASPECT_RATIOS = {
|
|
164
|
+
'1:1': [1024, 1024],
|
|
165
|
+
'16:9': [1344, 768],
|
|
166
|
+
'9:16': [768, 1344],
|
|
167
|
+
'4:3': [1152, 896],
|
|
168
|
+
'3:4': [896, 1152],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Размер в пикселях: aspectPixels (если задан) или из именованного размера. */
|
|
172
|
+
export function pxSize(aspectPixels, size) {
|
|
173
|
+
if (aspectPixels) return aspectPixels
|
|
174
|
+
return sizeToPixels(size)
|
|
175
|
+
}
|
|
176
|
+
|
|
162
177
|
export function sizeToPixels(size) {
|
|
163
178
|
const px = SIZE_PIXELS[size]
|
|
164
179
|
if (!px) return [1024, 1024]
|
|
@@ -177,13 +192,24 @@ export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
|
177
192
|
return form
|
|
178
193
|
}
|
|
179
194
|
|
|
195
|
+
/** Сравнить два изображения: доля различающихся пикселей (0..1). */
|
|
196
|
+
export async function pixelDiff(a, b) {
|
|
197
|
+
if (!a || !b) return { error: 'missing image' }
|
|
198
|
+
if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
|
|
199
|
+
let diff = 0
|
|
200
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
201
|
+
if (a[i] !== b[i]) diff += 1
|
|
202
|
+
}
|
|
203
|
+
return { diffRatio: diff / a.length }
|
|
204
|
+
}
|
|
205
|
+
|
|
180
206
|
export function makeProviders(deps, job) {
|
|
181
207
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
182
|
-
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength } = job
|
|
208
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels } = job
|
|
183
209
|
|
|
184
|
-
async function fal(seedArg = seed) {
|
|
210
|
+
async function fal(seedArg = seed, promptArg = prompt) {
|
|
185
211
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
186
|
-
const body = { prompt, image_size: size, num_images: 1 }
|
|
212
|
+
const body = { prompt: promptArg, image_size: size, num_images: 1 }
|
|
187
213
|
if (seedArg !== undefined) body.seed = seedArg
|
|
188
214
|
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
189
215
|
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
@@ -218,7 +244,7 @@ export function makeProviders(deps, job) {
|
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
// Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
|
|
221
|
-
async function custom(seedArg = seed) {
|
|
247
|
+
async function custom(seedArg = seed, promptArg = prompt) {
|
|
222
248
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
223
249
|
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
224
250
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
@@ -236,12 +262,12 @@ export function makeProviders(deps, job) {
|
|
|
236
262
|
method: 'POST',
|
|
237
263
|
headers,
|
|
238
264
|
body: source
|
|
239
|
-
? buildEditForm({ source, mask, prompt, size: cfg.customSize || SIZE_PIXELS[size] || size, strength })
|
|
265
|
+
? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
|
|
240
266
|
: JSON.stringify({
|
|
241
267
|
model: cfg.customModel,
|
|
242
|
-
prompt,
|
|
268
|
+
prompt: promptArg,
|
|
243
269
|
n: 1,
|
|
244
|
-
size: cfg.customSize || SIZE_PIXELS[size] || size,
|
|
270
|
+
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
245
271
|
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
246
272
|
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
247
273
|
}),
|
|
@@ -291,7 +317,7 @@ export function makeProviders(deps, job) {
|
|
|
291
317
|
// только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
|
|
292
318
|
// отдаёт готовую картинку, а не ключ доступа.
|
|
293
319
|
function subscription(provider) {
|
|
294
|
-
return async function generate(seedArg = seed) {
|
|
320
|
+
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
295
321
|
if (source) {
|
|
296
322
|
return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
|
|
297
323
|
}
|
|
@@ -307,7 +333,7 @@ export function makeProviders(deps, job) {
|
|
|
307
333
|
try {
|
|
308
334
|
produced = await images.generate({
|
|
309
335
|
provider,
|
|
310
|
-
prompt,
|
|
336
|
+
prompt: promptArg,
|
|
311
337
|
size: SUBSCRIPTION_SIZES[size] || '1024x1024',
|
|
312
338
|
quality: cfg.subscriptionQuality || undefined,
|
|
313
339
|
signal,
|
|
@@ -333,15 +359,15 @@ export function makeProviders(deps, job) {
|
|
|
333
359
|
}
|
|
334
360
|
|
|
335
361
|
// Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
|
|
336
|
-
async function local(seedArg = seed) {
|
|
362
|
+
async function local(seedArg = seed, promptArg = prompt) {
|
|
337
363
|
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
338
364
|
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
339
|
-
const [width, height] =
|
|
365
|
+
const [width, height] = pxSize(aspectPixels, size)
|
|
340
366
|
const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
|
|
341
367
|
|
|
342
368
|
if (kind === 'a1111') {
|
|
343
369
|
const body = {
|
|
344
|
-
prompt,
|
|
370
|
+
prompt: promptArg,
|
|
345
371
|
negative_prompt: negativePrompt,
|
|
346
372
|
width,
|
|
347
373
|
height,
|
|
@@ -379,7 +405,7 @@ export function makeProviders(deps, job) {
|
|
|
379
405
|
'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
406
|
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
|
|
381
407
|
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
|
382
|
-
'6': { class_type: 'CLIPTextEncode', inputs: { text:
|
|
408
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
|
|
383
409
|
'7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
|
|
384
410
|
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
385
411
|
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
|
|
@@ -429,5 +455,62 @@ export function makeProviders(deps, job) {
|
|
|
429
455
|
}
|
|
430
456
|
}
|
|
431
457
|
|
|
432
|
-
|
|
458
|
+
// Seedream (ByteDance): OpenAI-совместимый API картинок.
|
|
459
|
+
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
460
|
+
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
461
|
+
const base = 'https://api.bytedanceapi.com/v1'
|
|
462
|
+
const res = await fetchImpl(`${base}/images/generations`, {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
465
|
+
body: JSON.stringify({
|
|
466
|
+
model: cfg.seedreamModel || 'seedream-4.0',
|
|
467
|
+
prompt: promptArg,
|
|
468
|
+
n: 1,
|
|
469
|
+
size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
470
|
+
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
471
|
+
...(quality !== undefined ? { quality } : {}),
|
|
472
|
+
...(style !== undefined ? { style } : {}),
|
|
473
|
+
}),
|
|
474
|
+
signal,
|
|
475
|
+
})
|
|
476
|
+
const data = await res.json().catch(() => ({}))
|
|
477
|
+
if (!res.ok) {
|
|
478
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
479
|
+
throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
|
|
480
|
+
}
|
|
481
|
+
const item = data?.data?.[0]
|
|
482
|
+
if (!item) throw new Error('Seedream returned no images')
|
|
483
|
+
if (item.b64_json) {
|
|
484
|
+
return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
485
|
+
}
|
|
486
|
+
if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
|
|
487
|
+
const dl = await fetchImpl(item.url, { signal })
|
|
488
|
+
if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
|
|
489
|
+
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Gemini (Google): images.generate через GenAI API.
|
|
493
|
+
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
494
|
+
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
495
|
+
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
496
|
+
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
headers: { 'Content-Type': 'application/json' },
|
|
499
|
+
body: JSON.stringify({
|
|
500
|
+
contents: [{ parts: [{ text: promptArg }] }],
|
|
501
|
+
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
|
|
502
|
+
}),
|
|
503
|
+
signal,
|
|
504
|
+
})
|
|
505
|
+
const data = await res.json().catch(() => ({}))
|
|
506
|
+
if (!res.ok) {
|
|
507
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
508
|
+
throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
|
|
509
|
+
}
|
|
510
|
+
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
511
|
+
if (!part) throw new Error('Gemini returned no image')
|
|
512
|
+
return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini }
|
|
433
516
|
}
|
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.9",
|
|
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",
|