@goodandready/dsh-image-gen 0.10.8 → 0.10.11
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/README.md +15 -0
- package/README.ru.md +15 -0
- package/lib/client.js +147 -108
- package/lib/cost-meter.js +146 -0
- package/lib/generation-cache.js +131 -0
- package/lib/index.js +150 -3
- package/lib/loop-guard.js +64 -0
- package/lib/negative-sanitizer.js +149 -0
- package/lib/quality-gate.js +140 -0
- package/lib/security.js +59 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,6 +83,21 @@
|
|
|
83
83
|
* **FAL Credential Aliasing**: seamless fallback between `FAL_API_KEY` and `FAL_KEY` in credentials and environment.
|
|
84
84
|
* **Subscription Aspect Ratio Mapping**: maps aspect ratios (`16:9`, `3:2`, `9:16`, `2:3`) to appropriate subscription dimensions (`1536x1024` / `1024x1536`) instead of falling back to default square `1024x1024`.
|
|
85
85
|
|
|
86
|
+
|
|
87
|
+
### 🚀 What's New in v0.10.11 (#165, #166, #167, #168, #169, #170)
|
|
88
|
+
* **Negative Prompt Sanitizer (#165)**: Automatic defect filtering and deduplication for diffusion models (SDXL, ComfyUI, Seedream, Local) with style-conflict protection (preserves intentional grainy, vintage, or dark aesthetics).
|
|
89
|
+
* **Automated Quality Gate & Silent Re-roll (#166)**: Heuristic variance and sharpness inspection combined with `dsh-vision-bridge` hook; performs silent re-rolls (up to 2 attempts) for blank, corrupted, or solid frames before returning results.
|
|
90
|
+
* **Cost Metering & Daily Budget (#167)**: Full rate card pricing per provider and resolution, spend tracking in `~/.dsh/storages/dsh-image-gen-spend.json`, dispatch to `dsh-cost-meter`, and hard daily budget enforcement via `dailyBudgetUsd`.
|
|
91
|
+
* **Fail-Fast Loop Guard (#168)**: Session-scoped circuit breaker prevents runaway agent retry loops (default max 3 consecutive generations without user interaction).
|
|
92
|
+
* **Secure Credential Masking (#169)**: Comprehensive masking of tokens in logs, URLs, and errors (`Bearer sk-...abcd`), with `0600` file permission enforcement.
|
|
93
|
+
* **Content-Addressed Disk Cache (#170)**: Instant retrieval (<50ms, zero API cost) for identical requests by SHA-256 hash, with automated LRU disk eviction (500 MB limit) and `force: true` bypass.
|
|
94
|
+
|
|
95
|
+
### 🚀 What's New in v0.10.10 (#201, #203)
|
|
96
|
+
* **Settings GUI Stabilization (#201)**: Fixed `booleanField` spec in client runtime that prevented the settings configuration pane from rendering in DSH Web UI. All configuration fields (providers, model identifiers, API credentials, style presets, LLM enhancer, timeouts, cache retention) render cleanly.
|
|
97
|
+
* **Streamlined Settings UI (#203)**: Removed the bulky in-settings history gallery to keep the configuration panel focused, fast, and organized.
|
|
98
|
+
* **Comprehensive Localization (#201)**: Added 100% dictionary coverage for all provider credentials, endpoints, and field hints in English and Russian.
|
|
99
|
+
* **Robust Slot Mounting**: Implemented `registerFirst` helper with graceful fallback between `settings.plugin.item` and `settings.section`.
|
|
100
|
+
|
|
86
101
|
## 🎨 Supported Generation Backends
|
|
87
102
|
|
|
88
103
|
* **`fal`** (Default): FAL.ai queue for FLUX.1, SDXL, Clarity Upscaler, and BiRefNet.
|
package/README.ru.md
CHANGED
|
@@ -129,6 +129,21 @@ graph LR
|
|
|
129
129
|
- **Оптимизация компонентов (#139)**: из `FalImageCard` удалён неиспользуемый мёртвый стейт.
|
|
130
130
|
- **100% интернационализация (#140)**: устранены захардкоженные русские строки, добавлены переводы на русский, английский и китайский языки.
|
|
131
131
|
|
|
132
|
+
|
|
133
|
+
### 🚀 Что нового в v0.10.11 (#165, #166, #167, #168, #169, #170)
|
|
134
|
+
* **Negative Prompt Sanitizer (#165)**: Автоматическая санитизация и дедупликация негативных промптов для диффузионных моделей (SDXL, ComfyUI, Seedream, Local) с защитой от конфликта стилей (сохраняет намеренно запрошенную зернистость, винтажность или тени).
|
|
135
|
+
* **Quality Gate и Silent Re-roll (#166)**: Экспресс-контроль резкости/энтропии кадра и интеграция с `dsh-vision-bridge` с автоматическим тихим ре-роллом дефектных, пустых или черных кадров (до 2 попыток).
|
|
136
|
+
* **Интеграция с dsh-cost-meter и суточный бюджет (#167)**: Тарифная сетка по провайдерам и разрешениям, фиксация расходов в `~/.dsh/storages/dsh-image-gen-spend.json` и защита от перерасхода суточного бюджета (`dailyBudgetUsd`).
|
|
137
|
+
* **Fail-Fast Loop Guard (#168)**: Защита от бесконечного зацикливания агента в рамках сессии (максимум 3 последовательные генерации подряд без ответа пользователя).
|
|
138
|
+
* **Маскирование ключей и безопасность (#169)**: Полная санитизация токенов в логах, ошибках и URL (`Bearer sk-...abcd`) и права доступа `0600` на файлы конфигурации.
|
|
139
|
+
* **Контентно-адресуемый дисковый кэш (#170)**: Мгновенная отдача (<50мс, нулевая стоимость) повторных идентичных генераций по SHA-256 хэшу параметров с автоочисткой по LRU (лимит 500 МБ) и опцией принудительного обхода `force: true`.
|
|
140
|
+
|
|
141
|
+
### 🚀 Что нового в v0.10.10 (#201, #203)
|
|
142
|
+
* **Стабилизация окна настроек GUI (#201)**: Исправлена спецификация `booleanField` в клиентском коде, вызывавшая падение рендера карточки настроек плагина в DSH Web UI. Форма настроек теперь открывается штатно со всеми полями, селекторами провайдеров, моделями, ссылками на ключи и пресетами.
|
|
143
|
+
* **Очистка интерфейса настроек (#203)**: Из окна настроек убрана встроенная галерея истории генераций, загромождавшая карточку настроек.
|
|
144
|
+
* **Полная локализация (#201)**: Добавлены все недостающие строки переводов и подсказок на русском и английском языках для всех поддерживаемых провайдеров.
|
|
145
|
+
* **Надёжная регистрация слотов**: Реализован `registerFirst` с плавным откатом между `settings.plugin.item` и `settings.section`.
|
|
146
|
+
|
|
132
147
|
## 🎨 Поддерживаемые провайдеры генерации
|
|
133
148
|
|
|
134
149
|
* **`fal`** *(дефолт)*: Сверхбыстрая очередь FAL.ai (`FLUX.1-schnell`, `FLUX-dev`, `SDXL`, BiRefNet, Clarity Upscaler).
|
package/lib/client.js
CHANGED
|
@@ -82,12 +82,7 @@ window.__ModuleLoader__.load({
|
|
|
82
82
|
'.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
|
|
83
83
|
'.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
|
|
84
84
|
'.fal_invalid{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}' +
|
|
85
|
-
'.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
|
|
86
|
-
'.fal_gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-top:12px}' +
|
|
87
|
-
'.fal_galleryItem{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden}' +
|
|
88
|
-
'.fal_galleryImg{width:100%;height:90px;object-fit:cover;display:block}' +
|
|
89
|
-
'.fal_galleryMeta{font-size:11px;color:var(--dsw-alias-label-secondary);padding:4px 6px}' +
|
|
90
|
-
'.fal_galleryEmpty{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:12px}'
|
|
85
|
+
'.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
|
|
91
86
|
const tagId = 'dsh-image-gen/settings-card.module.css'
|
|
92
87
|
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
|
|
93
88
|
const tag = document.createElement('style')
|
|
@@ -124,11 +119,6 @@ window.__ModuleLoader__.load({
|
|
|
124
119
|
save: 'fal_save',
|
|
125
120
|
select: 'fal_select',
|
|
126
121
|
sep: 'fal_sep',
|
|
127
|
-
gallery: 'fal_gallery',
|
|
128
|
-
galleryItem: 'fal_galleryItem',
|
|
129
|
-
galleryImg: 'fal_galleryImg',
|
|
130
|
-
galleryMeta: 'fal_galleryMeta',
|
|
131
|
-
galleryEmpty: 'fal_galleryEmpty',
|
|
132
122
|
}
|
|
133
123
|
|
|
134
124
|
// -------------------------------------------------------------- chrome
|
|
@@ -136,7 +126,7 @@ window.__ModuleLoader__.load({
|
|
|
136
126
|
function PluginSettingsCard(props) {
|
|
137
127
|
// Карточка живёт в собственном разделе настроек и на странице одна, так
|
|
138
128
|
// что открыта сразу: свёрнутая выглядела бы как пустая страница.
|
|
139
|
-
const [open, setOpen] = react.useState(
|
|
129
|
+
const [open, setOpen] = react.useState(props.defaultOpen ?? true)
|
|
140
130
|
const { state } = props
|
|
141
131
|
// Раньше здесь было `return null`, и раздел оказывался пустым, если хост
|
|
142
132
|
// ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
|
|
@@ -464,7 +454,17 @@ window.__ModuleLoader__.load({
|
|
|
464
454
|
const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
465
455
|
|
|
466
456
|
/** Field specs in display order. */
|
|
467
|
-
const booleanField = (
|
|
457
|
+
const booleanField = (field) => ({
|
|
458
|
+
field,
|
|
459
|
+
type: 'boolean',
|
|
460
|
+
format: (value) => (typeof value === 'boolean' ? String(value) : (value === 'true' || value === 'false' ? value : '')),
|
|
461
|
+
parse: (text) => {
|
|
462
|
+
if (text === '') return { kind: 'clear' }
|
|
463
|
+
if (text === 'true') return { kind: 'set', value: true }
|
|
464
|
+
if (text === 'false') return { kind: 'set', value: false }
|
|
465
|
+
return void 0
|
|
466
|
+
},
|
|
467
|
+
})
|
|
468
468
|
const STYLE_PRESET_OPTIONS = ['', 'cinematic', 'anime', 'digital_art', 'photographic', 'fantasy', 'isometric', 'cyberpunk', 'watercolor', 'origami', 'oil_painting', 'pixel_art', 'logo']
|
|
469
469
|
|
|
470
470
|
const FIELDS = [
|
|
@@ -511,7 +511,6 @@ window.__ModuleLoader__.load({
|
|
|
511
511
|
{ field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
|
|
512
512
|
{ field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
|
|
513
513
|
{ field: 'cacheTtlDays', spec: numberField('cacheTtlDays'), kind: 'number', labelKey: 'f.cacheTtlDays', hintKey: 'f.cacheTtlDaysHint', placeholderKey: 'p.cacheTtlDays' },
|
|
514
|
-
{ field: 'maxHistoryEntries', spec: numberField('maxHistoryEntries'), kind: 'number', labelKey: 'f.maxHistoryEntries', hintKey: 'f.maxHistoryEntriesHint', placeholderKey: 'p.maxHistoryEntries' },
|
|
515
514
|
]
|
|
516
515
|
|
|
517
516
|
function Field(props) {
|
|
@@ -539,66 +538,11 @@ window.__ModuleLoader__.load({
|
|
|
539
538
|
return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
|
|
540
539
|
}
|
|
541
540
|
|
|
542
|
-
/** Render the plugin settings card. */
|
|
543
|
-
/** Галерея истории генераций: тянет /history и рендерит миниатюры. */
|
|
544
|
-
function HistoryGallery(props) {
|
|
545
|
-
const [entries, setEntries] = react.useState([])
|
|
546
|
-
const [loading, setLoading] = react.useState(true)
|
|
547
|
-
const [filter, setFilter] = react.useState('')
|
|
548
|
-
react.useEffect(() => {
|
|
549
|
-
let cancelled = false
|
|
550
|
-
fetch('/dsh-image-gen/history')
|
|
551
|
-
.then((r) => r.json())
|
|
552
|
-
.then((data) => { if (!cancelled) { setEntries(Array.isArray(data) ? data : []); setLoading(false) } })
|
|
553
|
-
.catch(() => { if (!cancelled) setLoading(false) })
|
|
554
|
-
return () => { cancelled = true }
|
|
555
|
-
}, [])
|
|
556
|
-
const t = props.t
|
|
557
|
-
if (loading) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.loading') })
|
|
558
|
-
if (!entries.length) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.empty') })
|
|
559
|
-
const providers = [...new Set(entries.map((e) => e.provider).filter(Boolean))]
|
|
560
|
-
const filtered = filter ? entries.filter((e) => e.provider === filter) : entries
|
|
561
|
-
return react_jsx_runtime.jsxs('div', {
|
|
562
|
-
className: cssDefault.gallery,
|
|
563
|
-
children: [
|
|
564
|
-
react_jsx_runtime.jsx('select', {
|
|
565
|
-
className: cssDefault.select,
|
|
566
|
-
value: filter,
|
|
567
|
-
onChange: (e) => setFilter(e.target.value),
|
|
568
|
-
children: [
|
|
569
|
-
react_jsx_runtime.jsx('option', { value: '', children: t('history.all') }),
|
|
570
|
-
providers.map((p) => react_jsx_runtime.jsx('option', { value: p, children: p }, p)),
|
|
571
|
-
],
|
|
572
|
-
}),
|
|
573
|
-
filtered.map((e) =>
|
|
574
|
-
react_jsx_runtime.jsxs('a', {
|
|
575
|
-
className: cssDefault.galleryItem,
|
|
576
|
-
key: e.path,
|
|
577
|
-
href: e.thumbnailUrl || '#',
|
|
578
|
-
target: '_blank',
|
|
579
|
-
rel: 'noreferrer',
|
|
580
|
-
children: [
|
|
581
|
-
e.thumbnailUrl ? react_jsx_runtime.jsx('img', { className: cssDefault.galleryImg, src: e.thumbnailUrl, alt: e.prompt || '', loading: 'lazy' }) : null,
|
|
582
|
-
react_jsx_runtime.jsx('div', { className: cssDefault.galleryMeta, children: `${e.provider || ''} · ${e.size || ''}` }),
|
|
583
|
-
e.seed !== undefined ? react_jsx_runtime.jsx('button', {
|
|
584
|
-
type: 'button',
|
|
585
|
-
className: 'fal-repeat',
|
|
586
|
-
onClick: (ev) => {
|
|
587
|
-
ev.preventDefault()
|
|
588
|
-
ev.stopPropagation()
|
|
589
|
-
try { navigator.clipboard.writeText(String(e.seed)) } catch (err) { /* нет доступа к буферу */ }
|
|
590
|
-
},
|
|
591
|
-
}, `seed ${e.seed}`) : null,
|
|
592
|
-
],
|
|
593
|
-
}),
|
|
594
|
-
),
|
|
595
|
-
],
|
|
596
|
-
})
|
|
597
|
-
}
|
|
598
|
-
|
|
599
541
|
function FalSettingsCard(props) {
|
|
600
|
-
const
|
|
601
|
-
const state = props.useFalSettingsCard
|
|
542
|
+
const t = props.t || ((k) => k)
|
|
543
|
+
const state = (typeof props.useFalSettingsCard === 'function'
|
|
544
|
+
? props.useFalSettingsCard((snapshot) => snapshot)
|
|
545
|
+
: null) || props.state || { available: true, writable: true, provider: { text: 'fal' } }
|
|
602
546
|
const disabled = !state.writable
|
|
603
547
|
const currentProvider = (state.provider && state.provider.text) || 'fal'
|
|
604
548
|
const visibleFields = FIELDS.filter((entry) => !entry.when || entry.when.includes(currentProvider))
|
|
@@ -616,7 +560,7 @@ window.__ModuleLoader__.load({
|
|
|
616
560
|
onSave: props.save,
|
|
617
561
|
onDiscard: props.discard,
|
|
618
562
|
children: [
|
|
619
|
-
react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: t('settings.credentialHint') }),
|
|
563
|
+
react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'credHint', children: t('settings.credentialHint') }),
|
|
620
564
|
visibleFields.map((entry) =>
|
|
621
565
|
react_jsx_runtime.jsx(
|
|
622
566
|
Field,
|
|
@@ -625,15 +569,12 @@ window.__ModuleLoader__.load({
|
|
|
625
569
|
state: state[entry.field] ?? { text: '', overridden: false, invalid: false },
|
|
626
570
|
fieldProps,
|
|
627
571
|
t,
|
|
628
|
-
onEdit: (text) => props.edit(entry.field, text),
|
|
629
|
-
onReset: () => props.resetField(entry.field),
|
|
572
|
+
onEdit: (text) => props.edit && props.edit(entry.field, text),
|
|
573
|
+
onReset: () => props.resetField && props.resetField(entry.field),
|
|
630
574
|
},
|
|
631
575
|
entry.field,
|
|
632
576
|
),
|
|
633
577
|
),
|
|
634
|
-
react_jsx_runtime.jsx('div', { className: cssDefault.sep, key: 'hsep' }),
|
|
635
|
-
react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'hlabel', children: t('history.title') }),
|
|
636
|
-
react_jsx_runtime.jsx(HistoryGallery, { t, key: 'history' }),
|
|
637
578
|
],
|
|
638
579
|
})
|
|
639
580
|
}
|
|
@@ -680,6 +621,53 @@ window.__ModuleLoader__.load({
|
|
|
680
621
|
'settings.saving': 'Saving…',
|
|
681
622
|
'settings.saveFailed': 'Save failed',
|
|
682
623
|
'f.inherit': 'Inherit (default)',
|
|
624
|
+
'f.enabled': 'Enabled',
|
|
625
|
+
'f.enabledHint': 'Master switch: enable or disable the generate_image tool.',
|
|
626
|
+
'f.needFal': 'requires FAL API key',
|
|
627
|
+
'f.needCustom': 'requires custom endpoint URL',
|
|
628
|
+
'f.needCodex': 'uses Codex subscription session',
|
|
629
|
+
'f.needGrok': 'uses Grok subscription session',
|
|
630
|
+
'f.needLocal': 'connects to local ComfyUI/A1111',
|
|
631
|
+
'f.needSeedream': 'requires SeaDream API key',
|
|
632
|
+
'f.needGemini': 'requires Gemini API key',
|
|
633
|
+
'f.needReplicate': 'requires Replicate API token',
|
|
634
|
+
'f.replicateModel': 'Model (Replicate)',
|
|
635
|
+
'f.replicateModelHint': 'Replicate model identifier, e.g. black-forest-labs/flux-schnell.',
|
|
636
|
+
'f.replicateKeyEnv': 'API key reference (Replicate)',
|
|
637
|
+
'f.replicateKeyEnvHint': 'Credential reference / env var holding the Replicate API token.',
|
|
638
|
+
'p.replicateModel': 'Example: black-forest-labs/flux-schnell',
|
|
639
|
+
'p.replicateKeyEnv': 'Example: REPLICATE_API_TOKEN',
|
|
640
|
+
'f.seedreamModel': 'Model (SeaDream)',
|
|
641
|
+
'f.seedreamModelHint': 'SeaDream model identifier.',
|
|
642
|
+
'f.seedreamKeyEnv': 'API key reference (SeaDream)',
|
|
643
|
+
'f.seedreamKeyEnvHint': 'Credential reference / env var holding the SeaDream API key.',
|
|
644
|
+
'f.seedreamBaseURL': 'Base URL (SeaDream)',
|
|
645
|
+
'f.seedreamBaseURLHint': 'SeaDream endpoint URL (Volcengine Ark API).',
|
|
646
|
+
'p.seedreamModel': 'Example: Doubao-SeaDream-3.0',
|
|
647
|
+
'p.seedreamKeyEnv': 'Example: SEADREAM_API_KEY',
|
|
648
|
+
'p.seedreamBaseURL': 'Example: https://ark.cn-beijing.volces.com/api/v3',
|
|
649
|
+
'f.geminiModel': 'Model (Gemini)',
|
|
650
|
+
'f.geminiModelHint': 'Gemini Imagen model identifier.',
|
|
651
|
+
'f.geminiKeyEnv': 'API key reference (Gemini)',
|
|
652
|
+
'f.geminiKeyEnvHint': 'Credential reference / env var holding the Gemini API key.',
|
|
653
|
+
'p.geminiModel': 'Example: imagen-3.0-generate-002',
|
|
654
|
+
'p.geminiKeyEnv': 'Example: GEMINI_API_KEY',
|
|
655
|
+
'f.localComfyUrl': 'ComfyUI URL',
|
|
656
|
+
'f.localComfyUrlHint': 'Base URL of local ComfyUI server.',
|
|
657
|
+
'p.localComfyUrl': 'Example: http://127.0.0.1:8188',
|
|
658
|
+
'f.localA1111Url': 'Automatic1111 URL',
|
|
659
|
+
'f.localA1111UrlHint': 'Base URL of local Automatic1111 / SD WebUI.',
|
|
660
|
+
'p.localA1111Url': 'Example: http://127.0.0.1:7860',
|
|
661
|
+
'f.stylePreset': 'Default style preset',
|
|
662
|
+
'f.stylePresetHint': 'Style preset automatically applied if the tool call does not specify one.',
|
|
663
|
+
'f.enableLlmEnhancer': 'LLM prompt enhancer',
|
|
664
|
+
'f.enableLlmEnhancerHint': 'Automatically refine and expand prompts before generation using session LLM.',
|
|
665
|
+
'f.cacheTtlDays': 'Cache retention (days)',
|
|
666
|
+
'f.cacheTtlDaysHint': 'How many days downloaded images are stored in cache before cleanup.',
|
|
667
|
+
'p.cacheTtlDays': 'Example: 7',
|
|
668
|
+
'f.maxHistoryEntries': 'Max history entries',
|
|
669
|
+
'f.maxHistoryEntriesHint': 'Maximum number of items shown in generation history gallery.',
|
|
670
|
+
'p.maxHistoryEntries': 'Example: 50',
|
|
683
671
|
'f.provider': 'Provider',
|
|
684
672
|
'f.providerHint': 'fal — the FAL queue configured below. custom — any OpenAI-compatible API, configured further down. '
|
|
685
673
|
+ 'codex and grok — through a subscription you are logged into, no key needed here.',
|
|
@@ -743,6 +731,53 @@ window.__ModuleLoader__.load({
|
|
|
743
731
|
'settings.saving': 'Сохранение…',
|
|
744
732
|
'settings.saveFailed': 'Не удалось сохранить',
|
|
745
733
|
'f.inherit': 'Унаследовать (по умолчанию)',
|
|
734
|
+
'f.enabled': 'Включено',
|
|
735
|
+
'f.enabledHint': 'Главный переключатель: включить или отключить инструмент generate_image.',
|
|
736
|
+
'f.needFal': 'требуется ключ API FAL',
|
|
737
|
+
'f.needCustom': 'требуется базовый URL API',
|
|
738
|
+
'f.needCodex': 'использует сессию подписки Codex',
|
|
739
|
+
'f.needGrok': 'использует сессию подписки Grok',
|
|
740
|
+
'f.needLocal': 'подключается к локальному ComfyUI/A1111',
|
|
741
|
+
'f.needSeedream': 'требуется ключ API SeaDream',
|
|
742
|
+
'f.needGemini': 'требуется ключ API Gemini',
|
|
743
|
+
'f.needReplicate': 'требуется токен API Replicate',
|
|
744
|
+
'f.replicateModel': 'Модель (Replicate)',
|
|
745
|
+
'f.replicateModelHint': 'Идентификатор модели Replicate, например black-forest-labs/flux-schnell.',
|
|
746
|
+
'f.replicateKeyEnv': 'Ссылка на ключ (Replicate)',
|
|
747
|
+
'f.replicateKeyEnvHint': 'Имя credential-ссылки / переменной окружения с токеном Replicate.',
|
|
748
|
+
'p.replicateModel': 'Пример: black-forest-labs/flux-schnell',
|
|
749
|
+
'p.replicateKeyEnv': 'Пример: REPLICATE_API_TOKEN',
|
|
750
|
+
'f.seedreamModel': 'Модель (SeaDream)',
|
|
751
|
+
'f.seedreamModelHint': 'Идентификатор модели SeaDream.',
|
|
752
|
+
'f.seedreamKeyEnv': 'Ссылка на ключ (SeaDream)',
|
|
753
|
+
'f.seedreamKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом SeaDream.',
|
|
754
|
+
'f.seedreamBaseURL': 'Базовый URL (SeaDream)',
|
|
755
|
+
'f.seedreamBaseURLHint': 'Базовый URL API SeaDream (Volcengine Ark).',
|
|
756
|
+
'p.seedreamModel': 'Пример: Doubao-SeaDream-3.0',
|
|
757
|
+
'p.seedreamKeyEnv': 'Пример: SEADREAM_API_KEY',
|
|
758
|
+
'p.seedreamBaseURL': 'Пример: https://ark.cn-beijing.volces.com/api/v3',
|
|
759
|
+
'f.geminiModel': 'Модель (Gemini)',
|
|
760
|
+
'f.geminiModelHint': 'Идентификатор модели Gemini Imagen.',
|
|
761
|
+
'f.geminiKeyEnv': 'Ссылка на ключ (Gemini)',
|
|
762
|
+
'f.geminiKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом Gemini.',
|
|
763
|
+
'p.geminiModel': 'Пример: imagen-3.0-generate-002',
|
|
764
|
+
'p.geminiKeyEnv': 'Пример: GEMINI_API_KEY',
|
|
765
|
+
'f.localComfyUrl': 'URL ComfyUI',
|
|
766
|
+
'f.localComfyUrlHint': 'Базовый URL локального сервера ComfyUI.',
|
|
767
|
+
'p.localComfyUrl': 'Пример: http://127.0.0.1:8188',
|
|
768
|
+
'f.localA1111Url': 'URL Automatic1111',
|
|
769
|
+
'f.localA1111UrlHint': 'Базовый URL локального SD WebUI / Automatic1111.',
|
|
770
|
+
'p.localA1111Url': 'Пример: http://127.0.0.1:7860',
|
|
771
|
+
'f.stylePreset': 'Стилевой пресет по умолчанию',
|
|
772
|
+
'f.stylePresetHint': 'Пресет стилизации, применяемый если инструмент вызван без указания стиля.',
|
|
773
|
+
'f.enableLlmEnhancer': 'Улучшение промпта через LLM',
|
|
774
|
+
'f.enableLlmEnhancerHint': 'Автоматически детализировать и улучшать промпты через чат-модель перед генерацией.',
|
|
775
|
+
'f.cacheTtlDays': 'Срок хранения кэша (дней)',
|
|
776
|
+
'f.cacheTtlDaysHint': 'Сколько дней скачанные картинки хранятся в кэше до очистки.',
|
|
777
|
+
'p.cacheTtlDays': 'Пример: 7',
|
|
778
|
+
'f.maxHistoryEntries': 'Максимум записей в истории',
|
|
779
|
+
'f.maxHistoryEntriesHint': 'Максимальное количество картинок в галерее истории генераций.',
|
|
780
|
+
'p.maxHistoryEntries': 'Пример: 50',
|
|
746
781
|
'f.provider': 'Провайдер',
|
|
747
782
|
'f.providerHint': 'fal — очередь FAL, настроенная ниже. custom — любой OpenAI-совместимый API, настраивается дальше. '
|
|
748
783
|
+ 'codex и grok — через подписку, в которую вы вошли; ключ здесь не нужен.',
|
|
@@ -1226,36 +1261,40 @@ function FalImageCard(props) {
|
|
|
1226
1261
|
// Запасной путь: слот объявлен пакетом настроек ядра. Если в сборке его
|
|
1227
1262
|
// нет, регистрация бросает — тогда остаётся прежний раздел в боковом
|
|
1228
1263
|
// списке, чтобы настройки не пропали.
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
}
|
|
1244
|
-
if (!placedInPluginsTab) {
|
|
1245
|
-
ctx.slots.inject('settings.section', () =>
|
|
1246
|
-
ctx.slots.register(
|
|
1247
|
-
{
|
|
1248
|
-
name: 'settings.section',
|
|
1249
|
-
id: '@goodandready/dsh-image-gen',
|
|
1250
|
-
order: 34,
|
|
1251
|
-
locale: NS,
|
|
1252
|
-
label: () => t('settings.navLabel'),
|
|
1253
|
-
inject: () => cardOnce().inject(),
|
|
1254
|
-
},
|
|
1255
|
-
FalSettingsCard,
|
|
1256
|
-
),
|
|
1257
|
-
)
|
|
1264
|
+
function registerFirst(ctx, candidates, component) {
|
|
1265
|
+
if (!ctx || !ctx.slots) return undefined
|
|
1266
|
+
for (const entry of candidates) {
|
|
1267
|
+
try {
|
|
1268
|
+
if (typeof ctx.slots?.has === 'function' && !ctx.slots.has(entry.name)) {
|
|
1269
|
+
continue
|
|
1270
|
+
}
|
|
1271
|
+
let ok = false
|
|
1272
|
+
ctx.slots.inject(entry.name, () => {
|
|
1273
|
+
ctx.slots.register(entry, component)
|
|
1274
|
+
ok = true
|
|
1275
|
+
})
|
|
1276
|
+
if (ok) return entry.name
|
|
1277
|
+
} catch {}
|
|
1258
1278
|
}
|
|
1279
|
+
return undefined
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
registerFirst(ctx, [
|
|
1283
|
+
{
|
|
1284
|
+
name: 'settings.plugin.item',
|
|
1285
|
+
key: SETTINGS_NS,
|
|
1286
|
+
locale: NS,
|
|
1287
|
+
inject: () => cardOnce().inject(),
|
|
1288
|
+
},
|
|
1289
|
+
{
|
|
1290
|
+
name: 'settings.section',
|
|
1291
|
+
id: '@goodandready/dsh-image-gen',
|
|
1292
|
+
order: 34,
|
|
1293
|
+
locale: NS,
|
|
1294
|
+
label: () => t('settings.navLabel'),
|
|
1295
|
+
inject: () => cardOnce().inject(),
|
|
1296
|
+
},
|
|
1297
|
+
], FalSettingsCard)
|
|
1259
1298
|
}
|
|
1260
1299
|
exports.apply = apply
|
|
1261
1300
|
exports.inject = inject
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// lib/cost-meter.js
|
|
2
|
+
// Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167)
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Standard pricing table per provider and resolution (USD per generation).
|
|
10
|
+
* Based on Fal.ai, OpenAI DALL-E 3/gpt-image-2, Seedream, Replicate rates.
|
|
11
|
+
*/
|
|
12
|
+
export const RATE_CARD = {
|
|
13
|
+
fal: {
|
|
14
|
+
'fal-ai/flux/schnell': 0.003,
|
|
15
|
+
'fal-ai/flux-2/klein/9b': 0.003,
|
|
16
|
+
'fal-ai/flux/dev': 0.025,
|
|
17
|
+
'fal-ai/flux-pro': 0.05,
|
|
18
|
+
'fal-ai/flux-pro/v1.1': 0.05,
|
|
19
|
+
'fal-ai/recraft-v3': 0.04,
|
|
20
|
+
'fal-ai/fast-sdxl': 0.002,
|
|
21
|
+
default: 0.025,
|
|
22
|
+
},
|
|
23
|
+
custom: {
|
|
24
|
+
'dall-e-3': 0.04,
|
|
25
|
+
'dall-e-3-hd': 0.08,
|
|
26
|
+
'dall-e-2': 0.02,
|
|
27
|
+
'gpt-image-2': 0.03,
|
|
28
|
+
default: 0.03,
|
|
29
|
+
},
|
|
30
|
+
seedream: {
|
|
31
|
+
'seedream-4.0': 0.015,
|
|
32
|
+
'seedream-3.0': 0.01,
|
|
33
|
+
default: 0.015,
|
|
34
|
+
},
|
|
35
|
+
replicate: {
|
|
36
|
+
'black-forest-labs/flux-schnell': 0.003,
|
|
37
|
+
'black-forest-labs/flux-dev': 0.025,
|
|
38
|
+
'stability-ai/sdxl': 0.01,
|
|
39
|
+
default: 0.02,
|
|
40
|
+
},
|
|
41
|
+
local: {
|
|
42
|
+
default: 0.0, // Self-hosted local ComfyUI / A1111 runs for free
|
|
43
|
+
},
|
|
44
|
+
codex: {
|
|
45
|
+
default: 0.0, // Handled via subscription
|
|
46
|
+
},
|
|
47
|
+
grok: {
|
|
48
|
+
default: 0.0, // Handled via subscription
|
|
49
|
+
},
|
|
50
|
+
gemini: {
|
|
51
|
+
default: 0.03,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Calculates estimated USD cost for a generation request.
|
|
57
|
+
*/
|
|
58
|
+
export function calculateGenerationCost({ provider = 'fal', model = '', size = '1024x1024', count = 1 } = {}) {
|
|
59
|
+
const p = String(provider || '').toLowerCase()
|
|
60
|
+
const provCard = RATE_CARD[p] || {}
|
|
61
|
+
const baseCost = provCard[model] ?? provCard.default ?? 0.02
|
|
62
|
+
|
|
63
|
+
// High-res multiplier (if > 1024x1024 or 2K/4K)
|
|
64
|
+
let sizeMult = 1.0
|
|
65
|
+
const sizeLower = String(size || '').toLowerCase()
|
|
66
|
+
if (sizeLower.includes('2048') || sizeLower.includes('1536') || sizeLower.includes('hd')) {
|
|
67
|
+
sizeMult = 1.5
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return +(baseCost * sizeMult * Math.max(1, count)).toFixed(4)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getSpendFilePath() {
|
|
74
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
75
|
+
return join(base, 'storages', 'dsh-image-gen-spend.json')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Loads daily spend record from storage.
|
|
80
|
+
*/
|
|
81
|
+
export function loadDailySpend() {
|
|
82
|
+
const filePath = getSpendFilePath()
|
|
83
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
86
|
+
const data = JSON.parse(raw)
|
|
87
|
+
if (data && data.date === today) {
|
|
88
|
+
return data
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
return { date: today, totalSpendUsd: 0, generations: 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Records an image generation expense.
|
|
96
|
+
*/
|
|
97
|
+
export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
98
|
+
const amount = Number(usdAmount) || 0
|
|
99
|
+
if (amount <= 0) return loadDailySpend()
|
|
100
|
+
|
|
101
|
+
const filePath = getSpendFilePath()
|
|
102
|
+
const current = loadDailySpend()
|
|
103
|
+
current.totalSpendUsd = +(current.totalSpendUsd + amount).toFixed(4)
|
|
104
|
+
current.generations += 1
|
|
105
|
+
current.lastUpdated = new Date().toISOString()
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(join(filePath, '..'), { recursive: true })
|
|
109
|
+
writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
// Non-fatal write failure
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Notify dsh-cost-meter if available in cordis context
|
|
115
|
+
try {
|
|
116
|
+
const costMeter = ctx && (ctx.get?.('dsh-cost-meter') || ctx['dsh-cost-meter'])
|
|
117
|
+
if (costMeter && typeof costMeter.recordSpend === 'function') {
|
|
118
|
+
costMeter.recordSpend({
|
|
119
|
+
source: 'dsh-image-gen',
|
|
120
|
+
cost: amount,
|
|
121
|
+
currency: 'USD',
|
|
122
|
+
meta,
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
return current
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Asserts that the generation does not exceed daily budget.
|
|
132
|
+
* @throws {Error} If daily budget is exceeded.
|
|
133
|
+
*/
|
|
134
|
+
export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
|
|
135
|
+
const budget = Number(dailyBudgetUsd) || 0
|
|
136
|
+
if (budget <= 0) return true // Budget 0 = unlimited
|
|
137
|
+
|
|
138
|
+
const current = loadDailySpend()
|
|
139
|
+
const projected = +(current.totalSpendUsd + estimatedCost).toFixed(4)
|
|
140
|
+
if (projected > budget) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${estimatedCost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
return true
|
|
146
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// lib/generation-cache.js
|
|
2
|
+
// Content-addressed disk cache for image generations with LRU eviction (#170)
|
|
3
|
+
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
9
|
+
|
|
10
|
+
function getCacheDir() {
|
|
11
|
+
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
12
|
+
return join(base, 'cache', 'image-gen')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Looks up an identical previous generation in content-addressed disk cache.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} hash - Deterministic sha256 hash
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @param {boolean} [options.force=false] - If true, bypass cache
|
|
21
|
+
* @returns {Object|null} Cached entry { bytes, mediaType, meta } or null
|
|
22
|
+
*/
|
|
23
|
+
export function getCachedGeneration(hash, { force = false } = {}) {
|
|
24
|
+
if (force || !hash) return null
|
|
25
|
+
|
|
26
|
+
const dir = getCacheDir()
|
|
27
|
+
const metaPath = join(dir, `${hash}.json`)
|
|
28
|
+
const dataPath = join(dir, `${hash}.bin`)
|
|
29
|
+
|
|
30
|
+
if (!existsSync(metaPath) || !existsSync(dataPath)) return null
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
34
|
+
const bytes = readFileSync(dataPath)
|
|
35
|
+
|
|
36
|
+
// Touch meta file for LRU update
|
|
37
|
+
meta.lastAccessed = Date.now()
|
|
38
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
bytes,
|
|
42
|
+
mediaType: meta.mediaType || 'image/png',
|
|
43
|
+
width: meta.width,
|
|
44
|
+
height: meta.height,
|
|
45
|
+
seed: meta.seed,
|
|
46
|
+
meta,
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Stores a generation result in content-addressed cache and performs LRU pruning if needed.
|
|
55
|
+
*/
|
|
56
|
+
export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', width, height, seed, meta = {} } = {}, maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
57
|
+
if (!hash || !bytes) return
|
|
58
|
+
|
|
59
|
+
const dir = getCacheDir()
|
|
60
|
+
try {
|
|
61
|
+
mkdirSync(dir, { recursive: true })
|
|
62
|
+
const metaPath = join(dir, `${hash}.json`)
|
|
63
|
+
const dataPath = join(dir, `${hash}.bin`)
|
|
64
|
+
|
|
65
|
+
const cacheMeta = {
|
|
66
|
+
...meta,
|
|
67
|
+
hash,
|
|
68
|
+
mediaType,
|
|
69
|
+
width,
|
|
70
|
+
height,
|
|
71
|
+
seed,
|
|
72
|
+
sizeBytes: bytes.length,
|
|
73
|
+
createdAt: Date.now(),
|
|
74
|
+
lastAccessed: Date.now(),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
|
|
78
|
+
writeFileSync(dataPath, Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
|
|
79
|
+
|
|
80
|
+
pruneCacheToLimit(maxBytes)
|
|
81
|
+
} catch {
|
|
82
|
+
// Non-fatal write failure
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Prunes the cache directory down to maxBytes using LRU (least recently accessed).
|
|
88
|
+
*/
|
|
89
|
+
export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
90
|
+
const dir = getCacheDir()
|
|
91
|
+
if (!existsSync(dir)) return
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const files = readdirSync(dir).filter((f) => f.endsWith('.json'))
|
|
95
|
+
const entries = []
|
|
96
|
+
let totalSize = 0
|
|
97
|
+
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
const metaPath = join(dir, file)
|
|
100
|
+
const stem = file.slice(0, -5)
|
|
101
|
+
const dataPath = join(dir, `${stem}.bin`)
|
|
102
|
+
try {
|
|
103
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
104
|
+
const binStat = existsSync(dataPath) ? statSync(dataPath) : { size: 0 }
|
|
105
|
+
const size = binStat.size + statSync(metaPath).size
|
|
106
|
+
totalSize += size
|
|
107
|
+
entries.push({
|
|
108
|
+
stem,
|
|
109
|
+
metaPath,
|
|
110
|
+
dataPath,
|
|
111
|
+
size,
|
|
112
|
+
lastAccessed: meta.lastAccessed || meta.createdAt || 0,
|
|
113
|
+
})
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (totalSize <= maxBytes) return
|
|
118
|
+
|
|
119
|
+
// Sort by lastAccessed ascending (oldest first)
|
|
120
|
+
entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (totalSize <= maxBytes) break
|
|
124
|
+
try {
|
|
125
|
+
if (existsSync(entry.metaPath)) unlinkSync(entry.metaPath)
|
|
126
|
+
if (existsSync(entry.dataPath)) unlinkSync(entry.dataPath)
|
|
127
|
+
totalSize -= entry.size
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,12 @@ import {
|
|
|
50
50
|
} from './providers.js'
|
|
51
51
|
|
|
52
52
|
import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from './resolve-image.js'
|
|
53
|
+
import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
|
|
54
|
+
import { executeWithQualityGate } from './quality-gate.js'
|
|
55
|
+
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from './cost-meter.js'
|
|
56
|
+
import { trackAndAssertLoopGuard, resetLoopGuard } from './loop-guard.js'
|
|
57
|
+
import { maskApiKey, sanitizeErrorAndLogs } from './security.js'
|
|
58
|
+
import { getCachedGeneration, setCachedGeneration } from './generation-cache.js'
|
|
53
59
|
import {
|
|
54
60
|
extractDesignTokens,
|
|
55
61
|
generateCssGradient,
|
|
@@ -322,6 +328,22 @@ export const Config = z.object({
|
|
|
322
328
|
.boolean()
|
|
323
329
|
.description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
324
330
|
.default(false),
|
|
331
|
+
qualityGate: z
|
|
332
|
+
.boolean()
|
|
333
|
+
.description('Automatic quality gate with silent re-roll for blank or defective frames. On by default.')
|
|
334
|
+
.default(true),
|
|
335
|
+
dailyBudgetUsd: z
|
|
336
|
+
.number()
|
|
337
|
+
.description('Daily image generation spending budget in USD. 0 (default) disables limit enforcement.')
|
|
338
|
+
.default(0),
|
|
339
|
+
loopGuardLimit: z
|
|
340
|
+
.number()
|
|
341
|
+
.description('Maximum consecutive image generations per session without user interaction. 0 disables protection.')
|
|
342
|
+
.default(3),
|
|
343
|
+
diskCache: z
|
|
344
|
+
.boolean()
|
|
345
|
+
.description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
|
|
346
|
+
.default(true),
|
|
325
347
|
})
|
|
326
348
|
|
|
327
349
|
/** Keep a file stem safe for the filesystem. */
|
|
@@ -729,6 +751,14 @@ export function apply(ctx, config) {
|
|
|
729
751
|
type: 'number',
|
|
730
752
|
description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
|
|
731
753
|
},
|
|
754
|
+
quality_gate: {
|
|
755
|
+
type: 'boolean',
|
|
756
|
+
description: 'Optional override to enable/disable automated quality gate and defect re-rolling for this call.',
|
|
757
|
+
},
|
|
758
|
+
force: {
|
|
759
|
+
type: 'boolean',
|
|
760
|
+
description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
|
|
761
|
+
},
|
|
732
762
|
},
|
|
733
763
|
output: {
|
|
734
764
|
schema: {
|
|
@@ -789,6 +819,15 @@ export function apply(ctx, config) {
|
|
|
789
819
|
timeoutMs: config.timeoutMs + 30000,
|
|
790
820
|
async execute(args, exec) {
|
|
791
821
|
const cfg = live()
|
|
822
|
+
|
|
823
|
+
// #168: Fail-fast loop guard protecting against runaway agent loops
|
|
824
|
+
const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
|
|
825
|
+
if (cfg.loopGuardLimit > 0) {
|
|
826
|
+
trackAndAssertLoopGuard(sessionId, {
|
|
827
|
+
limit: cfg.loopGuardLimit,
|
|
828
|
+
prompt: args.prompt,
|
|
829
|
+
})
|
|
830
|
+
}
|
|
792
831
|
if (cfg.enabled === false) {
|
|
793
832
|
throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
|
|
794
833
|
}
|
|
@@ -814,8 +853,27 @@ export function apply(ctx, config) {
|
|
|
814
853
|
const effectiveStyle = args.style || cfg.stylePreset
|
|
815
854
|
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
816
855
|
const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
817
|
-
|
|
856
|
+
|
|
857
|
+
// #165: Negative prompt sanitizer for diffusion models
|
|
858
|
+
let effectiveNegative = styleInfo.negativePrompt
|
|
859
|
+
if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
|
|
860
|
+
effectiveNegative = sanitizeNegativePrompt({
|
|
861
|
+
positivePrompt: effectivePrompt,
|
|
862
|
+
userNegative: effectiveNegative,
|
|
863
|
+
autoInjectDefects: true,
|
|
864
|
+
})
|
|
865
|
+
}
|
|
818
866
|
const effectiveGuidance = styleInfo.guidanceScale
|
|
867
|
+
|
|
868
|
+
// #167: Assert daily budget availability before calling API
|
|
869
|
+
const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
|
|
870
|
+
const estimatedCost = calculateGenerationCost({
|
|
871
|
+
provider,
|
|
872
|
+
model: cfg.model || cfg.customModel,
|
|
873
|
+
size,
|
|
874
|
+
count: totalCount,
|
|
875
|
+
})
|
|
876
|
+
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
819
877
|
const providers = makeProviders(
|
|
820
878
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
821
879
|
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
|
|
@@ -886,12 +944,24 @@ export function apply(ctx, config) {
|
|
|
886
944
|
|
|
887
945
|
const cacheHash = computeGenerationHash({
|
|
888
946
|
provider,
|
|
889
|
-
model: cfg.model,
|
|
947
|
+
model: cfg.model || cfg.customModel,
|
|
890
948
|
prompt: promptArg,
|
|
891
949
|
seed: gen.seed,
|
|
892
950
|
size: args.image_size || cfg.defaultSize,
|
|
893
951
|
style: args.style || cfg.stylePreset,
|
|
894
952
|
})
|
|
953
|
+
|
|
954
|
+
// #170: Store in content-addressed disk cache
|
|
955
|
+
if (cfg.diskCache !== false) {
|
|
956
|
+
setCachedGeneration(cacheHash, {
|
|
957
|
+
bytes,
|
|
958
|
+
mediaType,
|
|
959
|
+
width: gen.width || attachment.width,
|
|
960
|
+
height: gen.height || attachment.height,
|
|
961
|
+
seed: gen.seed,
|
|
962
|
+
meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
|
|
963
|
+
})
|
|
964
|
+
}
|
|
895
965
|
const entry = {
|
|
896
966
|
path: filePath,
|
|
897
967
|
prompt: promptArg,
|
|
@@ -915,6 +985,12 @@ export function apply(ctx, config) {
|
|
|
915
985
|
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
916
986
|
await writeHistory(current)
|
|
917
987
|
|
|
988
|
+
// #167: Record spend in cost meter
|
|
989
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
990
|
+
ctx,
|
|
991
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
992
|
+
})
|
|
993
|
+
|
|
918
994
|
return {
|
|
919
995
|
path: filePath,
|
|
920
996
|
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
@@ -940,10 +1016,81 @@ export function apply(ctx, config) {
|
|
|
940
1016
|
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
941
1017
|
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
942
1018
|
const order = fallbackOrder(provider)
|
|
943
|
-
const
|
|
1019
|
+
const qgEnabled = args.quality_gate ?? cfg.qualityGate
|
|
1020
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|
|
1021
|
+
k,
|
|
1022
|
+
async (s, p) => {
|
|
1023
|
+
const { generated, qualityReport } = await executeWithQualityGate(
|
|
1024
|
+
(seedVal) => one(seedVal, k, p),
|
|
1025
|
+
{
|
|
1026
|
+
initialSeed: s,
|
|
1027
|
+
enabled: qgEnabled,
|
|
1028
|
+
ctx,
|
|
1029
|
+
prompt: p,
|
|
1030
|
+
}
|
|
1031
|
+
)
|
|
1032
|
+
return { ...generated, qualityReport }
|
|
1033
|
+
}
|
|
1034
|
+
]))
|
|
944
1035
|
const images = []
|
|
945
1036
|
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
946
1037
|
const checkCache = async (seedVal, promptVal) => {
|
|
1038
|
+
const h = computeGenerationHash({
|
|
1039
|
+
provider,
|
|
1040
|
+
model: cfg.model || cfg.customModel,
|
|
1041
|
+
prompt: promptVal,
|
|
1042
|
+
seed: seedVal,
|
|
1043
|
+
size: args.image_size || cfg.defaultSize,
|
|
1044
|
+
style: args.style || cfg.stylePreset,
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
// #170: Content-addressed disk cache check (<50ms return)
|
|
1048
|
+
if (cfg.diskCache !== false && !args.force) {
|
|
1049
|
+
const diskHit = getCachedGeneration(h, { force: args.force })
|
|
1050
|
+
if (diskHit) {
|
|
1051
|
+
const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
|
|
1052
|
+
const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
|
|
1053
|
+
const name = `${stem}.${extension}`
|
|
1054
|
+
const attachment = await ctx.attachments.saveImage({
|
|
1055
|
+
data: new Uint8Array(diskHit.bytes),
|
|
1056
|
+
mediaType: diskHit.mediaType,
|
|
1057
|
+
name,
|
|
1058
|
+
})
|
|
1059
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1060
|
+
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
1061
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
1062
|
+
await mkdir(outDir, { recursive: true })
|
|
1063
|
+
const filePath = path.join(outDir, name)
|
|
1064
|
+
await writeFile(filePath, diskHit.bytes)
|
|
1065
|
+
|
|
1066
|
+
const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
|
|
1067
|
+
+ '&mt=' + encodeURIComponent(attachment.mediaType)
|
|
1068
|
+
+ '&b=' + encodeURIComponent(String(attachment.bytes))
|
|
1069
|
+
+ '&w=' + encodeURIComponent(String(attachment.width))
|
|
1070
|
+
+ '&h=' + encodeURIComponent(String(attachment.height))
|
|
1071
|
+
|
|
1072
|
+
return {
|
|
1073
|
+
path: filePath,
|
|
1074
|
+
url: localUrl,
|
|
1075
|
+
width: diskHit.width || attachment.width,
|
|
1076
|
+
height: diskHit.height || attachment.height,
|
|
1077
|
+
seed: seedVal,
|
|
1078
|
+
prompt: promptVal,
|
|
1079
|
+
cost: 0,
|
|
1080
|
+
fromCache: true,
|
|
1081
|
+
format: diskHit.mediaType.replace('image/', ''),
|
|
1082
|
+
attachment: {
|
|
1083
|
+
attachmentId: attachment.attachmentId,
|
|
1084
|
+
mediaType: attachment.mediaType,
|
|
1085
|
+
bytes: attachment.bytes,
|
|
1086
|
+
width: attachment.width,
|
|
1087
|
+
height: attachment.height,
|
|
1088
|
+
name: attachment.name,
|
|
1089
|
+
},
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
947
1094
|
if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
|
|
948
1095
|
const h = computeGenerationHash({
|
|
949
1096
|
provider,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// lib/loop-guard.js
|
|
2
|
+
// Fail-fast loop guard protecting against runaway agent retry loops (#168)
|
|
3
|
+
|
|
4
|
+
/** In-memory session tracking map: sessionId -> { count: number, lastPrompt: string, lastTimestamp: number } */
|
|
5
|
+
const sessionStates = new Map()
|
|
6
|
+
|
|
7
|
+
/** Default maximum consecutive generations without user interaction */
|
|
8
|
+
export const DEFAULT_MAX_CONSECUTIVE_GENERATIONS = 3
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Checks and records a generation turn for a session.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} sessionId - Conversation session identifier
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {number} [options.limit=3] - Maximum allowed calls
|
|
16
|
+
* @param {string} [options.prompt=''] - Current prompt
|
|
17
|
+
* @param {boolean} [options.isUserTurn=false] - Whether the user just submitted a turn (resets counter)
|
|
18
|
+
* @throws {Error} If consecutive generation limit is exceeded.
|
|
19
|
+
*/
|
|
20
|
+
export function trackAndAssertLoopGuard(sessionId, {
|
|
21
|
+
limit = DEFAULT_MAX_CONSECUTIVE_GENERATIONS,
|
|
22
|
+
prompt = '',
|
|
23
|
+
isUserTurn = false,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const sid = String(sessionId || 'default_session')
|
|
26
|
+
|
|
27
|
+
if (isUserTurn) {
|
|
28
|
+
sessionStates.delete(sid)
|
|
29
|
+
return { count: 0, allowed: true }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
33
|
+
const maxLimit = Math.max(1, limit)
|
|
34
|
+
|
|
35
|
+
if (current.count >= maxLimit) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Generation loop limit reached (${current.count}/${maxLimit} consecutive generations). ` +
|
|
38
|
+
`To prevent runaway API billing, please stop retrying and ask the user to clarify or confirm the image prompt.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
current.count += 1
|
|
43
|
+
current.lastPrompt = prompt
|
|
44
|
+
current.lastTimestamp = Date.now()
|
|
45
|
+
sessionStates.set(sid, current)
|
|
46
|
+
|
|
47
|
+
return { count: current.count, remaining: maxLimit - current.count, allowed: true }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resets loop guard counter for a session.
|
|
52
|
+
*/
|
|
53
|
+
export function resetLoopGuard(sessionId) {
|
|
54
|
+
const sid = String(sessionId || 'default_session')
|
|
55
|
+
sessionStates.delete(sid)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Gets current state for debugging/testing.
|
|
60
|
+
*/
|
|
61
|
+
export function getLoopGuardState(sessionId) {
|
|
62
|
+
const sid = String(sessionId || 'default_session')
|
|
63
|
+
return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
64
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// lib/negative-sanitizer.js
|
|
2
|
+
// Negative prompt sanitizer & smart enhancer for diffusion models (#165)
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standard defect keywords grouped by category.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_DEFECT_TERMS = [
|
|
8
|
+
'blurry',
|
|
9
|
+
'low quality',
|
|
10
|
+
'worst quality',
|
|
11
|
+
'deformed',
|
|
12
|
+
'disfigured',
|
|
13
|
+
'bad anatomy',
|
|
14
|
+
'extra limbs',
|
|
15
|
+
'missing limbs',
|
|
16
|
+
'poorly drawn face',
|
|
17
|
+
'poorly drawn hands',
|
|
18
|
+
'missing fingers',
|
|
19
|
+
'extra fingers',
|
|
20
|
+
'watermark',
|
|
21
|
+
'signature',
|
|
22
|
+
'jpeg artifacts',
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Models that explicitly benefit from negative prompts (SD 1.5, SD 2.x, SDXL, ComfyUI, etc.).
|
|
27
|
+
* FLUX, DALL-E, and Ideogram generally ignore or misinterpret negative prompts.
|
|
28
|
+
*/
|
|
29
|
+
export function supportsNegativePrompt(provider, model) {
|
|
30
|
+
const p = String(provider || '').toLowerCase()
|
|
31
|
+
const m = String(model || '').toLowerCase()
|
|
32
|
+
|
|
33
|
+
if (p === 'fal' && (m.includes('flux') || m.includes('recraft'))) return false
|
|
34
|
+
if (p === 'codex' || p === 'openai' || m.includes('dall-e') || m.includes('gpt-image')) return false
|
|
35
|
+
if (p === 'grok' || m.includes('aurora')) return false
|
|
36
|
+
|
|
37
|
+
// Diffusion / Comfy / A1111 / SDXL / Seedream / Replicate (SD variants)
|
|
38
|
+
if (p === 'local' || p === 'seedream') return true
|
|
39
|
+
if (m.includes('stable-diffusion') || m.includes('sdxl') || m.includes('sd-') || m.includes('illustrious') || m.includes('pony')) return true
|
|
40
|
+
if (p === 'custom' && (m.includes('sd') || m.includes('diffusion'))) return true
|
|
41
|
+
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clean and split prompt into normalized lowercase terms.
|
|
47
|
+
*/
|
|
48
|
+
export function splitTerms(promptStr) {
|
|
49
|
+
if (!promptStr || typeof promptStr !== 'string') return []
|
|
50
|
+
return promptStr
|
|
51
|
+
.split(/[,;\n]+/)
|
|
52
|
+
.map((t) => t.trim())
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeWord(word) {
|
|
57
|
+
if (!word) return ''
|
|
58
|
+
return word
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/(y|ies|ing|ed|s)$/, '')
|
|
61
|
+
.replace(/(.)\1+$/, '$1') // e.g. blurr -> blur
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks whether a negative term contradicts a positive prompt intent.
|
|
66
|
+
* E.g. If user asks for "grainy vintage 35mm film", don't inject "grain" or "vintage".
|
|
67
|
+
*/
|
|
68
|
+
export function contradictsPositive(negativeTerm, positivePrompt) {
|
|
69
|
+
const pos = String(positivePrompt || '').toLowerCase()
|
|
70
|
+
const neg = String(negativeTerm || '').toLowerCase().trim()
|
|
71
|
+
|
|
72
|
+
if (!pos || !neg) return false
|
|
73
|
+
|
|
74
|
+
// Check direct inclusion or overlap
|
|
75
|
+
const posWords = pos.match(/[a-z0-9]+/g) || []
|
|
76
|
+
const negWords = neg.match(/[a-z0-9]+/g) || []
|
|
77
|
+
|
|
78
|
+
// Stylistic keywords where positive intent must be respected
|
|
79
|
+
const styleKeywords = [
|
|
80
|
+
'blur', 'blurry', 'grain', 'grainy', 'vintage', 'noise', 'noisy',
|
|
81
|
+
'monochrome', 'grayscale', 'sketch', 'drawing', 'dark', 'shadowy',
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for (const nw of negWords) {
|
|
85
|
+
const nwNorm = normalizeWord(nw)
|
|
86
|
+
for (const styleKw of styleKeywords) {
|
|
87
|
+
if (nw === styleKw || nwNorm === normalizeWord(styleKw)) {
|
|
88
|
+
// Look if positive prompt contains this style word or normalized form
|
|
89
|
+
if (posWords.some((pw) => pw === styleKw || normalizeWord(pw) === nwNorm)) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Sanitize, merge, and deduplicate negative prompt with default safety defect terms.
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} options
|
|
103
|
+
* @param {string} [options.positivePrompt]
|
|
104
|
+
* @param {string} [options.userNegative]
|
|
105
|
+
* @param {string[]} [options.additionalTerms]
|
|
106
|
+
* @param {boolean} [options.autoInjectDefects=true]
|
|
107
|
+
* @returns {string} Sanitized negative prompt
|
|
108
|
+
*/
|
|
109
|
+
export function sanitizeNegativePrompt({
|
|
110
|
+
positivePrompt = '',
|
|
111
|
+
userNegative = '',
|
|
112
|
+
additionalTerms = [],
|
|
113
|
+
autoInjectDefects = true,
|
|
114
|
+
} = {}) {
|
|
115
|
+
const seen = new Set()
|
|
116
|
+
const result = []
|
|
117
|
+
|
|
118
|
+
const addTerm = (rawTerm) => {
|
|
119
|
+
const term = String(rawTerm || '').trim()
|
|
120
|
+
if (!term) return
|
|
121
|
+
const key = term.toLowerCase()
|
|
122
|
+
if (seen.has(key)) return
|
|
123
|
+
if (contradictsPositive(term, positivePrompt)) return
|
|
124
|
+
seen.add(key)
|
|
125
|
+
result.push(term)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 1. User's explicit negative prompt comes first (preserves user intent and weights like "(bad:1.2)")
|
|
129
|
+
const userTerms = splitTerms(userNegative)
|
|
130
|
+
for (const t of userTerms) {
|
|
131
|
+
addTerm(t)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 2. Preset / additional negative terms
|
|
135
|
+
for (const t of additionalTerms) {
|
|
136
|
+
if (typeof t === 'string') {
|
|
137
|
+
for (const sub of splitTerms(t)) addTerm(sub)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 3. Built-in defect terms (if enabled)
|
|
142
|
+
if (autoInjectDefects) {
|
|
143
|
+
for (const d of DEFAULT_DEFECT_TERMS) {
|
|
144
|
+
addTerm(d)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result.join(', ')
|
|
149
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// lib/quality-gate.js
|
|
2
|
+
// Automatic Quality Gate and Silent Re-roll Engine (#166)
|
|
3
|
+
|
|
4
|
+
import { estimateSharpnessAndVariance } from './providers.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Inspects generated image bytes for visual defects, blank frames, or low entropy.
|
|
8
|
+
*
|
|
9
|
+
* @param {Buffer|Uint8Array} bytes - Image bytes
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {Object} [options.ctx] - Cordis context for vision bridge integration
|
|
12
|
+
* @param {string} [options.prompt] - Prompt used for generation
|
|
13
|
+
* @returns {Promise<{ passed: boolean, score: number, defect: string|null, details: Object }>}
|
|
14
|
+
*/
|
|
15
|
+
export async function evaluateImageQuality(bytes, { ctx, prompt } = {}) {
|
|
16
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
|
|
17
|
+
|
|
18
|
+
// 1. Minimum buffer check
|
|
19
|
+
if (!buf || buf.length < 512) {
|
|
20
|
+
return {
|
|
21
|
+
passed: false,
|
|
22
|
+
score: 0,
|
|
23
|
+
defect: 'corrupt_buffer',
|
|
24
|
+
details: { reason: 'Buffer size is too small or corrupt (< 512 bytes)', size: buf.length },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 2. Laplacian-like sharpness / variance test
|
|
29
|
+
const heuristic = estimateSharpnessAndVariance(buf)
|
|
30
|
+
if (!heuristic.passed || heuristic.isBlank) {
|
|
31
|
+
return {
|
|
32
|
+
passed: false,
|
|
33
|
+
score: heuristic.score || 0.1,
|
|
34
|
+
defect: 'blank_or_solid_frame',
|
|
35
|
+
details: heuristic,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 3. Vision bridge integration (if available and loaded in ctx)
|
|
40
|
+
let visionVerdict = null
|
|
41
|
+
try {
|
|
42
|
+
const visionBridge = ctx && (ctx.get?.('dsh-vision-bridge') || ctx['dsh-vision-bridge'])
|
|
43
|
+
if (visionBridge && typeof visionBridge.inspectQuality === 'function') {
|
|
44
|
+
visionVerdict = await visionBridge.inspectQuality({ bytes: buf, prompt })
|
|
45
|
+
if (visionVerdict && visionVerdict.passed === false) {
|
|
46
|
+
return {
|
|
47
|
+
passed: false,
|
|
48
|
+
score: visionVerdict.score ?? 0.3,
|
|
49
|
+
defect: visionVerdict.defect || 'vision_rejected',
|
|
50
|
+
details: { heuristic, vision: visionVerdict },
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Non-fatal: if vision bridge fails or throws, fallback gracefully to heuristic result
|
|
56
|
+
visionVerdict = { error: err.message }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
passed: true,
|
|
61
|
+
score: heuristic.score,
|
|
62
|
+
defect: null,
|
|
63
|
+
details: {
|
|
64
|
+
heuristic,
|
|
65
|
+
...(visionVerdict ? { vision: visionVerdict } : {}),
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Runs a generation job with silent re-roll if quality gate fails.
|
|
72
|
+
*
|
|
73
|
+
* @param {Function} generateFn - Async function (seed) => Promise<generated>
|
|
74
|
+
* @param {Object} options
|
|
75
|
+
* @param {number} options.initialSeed - Base random seed
|
|
76
|
+
* @param {boolean} [options.enabled=true] - Whether quality gate is enabled
|
|
77
|
+
* @param {number} [options.maxRerolls=2] - Maximum silent retry attempts
|
|
78
|
+
* @param {Object} [options.ctx] - Cordis context
|
|
79
|
+
* @param {string} [options.prompt] - Text prompt
|
|
80
|
+
* @param {Function} [options.logger] - Optional logger callback
|
|
81
|
+
* @returns {Promise<{ generated: Object, qualityReport: Object }>}
|
|
82
|
+
*/
|
|
83
|
+
export async function executeWithQualityGate(generateFn, {
|
|
84
|
+
initialSeed,
|
|
85
|
+
enabled = true,
|
|
86
|
+
maxRerolls = 2,
|
|
87
|
+
ctx,
|
|
88
|
+
prompt,
|
|
89
|
+
logger = () => {},
|
|
90
|
+
}) {
|
|
91
|
+
let currentSeed = initialSeed !== undefined ? initialSeed : Math.floor(Math.random() * 1000000)
|
|
92
|
+
let rerolls = 0
|
|
93
|
+
let lastReport = null
|
|
94
|
+
let lastGenerated = null
|
|
95
|
+
|
|
96
|
+
const attempts = enabled ? Math.max(1, maxRerolls + 1) : 1
|
|
97
|
+
|
|
98
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
99
|
+
lastGenerated = await generateFn(currentSeed)
|
|
100
|
+
|
|
101
|
+
if (!enabled) {
|
|
102
|
+
return {
|
|
103
|
+
generated: lastGenerated,
|
|
104
|
+
qualityReport: { passed: true, score: 1.0, rerolls: 0, skipped: true },
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const evaluation = await evaluateImageQuality(lastGenerated.bytes, { ctx, prompt })
|
|
109
|
+
lastReport = {
|
|
110
|
+
passed: evaluation.passed,
|
|
111
|
+
score: evaluation.score,
|
|
112
|
+
defect: evaluation.defect,
|
|
113
|
+
details: evaluation.details,
|
|
114
|
+
rerolls,
|
|
115
|
+
seedUsed: currentSeed,
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (evaluation.passed) {
|
|
119
|
+
return {
|
|
120
|
+
generated: lastGenerated,
|
|
121
|
+
qualityReport: lastReport,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Critical defect detected, prepare silent re-roll
|
|
126
|
+
rerolls++
|
|
127
|
+
currentSeed = (currentSeed + 1013) % 2147483647
|
|
128
|
+
logger(`Quality gate failed (${evaluation.defect}, score: ${evaluation.score}). Silent re-roll attempt ${rerolls}/${maxRerolls}...`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// If all re-rolls exhausted, return the best we have with clear defect warning in report
|
|
132
|
+
return {
|
|
133
|
+
generated: lastGenerated,
|
|
134
|
+
qualityReport: {
|
|
135
|
+
...lastReport,
|
|
136
|
+
rerolls,
|
|
137
|
+
exhausted: true,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// lib/security.js
|
|
2
|
+
// Secure credential handling, 0600 file permissions, and token masking (#169)
|
|
3
|
+
|
|
4
|
+
import { chmodSync, existsSync } from 'node:fs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Masks sensitive API keys, leaving only the prefix/suffix.
|
|
8
|
+
* E.g. "sk-proj-1234567890abcdef" -> "sk-p...cdef"
|
|
9
|
+
*/
|
|
10
|
+
export function maskApiKey(key) {
|
|
11
|
+
if (!key || typeof key !== 'string') return ''
|
|
12
|
+
const trimmed = key.trim()
|
|
13
|
+
if (!trimmed) return ''
|
|
14
|
+
if (trimmed.length <= 8) return '********'
|
|
15
|
+
return trimmed.slice(0, 4) + '...' + trimmed.slice(-4)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitizes arbitrary text, error messages, and URLs to remove authorization tokens.
|
|
20
|
+
*/
|
|
21
|
+
export function sanitizeErrorAndLogs(input) {
|
|
22
|
+
if (!input) return ''
|
|
23
|
+
let text = typeof input === 'string' ? input : (input.message || JSON.stringify(input))
|
|
24
|
+
|
|
25
|
+
// Mask Bearer tokens
|
|
26
|
+
text = text.replace(/Bearer\s+([a-zA-Z0-9_\-\.]{6,})/gi, (match, token) => {
|
|
27
|
+
return `Bearer ${maskApiKey(token)}`
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Mask Key tokens
|
|
31
|
+
text = text.replace(/Key\s+([a-zA-Z0-9_\-\.]{6,})/gi, (match, token) => {
|
|
32
|
+
return `Key ${maskApiKey(token)}`
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Mask query params like ?key=... or &api_key=...
|
|
36
|
+
text = text.replace(/([?&](?:api_)?key=)([^&\s]+)/gi, (match, prefix, val) => {
|
|
37
|
+
return `${prefix}${maskApiKey(val)}`
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
// Mask raw sk-... tokens
|
|
41
|
+
text = text.replace(/(sk-[a-zA-Z0-9_\-]{8,})/gi, (match, token) => {
|
|
42
|
+
return maskApiKey(token)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return text
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ensures strict 0600 file permissions on sensitive config/key files.
|
|
50
|
+
*/
|
|
51
|
+
export function enforceSecurePermissions(filePath) {
|
|
52
|
+
if (!filePath || !existsSync(filePath)) return false
|
|
53
|
+
try {
|
|
54
|
+
chmodSync(filePath, 0o600)
|
|
55
|
+
return true
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.11",
|
|
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",
|