@goodandready/dsh-image-gen 0.8.8 → 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 +86 -10
- package/lib/providers.js +33 -6
- 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
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
PROVIDER_KEYS,
|
|
25
25
|
buildSidecar,
|
|
26
26
|
makeProviders,
|
|
27
|
+
ASPECT_RATIOS,
|
|
28
|
+
pixelDiff,
|
|
27
29
|
normalizeCount,
|
|
28
30
|
tryGenerate,
|
|
29
31
|
fallbackOrder,
|
|
@@ -153,6 +155,10 @@ export const Config = z.object({
|
|
|
153
155
|
.number()
|
|
154
156
|
.description('How many recent generations to keep in the in-memory history list.')
|
|
155
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),
|
|
156
162
|
enhancePrompt: z
|
|
157
163
|
.boolean()
|
|
158
164
|
.description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
|
|
@@ -173,6 +179,10 @@ export const Config = z.object({
|
|
|
173
179
|
.boolean()
|
|
174
180
|
.description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
175
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),
|
|
176
186
|
})
|
|
177
187
|
|
|
178
188
|
/** Keep a file stem safe for the filesystem. */
|
|
@@ -246,6 +256,11 @@ export function historyFile() {
|
|
|
246
256
|
|
|
247
257
|
/** Прочитать историю из файла (пусто, если файла нет). */
|
|
248
258
|
/** Найти запись истории по seed+prompt, если файл ещё существует. */
|
|
259
|
+
/** Найти запись истории по промпту (без учёта seed), если файл существует. */
|
|
260
|
+
export async function findCachedByPrompt(entries, prompt) {
|
|
261
|
+
return entries.find((e) => e.prompt === prompt)
|
|
262
|
+
}
|
|
263
|
+
|
|
249
264
|
export async function findCached(entries, seed, prompt) {
|
|
250
265
|
if (seed === undefined) return undefined
|
|
251
266
|
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
@@ -268,6 +283,22 @@ export async function cachedResult(entry) {
|
|
|
268
283
|
}
|
|
269
284
|
}
|
|
270
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
|
+
|
|
271
302
|
export async function readHistory() {
|
|
272
303
|
try {
|
|
273
304
|
const raw = await readFile(historyFile(), 'utf-8')
|
|
@@ -447,6 +478,11 @@ export function apply(ctx, config) {
|
|
|
447
478
|
type: 'string',
|
|
448
479
|
description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
|
|
449
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
|
+
},
|
|
450
486
|
seed: {
|
|
451
487
|
type: 'integer',
|
|
452
488
|
description: 'Optional seed for reproducible output.',
|
|
@@ -466,8 +502,13 @@ export function apply(ctx, config) {
|
|
|
466
502
|
},
|
|
467
503
|
prompts: {
|
|
468
504
|
type: 'array',
|
|
469
|
-
items: {
|
|
470
|
-
|
|
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.',
|
|
471
512
|
},
|
|
472
513
|
negative_prompt: {
|
|
473
514
|
type: 'string',
|
|
@@ -482,6 +523,10 @@ export function apply(ctx, config) {
|
|
|
482
523
|
enum: ['low', 'medium', 'high'],
|
|
483
524
|
description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
484
525
|
},
|
|
526
|
+
style: {
|
|
527
|
+
type: 'string',
|
|
528
|
+
description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
529
|
+
},
|
|
485
530
|
source_image: {
|
|
486
531
|
type: 'string',
|
|
487
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.',
|
|
@@ -556,10 +601,11 @@ export function apply(ctx, config) {
|
|
|
556
601
|
const cfg = live()
|
|
557
602
|
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
558
603
|
const deliverAs = cfg.deliverAs
|
|
559
|
-
const size = args.image_size ?? cfg.defaultSize
|
|
560
|
-
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)) {
|
|
561
606
|
throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
|
|
562
607
|
}
|
|
608
|
+
const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
|
|
563
609
|
const format = args.output_format ?? cfg.defaultFormat
|
|
564
610
|
if (!OUTPUT_FORMATS.includes(format)) {
|
|
565
611
|
throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
|
|
@@ -576,7 +622,7 @@ export function apply(ctx, config) {
|
|
|
576
622
|
const effectivePrompt = enhanced.prompt + styleSuffix
|
|
577
623
|
const providers = makeProviders(
|
|
578
624
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
579
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength, quality: args.quality },
|
|
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 },
|
|
580
626
|
)
|
|
581
627
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
582
628
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
@@ -639,7 +685,8 @@ export function apply(ctx, config) {
|
|
|
639
685
|
createdAt: new Date().toISOString(),
|
|
640
686
|
attachmentId: attachment.attachmentId,
|
|
641
687
|
}
|
|
642
|
-
|
|
688
|
+
let current = await readHistory()
|
|
689
|
+
current = await pruneHistory(current, cfg.pruneDays)
|
|
643
690
|
current.unshift(entry)
|
|
644
691
|
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
645
692
|
await writeHistory(current)
|
|
@@ -671,17 +718,19 @@ export function apply(ctx, config) {
|
|
|
671
718
|
const order = fallbackOrder(provider)
|
|
672
719
|
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
|
|
673
720
|
const images = []
|
|
674
|
-
const historyEntries = cfg.cacheBySeed ? await readHistory() : []
|
|
721
|
+
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
675
722
|
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
676
723
|
if (batchPrompts) {
|
|
677
724
|
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
678
|
-
const
|
|
679
|
-
|
|
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))
|
|
680
729
|
}
|
|
681
730
|
} else {
|
|
682
731
|
const count = normalizeCount(args.count)
|
|
683
732
|
for (let i = 0; i < count; i += 1) {
|
|
684
|
-
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : undefined
|
|
733
|
+
const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, effectivePrompt)) : undefined)
|
|
685
734
|
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
686
735
|
}
|
|
687
736
|
}
|
|
@@ -690,4 +739,31 @@ export function apply(ctx, config) {
|
|
|
690
739
|
},
|
|
691
740
|
}),
|
|
692
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
|
+
)
|
|
693
769
|
}
|
package/lib/providers.js
CHANGED
|
@@ -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,9 +192,20 @@ 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, quality } = job
|
|
208
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels } = job
|
|
183
209
|
|
|
184
210
|
async function fal(seedArg = seed, promptArg = prompt) {
|
|
185
211
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
@@ -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: promptArg, 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
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
|
}),
|
|
@@ -336,7 +362,7 @@ export function makeProviders(deps, job) {
|
|
|
336
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') {
|
|
@@ -440,9 +466,10 @@ export function makeProviders(deps, job) {
|
|
|
440
466
|
model: cfg.seedreamModel || 'seedream-4.0',
|
|
441
467
|
prompt: promptArg,
|
|
442
468
|
n: 1,
|
|
443
|
-
size: SIZE_PIXELS[size] || size,
|
|
469
|
+
size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
444
470
|
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
445
471
|
...(quality !== undefined ? { quality } : {}),
|
|
472
|
+
...(style !== undefined ? { style } : {}),
|
|
446
473
|
}),
|
|
447
474
|
signal,
|
|
448
475
|
})
|
|
@@ -471,7 +498,7 @@ export function makeProviders(deps, job) {
|
|
|
471
498
|
headers: { 'Content-Type': 'application/json' },
|
|
472
499
|
body: JSON.stringify({
|
|
473
500
|
contents: [{ parts: [{ text: promptArg }] }],
|
|
474
|
-
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}) },
|
|
501
|
+
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
|
|
475
502
|
}),
|
|
476
503
|
signal,
|
|
477
504
|
})
|
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",
|