@goodandready/dsh-image-gen 0.10.13 → 0.10.14

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 CHANGED
@@ -1,285 +1,183 @@
1
- // dsh-image-gen — browser (client) half.
2
- //
3
- // Renders a configuration card inside Web GUI 设置 → 插件 → 插件配置,
4
- // editing the `dsh-image-gen` dsh-settings namespace that the Host
5
- // plugin registers. The card is a hand-written ModuleLoader module: it
6
- // depends only on services the official client runtime provides (`slots`,
7
- // `settingsScope`, `locale`, `react`), so it ships inside this npm package
8
- // with no build step. The card chrome mirrors the official plugin-card
9
- // chrome in a self-contained slice.
10
-
1
+ // Plugin Settings Card (settings.plugin.item) and Toolviews for @goodandready/dsh-image-gen
11
2
  window.__ModuleLoader__.load({
12
3
  id: '@goodandready/dsh-image-gen',
13
4
  factory: (require) => {
14
5
  var module = { exports: {} }
15
6
  var exports = module.exports
16
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
17
- let react = require('react')
18
- let react_jsx_runtime = require('react/jsx-runtime')
19
- // Любой require пакета ядра из клиентской половины обязан быть обёрнут:
20
- // незащищённый роняет не только свой плагин, наружу это выходит списком
21
- // чужих имён. Имя пакета менялось между ядрами — пробуем новое и старое.
22
- function loadRuntime() {
23
- for (const id of ['@deepseek-ai/dsh-client-store', '@deepseek-ai/dsh-client-runtime/client']) {
24
- try { return require(id) } catch (missing) { /* следующее */ }
7
+ const react = require('react')
8
+ const React = react
9
+
10
+ let react_jsx_runtime = null
11
+ try {
12
+ react_jsx_runtime = require('react/jsx-runtime')
13
+ } catch (_) {
14
+ react_jsx_runtime = {
15
+ jsx: (type, props, key) => react.createElement(type, key !== undefined ? { ...props, key } : props),
16
+ jsxs: (type, props, key) => react.createElement(type, key !== undefined ? { ...props, key } : props),
25
17
  }
26
- return null
27
18
  }
28
- let runtime = loadRuntime()
19
+
20
+ const NS = 'dsh-image-gen'
21
+ const SETTINGS_NS = 'dsh-image-gen'
22
+ const TITLE = 'Image Studio'
23
+ const SUBTITLE = 'Multi-provider AI image generation, editing, upscale & vectorization.'
29
24
 
30
25
  // Ядровый шеврон раскрытия; в урезанной сборке — запасной SVG той же формы.
31
26
  let ChevronIcon = null
32
27
  try {
33
28
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
34
29
  ChevronIcon = primitives && primitives.IconChevronDownOutline14
35
- } catch (noPrimitives) {
30
+ } catch (_) {
36
31
  ChevronIcon = null
37
32
  }
38
- const ChevronIconNode = ChevronIcon || react.createElement('svg', {
39
- width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true,
40
- style: { display: 'block' },
41
- }, react.createElement('path', {
42
- d: 'M3 5.5 L7 9.5 L11 5.5',
43
- fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
44
- strokeLinecap: 'round', strokeLinejoin: 'round',
45
- }))
46
-
47
-
48
- // ---------------------------------------------------------------- css
49
- const css =
50
- '.fal_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
51
- '.fal_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
52
- '.fal_head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
53
- '.fal_head:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
54
- '.fal_headText{flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}' +
55
- '.fal_title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
56
- '.fal_sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
57
- '.fal_pending{color:var(--dsw-alias-state-warn-primary);font-size:13px}' +
58
- '.fal_chevron{color:var(--dsw-alias-label-tertiary);transition:transform .12s}' +
59
- '.fal_chevronOpen{transform:rotate(180deg)}' +
60
- '.fal_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
61
- '.fal_readOnly{color:var(--dsw-alias-label-tertiary);margin:0;font-size:13px;padding:12px 0}' +
62
- '.fal_foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
63
- '.fal_failed{color:var(--dsw-alias-state-error-primary);margin:0 auto 0 0;font-size:13px}' +
64
- '.fal_discard,.fal_save{appearance:none;font:inherit;cursor:pointer;border-radius:8px;padding:5px 14px;font-size:13px}' +
65
- '.fal_discard{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}' +
66
- '.fal_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}' +
67
- '.fal_save{border:1px solid transparent;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
68
- '.fal_save:hover:not(:disabled){opacity:.9}' +
69
- '.fal_discard:active:not(:disabled),.fal_save:active:not(:disabled){transform:translateY(1px)}' +
70
- '.fal_discard:focus-visible:not(:disabled),.fal_save:focus-visible:not(:disabled){outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
71
- '.fal_discard:disabled,.fal_save:disabled{opacity:.5;cursor:default}' +
72
- '.fal_field{display:flex;flex-direction:column;gap:6px;padding:12px 0}' +
73
- '.fal_fieldHead{align-items:center;gap:12px;display:flex}' +
74
- '.fal_label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}' +
75
- '.fal_badges{align-items:center;gap:6px;display:flex}' +
76
- '.fal_badge{background:var(--dsw-alias-interactive-bg-hover-accent);color:var(--dsw-alias-state-business-primary);border-radius:999px;padding:1px 6px;font-size:11px}' +
77
- '.fal_reset{color:var(--dsw-alias-state-business-primary);cursor:pointer;background:0 0;border:0;padding:0;font-size:11px}' +
78
- '.fal_reset:hover:not(:disabled){text-decoration:underline}' +
79
- '.fal_reset:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
80
- '.fal_input,.fal_select{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
81
- '.fal_inputInvalid{border:1px solid var(--dsw-alias-state-error-primary);font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
82
- '.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
83
- '.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
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
- const tagId = 'dsh-image-gen/settings-card.module.css'
87
- if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
88
- const tag = document.createElement('style')
89
- tag.dataset.plugin = 'dsh-image-gen'
90
- tag.dataset.pluginCss = tagId
91
- tag.textContent = css
92
- document.head.appendChild(tag)
93
- }
94
- const cssDefault = {
95
- badge: 'fal_badge',
96
- badges: 'fal_badges',
97
- body: 'fal_body',
98
- card: 'fal_card',
99
- cardOpen: 'fal_cardOpen',
100
- chevron: 'fal_chevron',
101
- chevronOpen: 'fal_chevronOpen',
102
- description: 'fal_sub',
103
- discard: 'fal_discard',
104
- failed: 'fal_failed',
105
- field: 'fal_field',
106
- footer: 'fal_foot',
107
- head: 'fal_fieldHead',
108
- header: 'fal_head',
109
- headText: 'fal_headText',
110
- hint: 'fal_hint',
111
- input: 'fal_input',
112
- inputInvalid: 'fal_inputInvalid',
113
- invalid: 'fal_invalid',
114
- label: 'fal_label',
115
- name: 'fal_title',
116
- pending: 'fal_pending',
117
- readOnly: 'fal_readOnly',
118
- reset: 'fal_reset',
119
- save: 'fal_save',
120
- select: 'fal_select',
121
- sep: 'fal_sep',
122
- }
33
+ const ChevronIconNode = ChevronIcon
34
+ ? react.createElement(ChevronIcon)
35
+ : react.createElement('svg', {
36
+ width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true,
37
+ style: { display: 'block' },
38
+ }, react.createElement('path', {
39
+ d: 'M3 5.5 L7 9.5 L11 5.5',
40
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
41
+ strokeLinecap: 'round', strokeLinejoin: 'round',
42
+ }))
123
43
 
124
- // -------------------------------------------------------------- chrome
125
- /** Shared card chrome: disclosure header, controls, save/discard footer. */
126
- function PluginSettingsCard(props) {
127
- // Карточка живёт в собственном разделе настроек и на странице одна, так
128
- // что открыта сразу: свёрнутая выглядела бы как пустая страница.
129
- const [open, setOpen] = react.useState(props.defaultOpen ?? true)
130
- const { state } = props
131
- // Раньше здесь было `return null`, и раздел оказывался пустым, если хост
132
- // ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
133
- // сказать прямо, что происходит.
134
- if (!state.available) {
135
- return react_jsx_runtime.jsx('li', {
136
- className: cssDefault.card,
137
- children: react_jsx_runtime.jsx('p', {
138
- className: cssDefault.hint,
139
- role: 'status',
140
- children: props.t('settings.unavailable'),
141
- }),
142
- })
143
- }
144
- const title = props.t(props.titleKey)
145
- const blocked = !state.dirty || state.invalid || state.saving
146
- return react_jsx_runtime.jsxs('li', {
147
- className: cssDefault.card + (open ? ' ' + cssDefault.cardOpen : ''),
148
- children: [
149
- react_jsx_runtime.jsxs('button', {
150
- type: 'button',
151
- className: cssDefault.header,
152
- 'aria-expanded': open,
153
- 'aria-label': `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`,
154
- onClick: () => setOpen(!open),
155
- children: [
156
- react_jsx_runtime.jsxs('span', {
157
- className: cssDefault.headText,
158
- children: [
159
- react_jsx_runtime.jsx('span', { className: cssDefault.name, children: title }),
160
- react_jsx_runtime.jsx('span', { className: cssDefault.description, children: props.t(props.descriptionKey) }),
161
- ],
162
- }),
163
- state.dirty ? react_jsx_runtime.jsx('span', { className: cssDefault.pending, children: props.t('settings.unsaved') }) : null,
164
- react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children: ChevronIconNode }),
165
- ],
166
- }),
167
- open
168
- ? react_jsx_runtime.jsxs('div', {
169
- className: cssDefault.body,
170
- children: [
171
- !state.writable ? react_jsx_runtime.jsx('p', { className: cssDefault.readOnly, role: 'status', children: props.t('settings.readOnly') }) : null,
172
- props.children,
173
- react_jsx_runtime.jsxs('div', {
174
- className: cssDefault.footer,
175
- children: [
176
- state.failed ? react_jsx_runtime.jsx('p', { className: cssDefault.failed, role: 'status', children: props.t('settings.saveFailed') }) : null,
177
- react_jsx_runtime.jsx('button', {
178
- type: 'button',
179
- className: cssDefault.discard,
180
- disabled: !state.dirty || state.saving,
181
- onClick: props.onDiscard,
182
- children: props.t('settings.discard'),
183
- }),
184
- react_jsx_runtime.jsx('button', {
185
- type: 'button',
186
- className: cssDefault.save,
187
- disabled: blocked,
188
- onClick: props.onSave,
189
- children: props.t(state.saving ? 'settings.saving' : 'settings.save'),
190
- }),
191
- ],
192
- }),
193
- ],
194
- })
195
- : null,
196
- ],
197
- })
44
+ // -------------------------------------------------------------- CSS & Design System
45
+ function ensureCss() {
46
+ if (typeof document === 'undefined') return
47
+ if (document.getElementById('dsh-image-gen-full-css')) return
48
+ const style = document.createElement('style')
49
+ style.id = 'dsh-image-gen-full-css'
50
+ style.dataset.dshPlugin = NS
51
+ style.textContent = `
52
+ .ig-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:1000px}
53
+ .ig-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}
54
+ .ig-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}
55
+ .ig-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
56
+
57
+ .ig-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:14px}
58
+ .ig-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}
59
+ .ig-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-6px;line-height:1.4}
60
+
61
+ .ig-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
62
+ .ig-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}
63
+ .ig-grid-4{display:grid;grid-template-columns:repeat(auto-fit, minmax(190px, 1fr));gap:10px}
64
+
65
+ .ig-stat-box{padding:12px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:4px}
66
+ .ig-stat-val{font-size:16px;font-weight:700;color:var(--dsw-alias-label-primary)}
67
+ .ig-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}
68
+
69
+ .ig-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
70
+ .ig-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}
71
+ .ig-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}
72
+ .ig-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}
73
+
74
+ .ig-tabs{display:flex;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:8px;margin-bottom:6px;overflow-x:auto}
75
+ .ig-tab-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:6px;padding:6px 12px;font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary);font-weight:500;display:inline-flex;align-items:center;gap:6px;transition:all .15s ease}
76
+ .ig-tab-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
77
+ .ig-tab-btn-active{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-border-l2);font-weight:600}
78
+
79
+ .ig-field{display:flex;flex-direction:column;gap:5px;padding:8px 0}
80
+ .ig-field-head{display:flex;justify-content:space-between;align-items:center}
81
+ .ig-field-label{font-size:13px;font-weight:500;color:var(--dsw-alias-label-primary)}
82
+ .ig-field-hint{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}
83
+ .ig-field-err{font-size:12px;color:var(--dsw-alias-state-error-primary);margin-top:2px}
84
+
85
+ .ig-input,.ig-select{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;width:100%;box-sizing:border-box}
86
+ .ig-input:focus,.ig-select:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}
87
+ .ig-input-invalid{border-color:var(--dsw-alias-state-error-primary)}
88
+ .ig-input:disabled,.ig-select:disabled{opacity:0.5;cursor:not-allowed}
89
+
90
+ .ig-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 14px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}
91
+ .ig-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
92
+ .ig-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
93
+ .ig-btn-primary:hover:not(:disabled){opacity:0.9}
94
+ .ig-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}
95
+ .ig-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.5)}
96
+ .ig-btn-disabled{opacity:0.5;cursor:not-allowed}
97
+
98
+ .ig-reset{font-size:11px;color:var(--dsw-alias-state-brand-primary);background:none;border:none;cursor:pointer;padding:0;text-decoration:underline}
99
+ .ig-reset:hover{opacity:0.8}
100
+
101
+ .ig-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px}
102
+ .ig-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}
103
+ .ig-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}
104
+ .ig-banner-warning{padding:12px 16px;border-radius:8px;background:rgba(245,158,11,0.12);border:1px solid var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
105
+
106
+ /* Backward-compatibility alias styles for toolview */
107
+ .fal_head{font-size:13px;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-label-primary)}
108
+ .fal-prompt{font-size:13px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;border:1px solid var(--dsw-alias-border-l2);margin-bottom:8px;word-break:break-word}
109
+ .fal-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:6px}
110
+ .fal-err{padding:10px;border-radius:6px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:12px}
111
+ `
112
+ document.head.appendChild(style)
198
113
  }
199
114
 
200
- /** A plain text (or numeric) value field. */
201
- function ValueField(props) {
202
- return react_jsx_runtime.jsxs('div', {
203
- className: cssDefault.field,
204
- children: [
205
- react_jsx_runtime.jsxs('div', {
206
- className: cssDefault.head,
207
- children: [
208
- react_jsx_runtime.jsx('label', { className: cssDefault.label, htmlFor: props.id, children: props.label }),
209
- props.overridden
210
- ? react_jsx_runtime.jsxs('span', {
211
- className: cssDefault.badges,
212
- children: [
213
- react_jsx_runtime.jsx('span', { className: cssDefault.badge, children: props.overriddenLabel }),
214
- react_jsx_runtime.jsx('button', { type: 'button', className: cssDefault.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel }),
215
- ],
216
- })
217
- : null,
218
- ],
219
- }),
220
- react_jsx_runtime.jsx('input', {
221
- id: props.id,
222
- className: props.invalid ? cssDefault.inputInvalid : cssDefault.input,
223
- type: 'text',
224
- ...(props.numeric === true ? { inputMode: 'numeric' } : {}),
225
- ...(props.invalid ? { 'aria-invalid': true } : {}),
226
- value: props.text,
227
- placeholder: props.placeholder ?? '',
228
- disabled: props.disabled,
229
- onChange: (event) => props.onEdit(event.target.value),
230
- }),
231
- react_jsx_runtime.jsx('p', { className: props.invalid ? cssDefault.invalid : cssDefault.hint, children: props.invalid ? props.invalidLabel : props.hint }),
232
- ],
233
- })
115
+ function createErrorBoundary() {
116
+ if (!React || typeof React.Component !== 'function') {
117
+ return function NoopBoundary(props) { return props?.children || null }
118
+ }
119
+ return class ErrorBoundary extends React.Component {
120
+ constructor(props) {
121
+ super(props)
122
+ this.state = { hasError: false, error: null }
123
+ }
124
+ static getDerivedStateFromError(error) {
125
+ return { hasError: true, error }
126
+ }
127
+ componentDidCatch(error, errorInfo) {
128
+ console.error('[dsh-image-gen] UI Crash caught by ErrorBoundary:', error, errorInfo)
129
+ }
130
+ render() {
131
+ if (this.state.hasError) {
132
+ return react.createElement(
133
+ 'div',
134
+ {
135
+ className: 'ig-alert-err',
136
+ style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
137
+ },
138
+ react.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Image Gen UI Error:'),
139
+ react.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
140
+ react.createElement(
141
+ 'button',
142
+ {
143
+ type: 'button',
144
+ className: 'ig-btn',
145
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
146
+ onClick: () => this.setState({ hasError: false, error: null }),
147
+ },
148
+ 'Retry'
149
+ )
150
+ )
151
+ }
152
+ return this.props.children
153
+ }
154
+ }
234
155
  }
156
+ const ErrorBoundary = createErrorBoundary()
235
157
 
236
- /** A select field (enum values; empty = inherit the default). */
237
- function SelectField(props) {
238
- return react_jsx_runtime.jsxs('div', {
239
- className: cssDefault.field,
240
- children: [
241
- react_jsx_runtime.jsxs('div', {
242
- className: cssDefault.head,
243
- children: [
244
- react_jsx_runtime.jsx('label', { className: cssDefault.label, htmlFor: props.id, children: props.label }),
245
- props.overridden
246
- ? react_jsx_runtime.jsxs('span', {
247
- className: cssDefault.badges,
248
- children: [
249
- react_jsx_runtime.jsx('span', { className: cssDefault.badge, children: props.overriddenLabel }),
250
- react_jsx_runtime.jsx('button', { type: 'button', className: cssDefault.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel }),
251
- ],
252
- })
253
- : null,
254
- ],
255
- }),
256
- react_jsx_runtime.jsxs('select', {
257
- id: props.id,
258
- className: cssDefault.select,
259
- value: props.text,
260
- disabled: props.disabled,
261
- onChange: (event) => props.onEdit(event.target.value),
262
- children: [
263
- react_jsx_runtime.jsx('option', { value: '', children: props.inheritLabel }),
264
- props.options.map((option) => react_jsx_runtime.jsx('option', { value: option, children: option }, option)),
265
- ],
266
- }),
267
- react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: props.hint }),
268
- ],
269
- })
270
- }
158
+ // -------------------------------------------------------------- Form & Specs
159
+ const booleanField = (field) => ({
160
+ field,
161
+ format: (value) => (value === true || value === 'true' ? 'true' : value === false || value === 'false' ? 'false' : ''),
162
+ parse: (text) => {
163
+ if (text === '') return { kind: 'clear' }
164
+ if (text === 'true') return { kind: 'set', value: true }
165
+ if (text === 'false') return { kind: 'set', value: false }
166
+ return void 0
167
+ },
168
+ })
271
169
 
272
- // --------------------------------------------------------------- form
273
- /** A staged text value (empty text clears the override). */
274
170
  function textField(field) {
275
171
  return {
276
172
  field,
277
173
  format: (value) => (typeof value === 'string' ? value : ''),
278
- parse: (text) => (text === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
174
+ parse: (text) => {
175
+ if (text === '') return { kind: 'clear' }
176
+ return { kind: 'set', value: text }
177
+ },
279
178
  }
280
179
  }
281
180
 
282
- /** A staged numeric value. */
283
181
  function numberField(field) {
284
182
  return {
285
183
  field,
@@ -292,7 +190,6 @@ window.__ModuleLoader__.load({
292
190
  }
293
191
  }
294
192
 
295
- /** A staged enum value (empty text clears the override). */
296
193
  function selectField(field, options) {
297
194
  const set = new Set(options)
298
195
  return {
@@ -305,11 +202,89 @@ window.__ModuleLoader__.load({
305
202
  }
306
203
  }
307
204
 
308
- /**
309
- * Stages one card's edits over one settings namespace and writes them on
310
- * save. The Host is the only authority on whether a value was accepted, so
311
- * the outcome is read back from the section rather than predicted here.
312
- */
205
+ const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
206
+ const IMAGE_SIZES = ['landscape_4_3', 'landscape_16_9', 'portrait_4_3', 'portrait_16_9', 'square', 'square_hd']
207
+ const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
208
+ const DELIVERY_MODES = ['link', 'image']
209
+ const STYLE_PRESET_OPTIONS = ['', 'cinematic', 'anime', 'digital_art', 'photographic', 'fantasy', 'isometric', 'cyberpunk', 'watercolor', 'origami', 'oil_painting', 'pixel_art', 'logo']
210
+ const SIZE_PIXELS = {
211
+ square: '512x512',
212
+ square_hd: '1024x1024',
213
+ portrait_4_3: '768x1024',
214
+ portrait_16_9: '576x1024',
215
+ landscape_4_3: '1024x768',
216
+ landscape_16_9: '1024x576',
217
+ }
218
+
219
+ const FIELDS = [
220
+ // General
221
+ { field: 'enabled', spec: booleanField('enabled'), kind: 'select', options: ['true', 'false'], labelKey: 'f.enabled', hintKey: 'f.enabledHint', tab: 'general' },
222
+ { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint', tab: 'general', providerNeeds: { fal: 'f.needFal', custom: 'f.needCustom', codex: 'f.needCodex', grok: 'f.needGrok', local: 'f.needLocal', seedream: 'f.needSeedream', gemini: 'f.needGemini', replicate: 'f.needReplicate' } },
223
+ { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS, tab: 'general' },
224
+ { field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint', tab: 'general' },
225
+ { field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint', tab: 'general' },
226
+ { field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir', tab: 'general' },
227
+ { field: 'historyLimit', spec: numberField('historyLimit'), kind: 'number', labelKey: 'f.historyLimit', hintKey: 'f.historyLimitHint', placeholderKey: 'p.historyLimit', tab: 'general' },
228
+
229
+ // Provider: FAL
230
+ { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model', when: ['fal'], tab: 'provider' },
231
+ { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv', when: ['fal'], tab: 'provider' },
232
+ { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL', when: ['fal'], tab: 'provider' },
233
+ { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs', when: ['fal'], tab: 'provider' },
234
+ { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs', when: ['fal'], tab: 'provider' },
235
+
236
+ // Provider: Custom
237
+ { field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL', when: ['custom'], tab: 'provider' },
238
+ { field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel', when: ['custom'], tab: 'provider' },
239
+ { field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv', when: ['custom'], tab: 'provider' },
240
+ { field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize', when: ['custom'], tab: 'provider' },
241
+
242
+ // Provider: Replicate
243
+ { field: 'replicateModel', spec: textField('replicateModel'), kind: 'text', labelKey: 'f.replicateModel', hintKey: 'f.replicateModelHint', placeholderKey: 'p.replicateModel', when: ['replicate'], tab: 'provider' },
244
+ { field: 'replicateKeyEnv', spec: textField('replicateKeyEnv'), kind: 'text', labelKey: 'f.replicateKeyEnv', hintKey: 'f.replicateKeyEnvHint', placeholderKey: 'p.replicateKeyEnv', when: ['replicate'], tab: 'provider' },
245
+
246
+ // Provider: SeaDream
247
+ { field: 'seedreamModel', spec: textField('seedreamModel'), kind: 'text', labelKey: 'f.seedreamModel', hintKey: 'f.seedreamModelHint', placeholderKey: 'p.seedreamModel', when: ['seedream'], tab: 'provider' },
248
+ { field: 'seedreamKeyEnv', spec: textField('seedreamKeyEnv'), kind: 'text', labelKey: 'f.seedreamKeyEnv', hintKey: 'f.seedreamKeyEnvHint', placeholderKey: 'p.seedreamKeyEnv', when: ['seedream'], tab: 'provider' },
249
+ { field: 'seedreamBaseURL', spec: textField('seedreamBaseURL'), kind: 'text', labelKey: 'f.seedreamBaseURL', hintKey: 'f.seedreamBaseURLHint', placeholderKey: 'p.seedreamBaseURL', when: ['seedream'], tab: 'provider' },
250
+
251
+ // Provider: Gemini
252
+ { field: 'geminiModel', spec: textField('geminiModel'), kind: 'text', labelKey: 'f.geminiModel', hintKey: 'f.geminiModelHint', placeholderKey: 'p.geminiModel', when: ['gemini'], tab: 'provider' },
253
+ { field: 'geminiKeyEnv', spec: textField('geminiKeyEnv'), kind: 'text', labelKey: 'f.geminiKeyEnv', hintKey: 'f.geminiKeyEnvHint', placeholderKey: 'p.geminiKeyEnv', when: ['gemini'], tab: 'provider' },
254
+
255
+ // Provider: Local
256
+ { field: 'localKind', spec: selectField('localKind', ['comfyui', 'a1111']), kind: 'select', options: ['comfyui', 'a1111'], labelKey: 'f.localKind', hintKey: 'f.localKindHint', when: ['local'], tab: 'provider' },
257
+ { field: 'localBaseURL', spec: textField('localBaseURL'), kind: 'text', labelKey: 'f.localBaseURL', hintKey: 'f.localBaseURLHint', placeholderKey: 'p.localBaseURL', when: ['local'], tab: 'provider' },
258
+ { field: 'localModel', spec: textField('localModel'), kind: 'text', labelKey: 'f.localModel', hintKey: 'f.localModelHint', placeholderKey: 'p.localModel', when: ['local'], tab: 'provider' },
259
+ { field: 'localSteps', spec: numberField('localSteps'), kind: 'number', labelKey: 'f.localSteps', hintKey: 'f.localStepsHint', placeholderKey: 'p.localSteps', when: ['local'], tab: 'provider' },
260
+ { field: 'localCfg', spec: numberField('localCfg'), kind: 'number', labelKey: 'f.localCfg', hintKey: 'f.localCfgHint', placeholderKey: 'p.localCfg', when: ['local'], tab: 'provider' },
261
+ // Backward-compat aliases for tests
262
+ { field: 'localComfyUrl', spec: textField('localComfyUrl'), kind: 'text', labelKey: 'f.localComfyUrl', hintKey: 'f.localComfyUrlHint', placeholderKey: 'p.localComfyUrl', when: ['local'], tab: 'provider' },
263
+ { field: 'localA1111Url', spec: textField('localA1111Url'), kind: 'text', labelKey: 'f.localA1111Url', hintKey: 'f.localA1111UrlHint', placeholderKey: 'p.localA1111Url', when: ['local'], tab: 'provider' },
264
+
265
+ // Provider: Codex / Grok
266
+ { field: 'subscriptionQuality', spec: selectField('subscriptionQuality', ['', 'low', 'medium', 'high']), kind: 'select', options: ['', 'low', 'medium', 'high'], labelKey: 'f.subscriptionQuality', hintKey: 'f.subscriptionQualityHint', when: ['codex', 'grok'], tab: 'provider' },
267
+
268
+ // Enhancer & Style
269
+ { field: 'enhancePrompt', spec: booleanField('enhancePrompt'), kind: 'select', options: ['true', 'false'], labelKey: 'f.enhancePrompt', hintKey: 'f.enhancePromptHint', tab: 'enhancer' },
270
+ { field: 'enableLlmEnhancer', spec: booleanField('enableLlmEnhancer'), kind: 'select', options: ['true', 'false'], labelKey: 'f.enableLlmEnhancer', hintKey: 'f.enableLlmEnhancerHint', tab: 'enhancer' },
271
+ { field: 'enhanceModel', spec: textField('enhanceModel'), kind: 'text', labelKey: 'f.enhanceModel', hintKey: 'f.enhanceModelHint', placeholderKey: 'p.enhanceModel', tab: 'enhancer' },
272
+ { field: 'enhanceBelowChars', spec: numberField('enhanceBelowChars'), kind: 'number', labelKey: 'f.enhanceBelowChars', hintKey: 'f.enhanceBelowCharsHint', placeholderKey: 'p.enhanceBelowChars', tab: 'enhancer' },
273
+ { field: 'stylePreset', spec: selectField('stylePreset', STYLE_PRESET_OPTIONS), kind: 'select', options: STYLE_PRESET_OPTIONS, labelKey: 'f.stylePreset', hintKey: 'f.stylePresetHint', tab: 'enhancer' },
274
+
275
+ // Safety & Budget
276
+ { field: 'qualityGate', spec: booleanField('qualityGate'), kind: 'select', options: ['true', 'false'], labelKey: 'f.qualityGate', hintKey: 'f.qualityGateHint', tab: 'safety' },
277
+ { field: 'dailyBudgetUsd', spec: numberField('dailyBudgetUsd'), kind: 'number', labelKey: 'f.dailyBudgetUsd', hintKey: 'f.dailyBudgetUsdHint', placeholderKey: 'p.dailyBudgetUsd', tab: 'safety' },
278
+ { field: 'loopGuardLimit', spec: numberField('loopGuardLimit'), kind: 'number', labelKey: 'f.loopGuardLimit', hintKey: 'f.loopGuardLimitHint', placeholderKey: 'p.loopGuardLimit', tab: 'safety' },
279
+
280
+ // Cache & Storage
281
+ { field: 'diskCache', spec: booleanField('diskCache'), kind: 'select', options: ['true', 'false'], labelKey: 'f.diskCache', hintKey: 'f.diskCacheHint', tab: 'cache' },
282
+ { field: 'cacheBySeed', spec: booleanField('cacheBySeed'), kind: 'select', options: ['true', 'false'], labelKey: 'f.cacheBySeed', hintKey: 'f.cacheBySeedHint', tab: 'cache' },
283
+ { field: 'cacheByPrompt', spec: booleanField('cacheByPrompt'), kind: 'select', options: ['true', 'false'], labelKey: 'f.cacheByPrompt', hintKey: 'f.cacheByPromptHint', tab: 'cache' },
284
+ { field: 'pruneDays', spec: numberField('pruneDays'), kind: 'number', labelKey: 'f.pruneDays', hintKey: 'f.pruneDaysHint', placeholderKey: 'p.pruneDays', tab: 'cache' },
285
+ { field: 'cacheTtlDays', spec: numberField('cacheTtlDays'), kind: 'number', labelKey: 'f.cacheTtlDays', hintKey: 'f.cacheTtlDaysHint', placeholderKey: 'p.cacheTtlDays', tab: 'cache' },
286
+ ]
287
+
313
288
  var CardForm = class {
314
289
  constructor(scope, specs) {
315
290
  this.scope = scope
@@ -318,24 +293,25 @@ window.__ModuleLoader__.load({
318
293
  this.listeners = new Set()
319
294
  this.saving = false
320
295
  this.failed = false
321
- scope.subscribe(() => this.publish())
296
+ if (scope && scope.subscribe) {
297
+ scope.subscribe(() => this.publish())
298
+ }
322
299
  }
323
300
  bind(project) {
324
- if (!runtime) {
325
- // Ядро без snapshot-store: отдаём статичный снимок, без подписки.
326
- // Карточка не перерисуется сама, но не уронит загрузчик.
327
- return { getSnapshot: () => project(), subscribe: () => () => {} }
301
+ return {
302
+ getSnapshot: () => project(),
303
+ subscribe: (cb) => {
304
+ this.listeners.add(cb)
305
+ return () => this.listeners.delete(cb)
306
+ },
328
307
  }
329
- const store = runtime.createSnapshotStore(project())
330
- this.listeners.add(() => store.set(project()))
331
- return store
332
308
  }
333
309
  shell() {
334
- const snapshot = this.scope.getSnapshot()
310
+ const snapshot = (this.scope && this.scope.getSnapshot && this.scope.getSnapshot()) || { status: 'ready', writable: true }
335
311
  const plan = this.plan()
336
312
  return {
337
313
  available: snapshot.status === 'ready',
338
- writable: snapshot.writable,
314
+ writable: Boolean(snapshot.writable),
339
315
  dirty: plan.length > 0,
340
316
  invalid: plan.some((item) => item.run === void 0),
341
317
  saving: this.saving,
@@ -345,7 +321,6 @@ window.__ModuleLoader__.load({
345
321
  field(field) {
346
322
  const spec = this.specOf(field)
347
323
  const staged = this.staged.get(field)
348
- const snapshot = this.scope.getSnapshot()
349
324
  if (staged === void 0) {
350
325
  return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
351
326
  }
@@ -378,552 +353,414 @@ window.__ModuleLoader__.load({
378
353
  let landed = true
379
354
  for (const write of writes) landed = (await write()) && landed
380
355
  if (landed) this.staged.clear()
381
- this.saving = false
382
356
  this.failed = !landed
357
+ this.saving = false
383
358
  this.publish()
384
359
  }
385
- plan() {
386
- const plan = []
387
- for (const [field, staged] of this.staged) {
388
- const spec = this.specOf(field)
389
- if (staged.clear) {
390
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
391
- continue
392
- }
393
- if (staged.text === spec.format(this.sectionValue(field))) continue
394
- const write = spec.parse(staged.text)
395
- if (write === void 0) plan.push({ field, run: void 0 })
396
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
397
- else plan.push({ field, run: () => this.store(field, write.value) })
398
- }
399
- return plan
400
- }
401
- async clear(field) {
402
- await this.scope.unset(field)
403
- return !this.stored(field)
404
- }
405
- async store(field, value) {
406
- await this.scope.set(field, value)
407
- return this.userLayer()?.[field] === value
408
- }
409
- stage(field, edit) {
410
- this.staged.set(field, edit)
411
- this.failed = false
412
- this.publish()
360
+ publish() {
361
+ for (const listener of this.listeners) listener()
413
362
  }
414
363
  specOf(field) {
415
364
  const spec = this.specs.get(field)
416
- if (spec === void 0) throw new Error('settings card has no field ' + field)
365
+ if (!spec) throw new Error('dsh-image-gen: unknown field "' + field + '"')
417
366
  return spec
418
367
  }
419
- snapshotOf() {
420
- return this.scope.getSnapshot()
368
+ section() {
369
+ return (this.scope && this.scope.getSnapshot && this.scope.getSnapshot()?.value) || {}
421
370
  }
422
371
  sectionValue(field) {
423
- return this.snapshotOf().value?.[field]
372
+ return this.section()[field]
424
373
  }
425
- baseValue(field) {
426
- return this.snapshotOf().base?.[field]
374
+ stored(field) {
375
+ return this.sectionValue(field) !== void 0
427
376
  }
428
- userLayer() {
429
- return this.snapshotOf().user
377
+ baseValue(_) {
378
+ return void 0
430
379
  }
431
- stored(field) {
432
- const user = this.userLayer()
433
- return user !== void 0 && Object.hasOwn(user, field)
380
+ stage(field, staged) {
381
+ this.specOf(field)
382
+ this.staged.set(field, staged)
383
+ this.publish()
434
384
  }
435
- publish() {
436
- for (const listener of this.listeners) listener()
385
+ plan() {
386
+ const plan = []
387
+ for (const [field, staged] of this.staged) {
388
+ const spec = this.specOf(field)
389
+ const write = staged.clear ? { kind: 'clear' } : spec.parse(staged.text)
390
+ if (!write) {
391
+ plan.push({ field, run: void 0 })
392
+ continue
393
+ }
394
+ plan.push({
395
+ field,
396
+ run: async () => {
397
+ try {
398
+ if (write.kind === 'clear') {
399
+ if (this.scope && this.scope.delete) await this.scope.delete(field)
400
+ } else {
401
+ if (this.scope && this.scope.set) await this.scope.set(field, write.value)
402
+ }
403
+ return true
404
+ } catch (_) {
405
+ return false
406
+ }
407
+ },
408
+ })
409
+ }
410
+ return plan
437
411
  }
438
412
  }
439
413
 
440
- // ------------------------------------------------------------- fields
441
- const IMAGE_SIZES = ['square_hd', 'square', 'portrait_4_3', 'portrait_16_9', 'landscape_4_3', 'landscape_16_9']
442
- // Перевод именованного размера в пиксели для подсказки в карточке
443
- // (значения совпадают с SIZE_PIXELS в хостовой половине).
444
- const SIZE_PIXELS = {
445
- square_hd: '1024x1024',
446
- square: '512x512',
447
- portrait_4_3: '768x1024',
448
- portrait_16_9: '576x1024',
449
- landscape_4_3: '1024x768',
450
- landscape_16_9: '1024x576',
414
+ var FalSettingsCardController = class {
415
+ constructor(scope) {
416
+ this.form = new CardForm(scope, FIELDS.map((entry) => entry.spec))
417
+ this.store = this.form.bind(() => this.projection())
418
+ }
419
+ projection() {
420
+ return {
421
+ ...this.form.shell(),
422
+ ...Object.fromEntries(FIELDS.map((entry) => [entry.field, this.form.field(entry.field)])),
423
+ }
424
+ }
425
+ inject() {
426
+ return { hooks: { falSettingsCard: this.store }, ...this.form.actions() }
427
+ }
451
428
  }
452
- const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
453
- const DELIVERY_MODES = ['link', 'image']
454
- const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
455
-
456
- /** Field specs in display order. */
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
- const STYLE_PRESET_OPTIONS = ['', 'cinematic', 'anime', 'digital_art', 'photographic', 'fantasy', 'isometric', 'cyberpunk', 'watercolor', 'origami', 'oil_painting', 'pixel_art', 'logo']
469
-
470
- const FIELDS = [
471
- { field: 'enabled', spec: booleanField('enabled'), kind: 'select', options: ['true', 'false'], labelKey: 'f.enabled', hintKey: 'f.enabledHint' },
472
- { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint', providerNeeds: { fal: 'f.needFal', custom: 'f.needCustom', codex: 'f.needCodex', grok: 'f.needGrok', local: 'f.needLocal', seedream: 'f.needSeedream', gemini: 'f.needGemini', replicate: 'f.needReplicate' } },
473
-
474
- // FAL
475
- { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model', when: ['fal'] },
476
- { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv', when: ['fal'] },
477
- { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL', when: ['fal'] },
478
- { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs', when: ['fal'] },
479
- { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs', when: ['fal'] },
480
-
481
- // Custom
482
- { field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL', when: ['custom'] },
483
- { field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel', when: ['custom'] },
484
- { field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv', when: ['custom'] },
485
- { field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize', when: ['custom'] },
486
-
487
- // Replicate
488
- { field: 'replicateModel', spec: textField('replicateModel'), kind: 'text', labelKey: 'f.replicateModel', hintKey: 'f.replicateModelHint', placeholderKey: 'p.replicateModel', when: ['replicate'] },
489
- { field: 'replicateKeyEnv', spec: textField('replicateKeyEnv'), kind: 'text', labelKey: 'f.replicateKeyEnv', hintKey: 'f.replicateKeyEnvHint', placeholderKey: 'p.replicateKeyEnv', when: ['replicate'] },
490
-
491
- // SeaDream
492
- { field: 'seedreamModel', spec: textField('seedreamModel'), kind: 'text', labelKey: 'f.seedreamModel', hintKey: 'f.seedreamModelHint', placeholderKey: 'p.seedreamModel', when: ['seedream'] },
493
- { field: 'seedreamKeyEnv', spec: textField('seedreamKeyEnv'), kind: 'text', labelKey: 'f.seedreamKeyEnv', hintKey: 'f.seedreamKeyEnvHint', placeholderKey: 'p.seedreamKeyEnv', when: ['seedream'] },
494
- { field: 'seedreamBaseURL', spec: textField('seedreamBaseURL'), kind: 'text', labelKey: 'f.seedreamBaseURL', hintKey: 'f.seedreamBaseURLHint', placeholderKey: 'p.seedreamBaseURL', when: ['seedream'] },
495
-
496
- // Gemini
497
- { field: 'geminiModel', spec: textField('geminiModel'), kind: 'text', labelKey: 'f.geminiModel', hintKey: 'f.geminiModelHint', placeholderKey: 'p.geminiModel', when: ['gemini'] },
498
- { field: 'geminiKeyEnv', spec: textField('geminiKeyEnv'), kind: 'text', labelKey: 'f.geminiKeyEnv', hintKey: 'f.geminiKeyEnvHint', placeholderKey: 'p.geminiKeyEnv', when: ['gemini'] },
499
-
500
- // Local ComfyUI / A1111
501
- { field: 'localComfyUrl', spec: textField('localComfyUrl'), kind: 'text', labelKey: 'f.localComfyUrl', hintKey: 'f.localComfyUrlHint', placeholderKey: 'p.localComfyUrl', when: ['local'] },
502
- { field: 'localA1111Url', spec: textField('localA1111Url'), kind: 'text', labelKey: 'f.localA1111Url', hintKey: 'f.localA1111UrlHint', placeholderKey: 'p.localA1111Url', when: ['local'] },
503
-
504
- // Common Style & LLM Enhancer
505
- { field: 'stylePreset', spec: selectField('stylePreset', STYLE_PRESET_OPTIONS), kind: 'select', options: STYLE_PRESET_OPTIONS, labelKey: 'f.stylePreset', hintKey: 'f.stylePresetHint' },
506
- { field: 'enableLlmEnhancer', spec: booleanField('enableLlmEnhancer'), kind: 'select', options: ['true', 'false'], labelKey: 'f.enableLlmEnhancer', hintKey: 'f.enableLlmEnhancerHint' },
507
-
508
- // Output & Defaults
509
- { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS },
510
- { field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
511
- { field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
512
- { field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
513
- { field: 'cacheTtlDays', spec: numberField('cacheTtlDays'), kind: 'number', labelKey: 'f.cacheTtlDays', hintKey: 'f.cacheTtlDaysHint', placeholderKey: 'p.cacheTtlDays' },
514
- ]
515
429
 
430
+ // -------------------------------------------------------------- UI Components
516
431
  function Field(props) {
517
432
  const { entry, state, fieldProps, t, onEdit, onReset } = props
518
- const base = {
519
- id: 'dsh-image-gen-' + entry.field,
520
- label: t(entry.labelKey),
521
- hint: t(entry.hintKey),
522
- overriddenLabel: fieldProps.overriddenLabel,
523
- resetLabel: fieldProps.resetLabel,
524
- invalidLabel: fieldProps.invalidLabel,
525
- disabled: fieldProps.disabled,
526
- text: state.text,
527
- overridden: state.overridden,
528
- invalid: state.invalid,
529
- onEdit,
530
- onReset,
531
- }
433
+ const id = 'dsh-image-gen-' + entry.field
434
+ const label = t(entry.labelKey)
435
+ const hint = t(entry.hintKey)
436
+ const overridden = Boolean(state.overridden)
437
+ const invalid = Boolean(state.invalid)
438
+ const disabled = Boolean(fieldProps.disabled)
439
+
532
440
  if (entry.kind === 'select') {
533
- const px = entry.sizePixels && entry.sizePixels[state.text] ? ` — ${entry.sizePixels[state.text]}` : ''
534
- const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ` (${t(entry.providerNeeds[state.text])})` : ''
535
- const hint = base.hint + px + need
536
- return react_jsx_runtime.jsx(SelectField, { ...base, hint, options: entry.options, inheritLabel: t('f.inherit') })
441
+ const px = entry.sizePixels && entry.sizePixels[state.text] ? ' — ' + entry.sizePixels[state.text] : ''
442
+ const need = entry.providerNeeds && entry.providerNeeds[state.text] ? ' (' + t(entry.providerNeeds[state.text]) + ')' : ''
443
+ return react.createElement(
444
+ 'div',
445
+ { className: 'ig-field', key: entry.field },
446
+ react.createElement(
447
+ 'div',
448
+ { className: 'ig-field-head' },
449
+ react.createElement('label', { className: 'ig-field-label', htmlFor: id }, label),
450
+ overridden
451
+ ? react.createElement(
452
+ 'div',
453
+ { className: 'ig-row' },
454
+ react.createElement('span', { className: 'ig-badge ig-badge-warn' }, fieldProps.overriddenLabel),
455
+ react.createElement('button', { type: 'button', className: 'ig-reset', disabled, onClick: onReset }, fieldProps.resetLabel)
456
+ )
457
+ : null
458
+ ),
459
+ react.createElement(
460
+ 'select',
461
+ {
462
+ id,
463
+ className: 'ig-select',
464
+ value: state.text,
465
+ disabled,
466
+ onChange: (e) => onEdit(e.target.value),
467
+ },
468
+ entry.options.map((opt) =>
469
+ react.createElement('option', { key: opt, value: opt }, opt === '' ? t('f.inherit') : opt)
470
+ )
471
+ ),
472
+ react.createElement('p', { className: 'ig-field-hint' }, hint + px + need)
473
+ )
537
474
  }
538
- return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
475
+
476
+ return react.createElement(
477
+ 'div',
478
+ { className: 'ig-field', key: entry.field },
479
+ react.createElement(
480
+ 'div',
481
+ { className: 'ig-field-head' },
482
+ react.createElement('label', { className: 'ig-field-label', htmlFor: id }, label),
483
+ overridden
484
+ ? react.createElement(
485
+ 'div',
486
+ { className: 'ig-row' },
487
+ react.createElement('span', { className: 'ig-badge ig-badge-warn' }, fieldProps.overriddenLabel),
488
+ react.createElement('button', { type: 'button', className: 'ig-reset', disabled, onClick: onReset }, fieldProps.resetLabel)
489
+ )
490
+ : null
491
+ ),
492
+ react.createElement('input', {
493
+ id,
494
+ className: invalid ? 'ig-input ig-input-invalid' : 'ig-input',
495
+ type: 'text',
496
+ ...(entry.kind === 'number' ? { inputMode: 'numeric' } : {}),
497
+ value: state.text,
498
+ placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '',
499
+ disabled,
500
+ onChange: (e) => onEdit(e.target.value),
501
+ }),
502
+ react.createElement('p', { className: invalid ? 'ig-field-err' : 'ig-field-hint' }, invalid ? fieldProps.invalidLabel : hint)
503
+ )
539
504
  }
540
505
 
541
506
  function FalSettingsCard(props) {
542
507
  const t = props.t || ((k) => k)
508
+ const [open, setOpen] = react.useState(props.defaultOpen ?? true)
509
+ const [activeTab, setActiveTab] = react.useState('general')
510
+
511
+ react.useEffect(() => {
512
+ ensureCss()
513
+ }, [])
514
+
543
515
  const state = (typeof props.useFalSettingsCard === 'function'
544
- ? props.useFalSettingsCard((snapshot) => snapshot)
516
+ ? props.useFalSettingsCard((s) => s)
545
517
  : null) || props.state || { available: true, writable: true, provider: { text: 'fal' } }
518
+
546
519
  const disabled = !state.writable
547
520
  const currentProvider = (state.provider && state.provider.text) || 'fal'
548
- const visibleFields = FIELDS.filter((entry) => !entry.when || entry.when.includes(currentProvider))
521
+ const blocked = !state.dirty || state.invalid || state.saving
522
+
549
523
  const fieldProps = {
550
524
  overriddenLabel: t('settings.overridden'),
551
525
  resetLabel: t('settings.reset'),
552
526
  invalidLabel: t('settings.invalidNumber'),
553
527
  disabled,
554
528
  }
555
- return react_jsx_runtime.jsx(PluginSettingsCard, {
556
- t,
557
- titleKey: 'settings.title',
558
- descriptionKey: 'settings.description',
559
- state,
560
- onSave: props.save,
561
- onDiscard: props.discard,
562
- children: [
563
- react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'credHint', children: t('settings.credentialHint') }),
564
- visibleFields.map((entry) =>
565
- react_jsx_runtime.jsx(
566
- Field,
567
- {
568
- entry,
569
- state: state[entry.field] ?? { text: '', overridden: false, invalid: false },
570
- fieldProps,
571
- t,
572
- onEdit: (text) => props.edit && props.edit(entry.field, text),
573
- onReset: () => props.resetField && props.resetField(entry.field),
574
- },
575
- entry.field,
576
- ),
577
- ),
578
- ],
579
- })
580
- }
581
529
 
582
- /** Bridges the `dsh-image-gen` scope onto the card's staged form. */
583
- var FalSettingsCardController = class {
584
- constructor(scope) {
585
- this.form = new CardForm(scope, FIELDS.map((entry) => entry.spec))
586
- this.store = this.form.bind(() => this.projection())
587
- }
588
- projection() {
589
- return {
590
- ...this.form.shell(),
591
- ...Object.fromEntries(FIELDS.map((entry) => [entry.field, this.form.field(entry.field)])),
592
- }
593
- }
594
- inject() {
595
- return { hooks: { falSettingsCard: this.store }, ...this.form.actions() }
530
+ if (!state.available) {
531
+ return react.createElement(
532
+ 'li',
533
+ { className: 'ig-section-card', style: { listStyle: 'none' } },
534
+ react.createElement('p', { className: 'ig-field-hint' }, t('settings.unavailable'))
535
+ )
596
536
  }
597
- }
598
-
599
- // -------------------------------------------------------------- apply
600
- const NS = 'dsh-image-gen'
601
- /** Settings namespace the card edits (the Host plugin registers it). */
602
- const SETTINGS_NS = 'dsh-image-gen'
603
- /** Required client services. */
604
- const inject = ['slots', 'settingsScope', 'locale', 'sessions']
605
-
606
- const en = {
607
- 'settings.navLabel': 'Image generation',
608
- 'settings.title': 'Image generation',
609
- 'settings.description': 'generate_image tool. Provider: the FAL queue, or any OpenAI-compatible images API.',
610
- 'settings.unavailable': 'The harness has not published this plugin\u2019s settings yet. If it has just restarted, the section appears on its own in a few seconds.',
611
- 'settings.credentialHint': 'API keys live in Credentials under the references set below (Settings → Credentials).',
612
- 'settings.expand': 'Expand',
613
- 'settings.collapse': 'Collapse',
614
- 'settings.unsaved': 'Unsaved changes',
615
- 'settings.readOnly': 'This deployment is read-only: settings cannot be changed from the GUI.',
616
- 'settings.overridden': 'Override',
617
- 'settings.reset': 'Reset',
618
- 'settings.invalidNumber': 'Enter a valid number',
619
- 'settings.discard': 'Discard',
620
- 'settings.save': 'Save',
621
- 'settings.saving': 'Saving…',
622
- 'settings.saveFailed': 'Save failed',
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',
671
- 'f.provider': 'Provider',
672
- 'f.providerHint': 'fal — the FAL queue configured below. custom — any OpenAI-compatible API, configured further down. '
673
- + 'codex and grok — through a subscription you are logged into, no key needed here.',
674
- 'f.model': 'Model (FAL)',
675
- 'f.modelHint': 'FAL model id, called as {baseURL}/{model}. Used when provider is fal.',
676
- 'f.apiKeyEnv': 'API key reference',
677
- 'f.apiKeyEnvHint': 'Credential reference / env var holding the FAL API key. The "Key " auth prefix is added automatically.',
678
- 'f.baseURL': 'Queue base URL',
679
- 'f.baseURLHint': 'FAL queue base URL (queue.fal.run).',
680
- 'f.defaultSize': 'Default image size',
681
- 'f.defaultSizeHint': 'Used when the tool call omits image_size.',
682
- 'f.defaultFormat': 'Default format',
683
- 'f.defaultFormatHint': 'Used when the tool call omits output_format.',
684
- 'f.pollIntervalMs': 'Poll interval (ms)',
685
- 'f.pollIntervalMsHint': 'Status polling interval while a job runs.',
686
- 'f.timeoutMs': 'Timeout (ms)',
687
- 'f.timeoutMsHint': 'Total generation timeout: submit + poll + download.',
688
- 'f.deliverAs': 'How the image reaches the chat',
689
- 'f.deliverAsHint': 'link — the result is text with a link and the card renders the picture from it; works with any chat model. image — the result carries the picture itself, which a text-only chat model cannot read, so this needs dsh-vision-bridge or a vision-capable model.',
690
- 'f.customBaseURL': 'Custom API: base URL',
691
- 'f.customBaseURLHint': 'API root without a trailing slash. The request goes to {base}/images/generations.',
692
- 'f.customModel': 'Custom API: model',
693
- 'f.customModelHint': 'Model id sent to that API, e.g. gpt-image-1.',
694
- 'f.customKeyEnv': 'Custom API: key reference',
695
- 'f.customKeyEnvHint': 'Credential reference / env var with the API key. Empty means no authorization header, for gateways that need none.',
696
- 'f.customSize': 'Custom API: fixed size',
697
- 'f.customSizeHint': 'Leave empty and the named size is translated automatically (square_hd → 1024x1024). Set it only for an API picky about sizes.',
698
- 'f.outputDir': 'Output directory',
699
- 'f.outputDirHint': 'Relative to the session working directory; an absolute path is used as given.',
700
- 'p.model': 'Example: fal-ai/flux-2/klein/9b',
701
- 'p.apiKeyEnv': 'Example: FAL_API_KEY',
702
- 'p.baseURL': 'Example: https://queue.fal.run',
703
- 'p.pollIntervalMs': 'Example: 2000',
704
- 'p.timeoutMs': 'Example: 180000',
705
- 'p.customBaseURL': 'Example: https://api.openai.com/v1',
706
- 'p.customModel': 'Example: gpt-image-1',
707
- 'p.customKeyEnv': 'Example: OPENAI_API_KEY',
708
- 'p.customSize': 'Example: 1024x1024',
709
- 'p.outputDir': 'Example: generated/images',
710
- 'history.title': 'Generation history',
711
- 'history.all': 'All providers',
712
- 'history.empty': 'No generations yet',
713
- 'history.loading': 'Loading…',
714
- }
715
537
 
716
- const ru = {
717
- 'settings.navLabel': 'Генерация изображений',
718
- 'settings.title': 'Генерация изображений',
719
- 'settings.description': 'Инструмент generate_image. Провайдер: очередь FAL или любой OpenAI-совместимый API картинок.',
720
- 'settings.unavailable': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, раздел появится сам через несколько секунд.',
721
- 'settings.credentialHint': 'Ключи API хранятся в Credentials под указанными ссылками (Настройки → Credentials).',
722
- 'settings.expand': 'Развернуть',
723
- 'settings.collapse': 'Свернуть',
724
- 'settings.unsaved': 'Есть несохранённые изменения',
725
- 'settings.readOnly': 'Этот деплой read-only: настройки нельзя менять из GUI.',
726
- 'settings.overridden': 'Переопределено',
727
- 'settings.reset': 'Сбросить',
728
- 'settings.invalidNumber': 'Введите корректное число',
729
- 'settings.discard': 'Отменить',
730
- 'settings.save': 'Сохранить',
731
- 'settings.saving': 'Сохранение…',
732
- 'settings.saveFailed': 'Не удалось сохранить',
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',
781
- 'f.provider': 'Провайдер',
782
- 'f.providerHint': 'fal — очередь FAL, настроенная ниже. custom — любой OpenAI-совместимый API, настраивается дальше. '
783
- + 'codex и grok — через подписку, в которую вы вошли; ключ здесь не нужен.',
784
- 'f.model': 'Модель (FAL)',
785
- 'f.modelHint': 'ID модели FAL, вызывается как {baseURL}/{model}. Используется при провайдере fal.',
786
- 'f.apiKeyEnv': 'Ссылка на ключ API',
787
- 'f.apiKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом FAL. Префикс "Key " добавляется автоматически.',
788
- 'f.baseURL': 'Базовый URL очереди',
789
- 'f.baseURLHint': 'Базовый URL FAL queue (queue.fal.run).',
790
- 'f.defaultSize': 'Размер по умолчанию',
791
- 'f.defaultSizeHint': 'Используется, когда вызов инструмента не указывает image_size.',
792
- 'f.defaultFormat': 'Формат по умолчанию',
793
- 'f.defaultFormatHint': 'Используется, когда вызов инструмента не указывает output_format.',
794
- 'f.pollIntervalMs': 'Интервал опроса (мс)',
795
- 'f.pollIntervalMsHint': 'Интервал опроса статуса задания.',
796
- 'f.timeoutMs': 'Таймаут (мс)',
797
- 'f.timeoutMsHint': 'Общий таймаут генерации: submit + poll + download.',
798
- 'f.deliverAs': 'Как картинка попадает в чат',
799
- 'f.deliverAsHint': 'link — в результате текст со ссылкой, карточка рисует картинку по ней; работает с любой чат-моделью. image — в результате сама картинка, текстовая модель её прочитать не может, поэтому нужен dsh-vision-bridge или модель с vision.',
800
- 'f.customBaseURL': 'Свой API: базовый URL',
801
- 'f.customBaseURLHint': 'Корень API без завершающего слэша. Запрос уходит на {base}/images/generations.',
802
- 'f.customModel': 'Свой API: модель',
803
- 'f.customModelHint': 'ID модели для этого API, например gpt-image-1.',
804
- 'f.customKeyEnv': 'Свой API: ссылка на ключ',
805
- 'f.customKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом. Пусто — без заголовка авторизации, для шлюзов, которым он не нужен.',
806
- 'f.customSize': 'Свой API: фиксированный размер',
807
- 'f.customSizeHint': 'Пусто — именованный размер переводится сам (square_hd → 1024x1024). Заполняйте только для API, придирчивого к размерам.',
808
- 'f.outputDir': 'Папка вывода',
809
- 'f.outputDirHint': 'Относительно рабочей папки сессии; абсолютный путь используется как есть.',
810
- 'p.model': 'Пример: fal-ai/flux-2/klein/9b',
811
- 'p.apiKeyEnv': 'Пример: FAL_API_KEY',
812
- 'p.baseURL': 'Пример: https://queue.fal.run',
813
- 'p.pollIntervalMs': 'Пример: 2000',
814
- 'p.timeoutMs': 'Пример: 180000',
815
- 'p.customBaseURL': 'Пример: https://api.openai.com/v1',
816
- 'p.customModel': 'Пример: gpt-image-1',
817
- 'p.customKeyEnv': 'Пример: OPENAI_API_KEY',
818
- 'p.customSize': 'Пример: 1024x1024',
819
- 'p.outputDir': 'Пример: generated/images',
820
- 'history.title': 'История генераций',
821
- 'history.all': 'Все провайдеры',
822
- 'history.empty': 'Ещё нет генераций',
823
- 'history.loading': 'Загрузка…',
824
- }
538
+ const tabs = [
539
+ { id: 'general', label: t('tab.general'), icon: '⚙️' },
540
+ { id: 'provider', label: t('tab.provider') + ' (' + currentProvider.toUpperCase() + ')', icon: '🔌' },
541
+ { id: 'enhancer', label: t('tab.enhancer'), icon: '✨' },
542
+ { id: 'safety', label: t('tab.safety'), icon: '🛡️' },
543
+ { id: 'cache', label: t('tab.cache'), icon: '⚡' },
544
+ ]
545
+
546
+ const currentTabFields = FIELDS.filter((entry) => {
547
+ if (entry.tab !== activeTab) return false
548
+ if (entry.when && !entry.when.includes(currentProvider)) return false
549
+ return true
550
+ })
825
551
 
826
- // ── карточка результата generate_image ────────────────────────────────
827
- //
828
- // Карточки инструментов не рисуют image-блоки (это умеют только сообщения
829
- // ассистента), поэтому сгенерированная картинка показывается здесь —
830
- // keyed-запись слота tool.call.toolview по имени инструмента.
831
- const EXTRA_CARD_CSS = '.fal-btn-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px}.fal-action-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);border-radius:6px;padding:3px 10px;font-size:12px;display:inline-flex;align-items:center;gap:4px}.fal-action-btn:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed);background:var(--dsw-alias-bg-layer-3)}.fal-action-btn:disabled{opacity:.5;cursor:default}.fal-diff-slider{position:relative;width:100%;overflow:hidden;border-radius:8px;user-select:none;aspect-ratio:1/1;max-height:400px;background:var(--dsw-alias-bg-layer-3)}.fal-diff-img{position:absolute;top:0;left:0;width:100%;height:100%;object-fit:contain;pointer-events:none}.fal-diff-clipped{position:absolute;top:0;left:0;width:100%;height:100%;overflow:hidden;pointer-events:none}.fal-diff-handle{position:absolute;top:0;bottom:0;width:2px;background:#fff;box-shadow:0 0 6px rgba(0,0,0,0.5);pointer-events:none}.fal-diff-handle-circle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:24px;height:24px;background:#fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;font-size:10px;color:#333}.fal-canvas-box{position:relative;border-radius:8px;overflow:hidden;margin-top:6px;background:#000}.fal-inpaint-canvas{position:absolute;top:0;left:0;cursor:crosshair;touch-action:none}'
832
- const CARD_CSS = EXTRA_CARD_CSS + '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}' + '.fal-repeat{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-3);border-radius:8px;padding:5px 14px;font-size:13px}' + '.fal-repeat:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed)}' + '.fal-repeat:disabled{opacity:.5;cursor:default}'
833
- + '.fal-card .fal-head{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}'
834
- + '.fal-card img{max-width:100%;border-radius:8px;display:block}'
835
- + '.fal-card .fal-prompt{font-size:12px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}'
836
- + '.fal-card .fal-meta{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}'
837
- + '.fal-card .fal-err{font-size:12px;color:var(--dsw-alias-state-error-primary)}'
838
- const CARD_CSS_ID = 'dsh-image-gen/tool-card.module.css'
839
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + CARD_CSS_ID + '"]')) {
840
- const tag = document.createElement('style')
841
- tag.textContent = CARD_CSS
842
- tag.setAttribute('data-plugin', 'dsh-image-gen')
843
- tag.dataset.pluginCss = CARD_CSS_ID
844
- document.head.appendChild(tag)
552
+ const diskCacheOn = state.diskCache && state.diskCache.text !== 'false'
553
+ const qualityGateOn = state.qualityGate && state.qualityGate.text !== 'false'
554
+ const budgetText = (state.dailyBudgetUsd && state.dailyBudgetUsd.text) || '0'
555
+ const loopLimit = (state.loopGuardLimit && state.loopGuardLimit.text) || '3'
556
+
557
+ return react.createElement(
558
+ 'li',
559
+ { className: 'ig-section-card', style: { listStyle: 'none', marginBottom: '12px' } },
560
+ // Header
561
+ react.createElement(
562
+ 'button',
563
+ {
564
+ type: 'button',
565
+ style: {
566
+ background: 'none',
567
+ border: 'none',
568
+ cursor: 'pointer',
569
+ display: 'flex',
570
+ alignItems: 'center',
571
+ width: '100%',
572
+ padding: 0,
573
+ textAlign: 'left',
574
+ },
575
+ 'aria-expanded': open,
576
+ onClick: () => setOpen(!open),
577
+ },
578
+ react.createElement(
579
+ 'div',
580
+ { style: { flex: 1 } },
581
+ react.createElement(
582
+ 'div',
583
+ { style: { fontWeight: 700, fontSize: '16px', display: 'flex', alignItems: 'center', gap: '8px' } },
584
+ '🎨 ' + t('settings.title')
585
+ ),
586
+ react.createElement(
587
+ 'div',
588
+ { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary)' } },
589
+ t('settings.description')
590
+ )
591
+ ),
592
+ state.dirty
593
+ ? react.createElement('span', { className: 'ig-badge ig-badge-warn', style: { marginRight: '8px' } }, t('settings.unsaved'))
594
+ : null,
595
+ react.createElement(
596
+ 'span',
597
+ { style: { transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' } },
598
+ ChevronIconNode
599
+ )
600
+ ),
601
+ open
602
+ ? react.createElement(
603
+ 'div',
604
+ { className: 'ig-page' },
605
+ // Quick Stats Row
606
+ react.createElement(
607
+ 'div',
608
+ { className: 'ig-grid-4' },
609
+ react.createElement(
610
+ 'div',
611
+ { className: 'ig-stat-box' },
612
+ react.createElement('div', { className: 'ig-stat-val' }, currentProvider.toUpperCase()),
613
+ react.createElement('div', { className: 'ig-stat-lbl' }, t('stat.active_provider'))
614
+ ),
615
+ react.createElement(
616
+ 'div',
617
+ { className: 'ig-stat-box' },
618
+ react.createElement('div', { className: 'ig-stat-val' }, ((state.defaultSize && state.defaultSize.text) || '4:3') + ' · ' + ((state.defaultFormat && state.defaultFormat.text) || 'png')),
619
+ react.createElement('div', { className: 'ig-stat-lbl' }, t('stat.format_size'))
620
+ ),
621
+ react.createElement(
622
+ 'div',
623
+ { className: 'ig-stat-box' },
624
+ react.createElement(
625
+ 'div',
626
+ { className: 'ig-stat-val' },
627
+ react.createElement(
628
+ 'span',
629
+ { className: qualityGateOn ? 'ig-badge ig-badge-ok' : 'ig-badge ig-badge-warn' },
630
+ qualityGateOn ? 'Gate Active' : 'Off'
631
+ ),
632
+ ' ',
633
+ react.createElement('span', { className: 'ig-badge ig-badge-ok' }, 'Guard ' + loopLimit)
634
+ ),
635
+ react.createElement('div', { className: 'ig-stat-lbl' }, t('stat.safety_status'))
636
+ ),
637
+ react.createElement(
638
+ 'div',
639
+ { className: 'ig-stat-box' },
640
+ react.createElement(
641
+ 'div',
642
+ { className: 'ig-stat-val' },
643
+ react.createElement(
644
+ 'span',
645
+ { className: diskCacheOn ? 'ig-badge ig-badge-ok' : 'ig-badge ig-badge-warn' },
646
+ diskCacheOn ? 'Cache ON' : 'Off'
647
+ ),
648
+ ' ',
649
+ budgetText !== '0' ? '$' + budgetText : 'No limit'
650
+ ),
651
+ react.createElement('div', { className: 'ig-stat-lbl' }, t('stat.budget_cache'))
652
+ )
653
+ ),
654
+ // Tab Navigation
655
+ react.createElement(
656
+ 'div',
657
+ { className: 'ig-tabs' },
658
+ tabs.map((tab) =>
659
+ react.createElement(
660
+ 'button',
661
+ {
662
+ key: tab.id,
663
+ type: 'button',
664
+ className: activeTab === tab.id ? 'ig-tab-btn ig-tab-btn-active' : 'ig-tab-btn',
665
+ onClick: () => setActiveTab(tab.id),
666
+ },
667
+ tab.icon + ' ' + tab.label
668
+ )
669
+ )
670
+ ),
671
+ // Active Tab Fields
672
+ react.createElement(
673
+ 'div',
674
+ { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
675
+ currentTabFields.map((entry) =>
676
+ react.createElement(Field, {
677
+ key: entry.field,
678
+ entry,
679
+ state: state[entry.field] ?? { text: '', overridden: false, invalid: false },
680
+ fieldProps,
681
+ t,
682
+ onEdit: (val) => props.edit && props.edit(entry.field, val),
683
+ onReset: () => props.resetField && props.resetField(entry.field),
684
+ })
685
+ )
686
+ ),
687
+ // Footer Action Bar
688
+ react.createElement(
689
+ 'div',
690
+ {
691
+ style: {
692
+ display: 'flex',
693
+ justifyContent: 'flex-end',
694
+ alignItems: 'center',
695
+ gap: '10px',
696
+ paddingTop: '12px',
697
+ borderTop: '1px solid var(--dsw-alias-border-l2)',
698
+ },
699
+ },
700
+ state.failed
701
+ ? react.createElement('span', { className: 'ig-alert-err', style: { marginRight: 'auto', padding: '4px 10px' } }, t('settings.saveFailed'))
702
+ : null,
703
+ react.createElement(
704
+ 'button',
705
+ {
706
+ type: 'button',
707
+ className: 'ig-btn',
708
+ disabled: !state.dirty || state.saving,
709
+ onClick: props.discard,
710
+ },
711
+ t('settings.discard')
712
+ ),
713
+ react.createElement(
714
+ 'button',
715
+ {
716
+ type: 'button',
717
+ className: 'ig-btn ig-btn-primary',
718
+ disabled: blocked,
719
+ onClick: props.save,
720
+ },
721
+ t(state.saving ? 'settings.saving' : 'settings.save')
722
+ )
723
+ )
724
+ )
725
+ : null
726
+ )
845
727
  }
846
728
 
847
- /**
848
- * Вытащить из результата всё, чем можно показать картинку.
849
- *
850
- * В режиме «image» она приходит блоком вложения. В режиме «link» результат
851
- * намеренно только текстовый — иначе чат-модель без vision на нём падает —
852
- * и ссылка на наш же роут разбирается отсюда.
853
- */
854
- // Старое имя оставлено намеренно: ссылки в уже отправленных сообщениях
855
- // указывают на прежний маршрут, и без него картинки в истории разговоров
856
- // перестали бы показываться.
857
- const IMAGE_URL_RE = /\/dsh-(?:fal-)?image-gen\/image\?[^\s)]+/
729
+ // -------------------------------------------------------------- Toolview Component
730
+ const IMAGE_URL_RE = /https?:\/\/[^\s)]+\.(png|jpg|jpeg|webp|gif)(\?[^\s)]*)?/i
858
731
 
859
732
  function readResult(block) {
860
- const content = block && Array.isArray(block.content) ? block.content : []
861
- let attachment
862
- let text = ''
863
- for (const part of content) {
864
- if (part && part.type === 'image' && part.attachment) {
865
- attachment = part.attachment
866
- } else if (part && part.type === 'text' && typeof part.text === 'string') {
867
- text += (text ? String.fromCharCode(10) : '') + part.text
868
- }
869
- }
733
+ if (!block) return { text: '', url: '', attachment: null }
734
+ const text = block.output || block.text || ''
735
+ const attachment = block.attachment || (block.attachments && block.attachments[0]) || null
870
736
  const linked = text.match(IMAGE_URL_RE)
871
737
  return {
872
- attachment: attachment,
738
+ attachment,
873
739
  url: linked ? linked[0] : '',
874
- // Ссылку из подписи убираем: она уже стала картинкой.
875
740
  text: linked ? text.replace(linked[0], '').trim() : text,
876
741
  }
877
742
  }
878
743
 
879
- function FalImageCard(props) {
744
+ function FalImageCard(props) {
880
745
  const block = props.block
881
746
  const running = !('kind' in (block || {}))
882
747
  const failed = block && (block.isError || block.error !== undefined)
883
748
  const parsed = readResult(block)
749
+ const t = props.t || ((k) => k)
884
750
 
885
- let args = {}
886
- try {
887
- const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
888
- if (raw) args = JSON.parse(raw)
889
- } catch (e) { /* ignore */ }
751
+ react.useEffect(() => {
752
+ ensureCss()
753
+ }, [])
890
754
 
755
+ let args = {}
891
756
  let prompt = ''
892
757
  try {
893
758
  const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
894
- prompt = raw ? (JSON.parse(raw).prompt || '') : ''
895
- } catch (e) { prompt = '' }
896
-
897
- const t = props.t || ((k) => k)
898
- const repeatText = (() => {
899
- const parts = [String(args.prompt || '').trim()]
900
- const size = args.image_size || args.size
901
- const seed = args.seed
902
- if (size) parts.push(`${t('card.sizePrefix')}: ${size}`)
903
- if (seed !== undefined) parts.push(`${t('card.seedPrefix')}: ${seed}`)
904
- return parts.filter(Boolean).join(', ')
905
- })()
906
-
907
- const [editing, setEditing] = react.useState(false)
908
- const [editText, setEditText] = react.useState('')
909
-
910
- const onEditPrompt = () => {
911
- const textToInsert = prompt || repeatText || ''
912
- if (typeof document !== 'undefined') {
913
- const input = document.querySelector('textarea, [contenteditable="true"], input[type="text"]')
914
- if (input) {
915
- if (input.tagName === 'TEXTAREA' || input.tagName === 'INPUT') {
916
- input.value = textToInsert
917
- input.dispatchEvent(new Event('input', { bubbles: true }))
918
- input.focus()
919
- } else if (input.isContentEditable) {
920
- input.innerText = textToInsert
921
- input.focus()
922
- }
923
- }
759
+ if (raw) {
760
+ args = JSON.parse(raw)
761
+ prompt = args.prompt || ''
924
762
  }
925
- onCopyPrompt()
926
- }
763
+ } catch (_) {}
927
764
 
928
765
  const [copied, setCopied] = react.useState(false)
929
766
 
@@ -934,7 +771,7 @@ function FalImageCard(props) {
934
771
  const binding = current && sessions.binding(current)
935
772
  const session = binding && binding.session
936
773
  if (session && session.prompt) await session.prompt([{ type: 'text', text }], 'queue')
937
- } catch (e) { /* error handled by host */ }
774
+ } catch (_) {}
938
775
  }
939
776
 
940
777
  const onCopyPrompt = () => {
@@ -945,57 +782,30 @@ function FalImageCard(props) {
945
782
  }
946
783
  }
947
784
 
948
- const onCopySeed = () => {
949
- if (args.seed !== undefined && typeof navigator !== 'undefined' && navigator.clipboard) {
950
- navigator.clipboard.writeText(String(args.seed))
951
- setCopied(true)
952
- setTimeout(() => setCopied(false), 2000)
953
- }
954
- }
955
-
956
785
  const onReroll = () => {
957
- const nextSeed = (typeof args.seed === 'number' ? args.seed + 1 : Math.floor(Math.random() * 100000))
958
- sendActionPrompt(`${t('card.actionReroll')} (${nextSeed}): ${prompt || repeatText}`)
786
+ const nextSeed = typeof args.seed === 'number' ? args.seed + 1 : Math.floor(Math.random() * 100000)
787
+ sendActionPrompt(t('card.actionReroll') + ' (' + nextSeed + '): ' + (prompt || ''))
959
788
  }
960
789
 
961
790
  const onUpscale = () => {
962
- const ref = parsed.attachment ? parsed.attachment.attachmentId : (parsed.url || '')
963
- sendActionPrompt(`${t('card.actionUpscale')}: upscale_image(image: "${ref}")`)
791
+ const ref = parsed.attachment ? (parsed.attachment.attachmentId || parsed.attachment.id) : (parsed.url || '')
792
+ sendActionPrompt(t('card.actionUpscale') + ': upscale_image(image: "' + ref + '")')
964
793
  }
965
794
 
966
795
  const onRemoveBg = () => {
967
- const ref = parsed.attachment ? parsed.attachment.attachmentId : (parsed.url || '')
968
- sendActionPrompt(`${t('card.actionRemoveBg')}: remove_background(image: "${ref}")`)
796
+ const ref = parsed.attachment ? (parsed.attachment.attachmentId || parsed.attachment.id) : (parsed.url || '')
797
+ sendActionPrompt(t('card.actionRemoveBg') + ': remove_background(image: "' + ref + '")')
969
798
  }
970
799
 
971
-
972
-
973
- const head = react.createElement('div', { className: 'fal-head' },
974
- running ? t('card.generating') : failed ? t('card.failed') : t('card.image'))
975
-
976
- // «Повторить генерацию»: отправляет в чат новое сообщение с теми же
977
- // промптом и параметрами, извлечёнными из исходного вызова инструмента.
978
- // Дальше модель сама решает, как сгенерировать, — это просто повторный
979
- // запрос, а не вызов инструмента из карточки.
980
-
981
- const repeat = async () => {
982
- try {
983
- const sessions = props.sessions
984
- const current = sessions && sessions.list && sessions.list.current
985
- const binding = current && sessions.binding(current)
986
- const session = binding && binding.session
987
- if (!session || !session.prompt) {
988
- return
989
- }
990
- const text = repeatText || String(args.prompt || '')
991
- await session.prompt([{ type: 'text', text }], 'queue')
992
- } catch (e) { /* контур сам покажет ошибку отправки */ }
993
- }
800
+ const head = react.createElement(
801
+ 'div',
802
+ { className: 'fal_head' },
803
+ running ? '⏳ ' + t('card.generating') : failed ? '❌ ' + t('card.failed') : '🖼️ ' + t('card.image')
804
+ )
994
805
 
995
806
  const body = []
996
- if (prompt) body.push(react.createElement('div', { className: 'fal-prompt', key: 'p' }, prompt))
997
- if (args.originalPrompt && args.originalPrompt !== prompt) {
998
- body.push(react.createElement('div', { className: 'fal-meta', key: 'orig' }, `${t('card.origPrompt')}: ${args.originalPrompt}`))
807
+ if (prompt) {
808
+ body.push(react.createElement('div', { className: 'fal-prompt', key: 'p' }, prompt))
999
809
  }
1000
810
  if (failed) {
1001
811
  body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || t('card.unknownError')))
@@ -1005,11 +815,9 @@ function FalImageCard(props) {
1005
815
  src: parsed.url,
1006
816
  alt: prompt || 'generated image',
1007
817
  loading: 'lazy',
818
+ style: { maxWidth: '100%', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)' },
1008
819
  }))
1009
- if (parsed.text) body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
1010
820
  } else if (parsed.attachment) {
1011
- // The attachment store verifies the whole reference, so the card hands
1012
- // back the metadata the tool result already carries.
1013
821
  const a = parsed.attachment
1014
822
  const query = 'id=' + encodeURIComponent(a.attachmentId ?? a.id ?? '')
1015
823
  + '&mt=' + encodeURIComponent(a.mediaType || 'image/png')
@@ -1021,243 +829,416 @@ function FalImageCard(props) {
1021
829
  src: '/dsh-image-gen/image?' + query,
1022
830
  alt: prompt || 'generated image',
1023
831
  loading: 'lazy',
832
+ style: { maxWidth: '100%', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)' },
1024
833
  }))
1025
- if (parsed.text) body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
1026
- } else if (!running && parsed.text) {
1027
- body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
1028
834
  }
1029
835
 
1030
- // Ссылка для скачивания: та же, что у картинки (наш роут или провайдерский url).
1031
- const downloadUrl = parsed.attachment
1032
- ? '/dsh-image-gen/image?id=' + encodeURIComponent(parsed.attachment.attachmentId ?? parsed.attachment.id ?? '')
1033
- + '&mt=' + encodeURIComponent(parsed.attachment.mediaType || 'image/png')
1034
- + '&b=' + encodeURIComponent(String(parsed.attachment.bytes ?? 0))
1035
- + '&w=' + encodeURIComponent(String(parsed.attachment.width ?? 0))
1036
- + '&h=' + encodeURIComponent(String(parsed.attachment.height ?? 0))
1037
- : parsed.url
1038
-
1039
- // Диагностика: метаданные генерации (prompt/seed/size/format), чтобы было
1040
- // понятно, по каким параметрам получилась именно эта картинка.
1041
- const metaParts = []
1042
- if (args.image_size || args.size) metaParts.push(`size ${args.image_size || args.size}`)
1043
- if (args.output_format || args.format) metaParts.push(`format ${args.output_format || args.format}`)
1044
- if (args.seed !== undefined) metaParts.push(`seed ${args.seed}`)
1045
- const meta = metaParts.join(' · ')
1046
-
1047
-
1048
- if (!running && !failed) {
1049
- const btns = []
1050
- btns.push(react.createElement('button', {
1051
- key: 'btn-reroll',
1052
- type: 'button',
1053
- className: 'fal-action-btn',
1054
- onClick: onReroll,
1055
- title: t('card.rerollTitle')
1056
- }, '🔄 Re-roll'))
1057
-
1058
- btns.push(react.createElement('button', {
1059
- key: 'btn-up',
1060
- type: 'button',
1061
- className: 'fal-action-btn',
1062
- onClick: onUpscale,
1063
- title: t('card.upscaleTitle')
1064
- }, '🔍 2x Upscale'))
1065
-
1066
- btns.push(react.createElement('button', {
1067
- key: 'btn-nobg',
1068
- type: 'button',
1069
- className: 'fal-action-btn',
1070
- onClick: onRemoveBg,
1071
- title: t('card.removeBgTitle')
1072
- }, '✂️ Remove BG'))
1073
-
1074
- if (prompt) {
1075
- btns.push(react.createElement('button', {
1076
- key: 'btn-copy-p',
1077
- type: 'button',
1078
- className: 'fal-action-btn',
1079
- onClick: onCopyPrompt,
1080
- title: t('card.copyPromptTitle')
1081
- }, copied ? t('card.copied') : t('card.copyPrompt')))
1082
- }
1083
-
1084
- if (args.seed !== undefined) {
1085
- btns.push(react.createElement('button', {
1086
- key: 'btn-copy-s',
1087
- type: 'button',
1088
- className: 'fal-action-btn',
1089
- onClick: onCopySeed,
1090
- title: t('card.copySeedTitle')
1091
- }, `🎲 ${t('card.seedPrefix')}: ${args.seed}`))
1092
- }
1093
-
1094
- body.push(react.createElement('div', { key: 'action-row', className: 'fal-btn-row' }, btns))
836
+ const actions = []
837
+ if (!running && !failed && (parsed.url || parsed.attachment)) {
838
+ actions.push(
839
+ react.createElement('button', { key: 'reroll', type: 'button', className: 'ig-btn', onClick: onReroll }, '🎲 ' + t('card.reroll')),
840
+ react.createElement('button', { key: 'upscale', type: 'button', className: 'ig-btn', onClick: onUpscale }, '🔍 ' + t('card.upscale')),
841
+ react.createElement('button', { key: 'removeBg', type: 'button', className: 'ig-btn', onClick: onRemoveBg }, '✂️ ' + t('card.removeBg')),
842
+ react.createElement('button', { key: 'copy', type: 'button', className: 'ig-btn', onClick: onCopyPrompt }, copied ? '✓ Copied' : '📋 ' + t('card.copyPrompt'))
843
+ )
1095
844
  }
1096
845
 
1097
- // Кнопка повтора — только когда генерация завершена (не в процессе и не ошибка).
1098
- if (!running && !failed && meta) {
1099
- body.push(react.createElement('div', { key: 'meta', className: 'fal-meta' }, meta))
1100
- }
1101
- if (!running && !failed && repeatText) {
1102
- body.push(react.createElement('button', {
1103
- key: 'repeat',
1104
- type: 'button',
1105
- className: 'fal-repeat',
1106
- onClick: repeat,
1107
- }, t('card.repeatGen')))
1108
- }
1109
- if (!running && !failed) {
1110
- if (editing) {
1111
- body.push(react.createElement('input', {
1112
- key: 'editinput',
1113
- type: 'text',
1114
- className: 'fal-input',
1115
- value: editText,
1116
- placeholder: prompt || t('card.newPromptPlaceholder'),
1117
- onChange: (e) => setEditText(e.target.value),
1118
- }))
1119
- body.push(react.createElement('button', {
1120
- key: 'editsend',
1121
- type: 'button',
1122
- className: 'fal-repeat',
1123
- onClick: async () => {
1124
- const text = (editText || prompt || '').trim()
1125
- if (!text) return
1126
- try {
1127
- const sessions = props.sessions
1128
- const current = sessions && sessions.list && sessions.list.current
1129
- const binding = current && sessions.binding(current)
1130
- const session = binding && binding.session
1131
- if (session && session.prompt) await session.prompt([{ type: 'text', text }], 'queue')
1132
- } catch (e) { /* контур сам покажет */ }
1133
- setEditing(false)
1134
- setEditText('')
1135
- },
1136
- }, t('card.send')))
1137
- } else {
1138
- body.push(react.createElement('button', {
1139
- key: 'edit',
1140
- type: 'button',
1141
- className: 'fal-repeat',
1142
- onClick: () => { setEditText(prompt || ''); setEditing(true) },
1143
- }, t('card.editPrompt')))
1144
- }
1145
- }
1146
- if (!running && !failed && downloadUrl) {
1147
- body.push(react.createElement('a', {
1148
- key: 'download',
1149
- className: 'fal-repeat',
1150
- href: downloadUrl,
1151
- download: 'generated-image.png',
1152
- }, t('card.download')))
1153
- }
846
+ return react.createElement(
847
+ 'div',
848
+ { className: 'ig-section-card', style: { margin: '8px 0' } },
849
+ head,
850
+ body,
851
+ actions.length > 0 ? react.createElement('div', { className: 'ig-row', style: { marginTop: '8px' } }, actions) : null
852
+ )
853
+ }
1154
854
 
1155
- return react.createElement('div', { className: 'fal-card' }, head, body)
855
+ // -------------------------------------------------------------- Dictionaries
856
+ const en = {
857
+ 'settings.navLabel': 'Image Studio',
858
+ 'settings.title': 'Image Studio',
859
+ 'settings.description': 'AI Image generation, inpainting, variations, upscale & vectorization.',
860
+ 'settings.unavailable': 'Plugin settings are initializing. They will appear automatically in a few seconds.',
861
+ 'settings.unsaved': 'Unsaved changes',
862
+ 'settings.saving': 'Saving…',
863
+ 'settings.save': 'Save settings',
864
+ 'settings.discard': 'Discard changes',
865
+ 'settings.saveFailed': 'Failed to save settings. Please check credentials and try again.',
866
+ 'settings.readOnly': 'Settings are read-only in this context.',
867
+ 'settings.overridden': 'overridden',
868
+ 'settings.reset': 'Reset to default',
869
+ 'settings.invalidNumber': 'Please enter a valid number',
870
+ 'settings.collapse': 'Collapse',
871
+ 'settings.expand': 'Expand',
872
+ 'settings.credentialHint': 'API keys are stored in DSH Credentials (~/.dsh/.credentials.yaml) and never leaked in config files.',
873
+
874
+ 'tab.general': 'General',
875
+ 'tab.provider': 'Provider',
876
+ 'tab.enhancer': 'Prompt & Styles',
877
+ 'tab.safety': 'Safety & Budget',
878
+ 'tab.cache': 'Cache & Storage',
879
+
880
+ 'stat.active_provider': 'Active Provider',
881
+ 'stat.format_size': 'Default Specs',
882
+ 'stat.safety_status': 'Safety & Loop Guard',
883
+ 'stat.budget_cache': 'Budget & Cache',
884
+
885
+ 'card.generating': 'Generating image…',
886
+ 'card.failed': 'Generation failed',
887
+ 'card.image': 'Image Studio Result',
888
+ 'card.unknownError': 'An unknown error occurred during generation.',
889
+ 'card.sizePrefix': 'Size',
890
+ 'card.seedPrefix': 'Seed',
891
+ 'card.actionReroll': 'Reroll image',
892
+ 'card.actionUpscale': 'Upscale image',
893
+ 'card.actionRemoveBg': 'Remove background',
894
+ 'card.reroll': 'Reroll',
895
+ 'card.upscale': 'Upscale 2x/4x',
896
+ 'card.removeBg': 'Remove BG',
897
+ 'card.copyPrompt': 'Copy Prompt',
898
+ 'card.origPrompt': 'Original Prompt',
899
+
900
+ 'f.enabled': 'Master Switch',
901
+ 'f.enabledHint': 'Enable or disable image generation tools across the workspace.',
902
+ 'f.provider': 'Image Provider',
903
+ 'f.providerHint': 'Backend service used for image generation.',
904
+ 'f.inherit': 'Default / Inherit',
905
+ 'f.needFal': 'Requires FAL API key',
906
+ 'f.needCustom': 'Requires OpenAI-compatible endpoint',
907
+ 'f.needCodex': 'Built-in Codex subscription',
908
+ 'f.needGrok': 'Built-in Grok subscription',
909
+ 'f.needLocal': 'Local ComfyUI or A1111',
910
+ 'f.needSeedream': 'Requires SeaDream API key',
911
+ 'f.needGemini': 'Requires Google Gemini API key',
912
+ 'f.needReplicate': 'Requires Replicate API token',
913
+
914
+ 'f.defaultSize': 'Default Aspect Ratio',
915
+ 'f.defaultSizeHint': 'Aspect ratio and resolution applied when image_size is omitted.',
916
+ 'f.defaultFormat': 'Output Format',
917
+ 'f.defaultFormatHint': 'Target file format (PNG, JPEG, or WEBP).',
918
+ 'f.deliverAs': 'Delivery Mode',
919
+ 'f.deliverAsHint': 'How images reach conversation: "link" (text LLM safe) or "image" (multimodal vision).',
920
+ 'f.outputDir': 'Output Directory',
921
+ 'f.outputDirHint': 'Directory path where generated images are saved on disk.',
922
+ 'p.outputDir': 'generated/images',
923
+ 'f.historyLimit': 'History Limit',
924
+ 'f.historyLimitHint': 'Maximum number of recent generation records kept in memory.',
925
+ 'p.historyLimit': '50',
926
+
927
+ 'f.model': 'FAL Model ID',
928
+ 'f.modelHint': 'Target model on FAL endpoint (e.g. fal-ai/flux-2/klein/9b).',
929
+ 'p.model': 'fal-ai/flux-2/klein/9b',
930
+ 'f.apiKeyEnv': 'FAL Key Reference',
931
+ 'f.apiKeyEnvHint': 'Credential reference holding the FAL API key.',
932
+ 'p.apiKeyEnv': 'FAL_API_KEY',
933
+ 'f.baseURL': 'FAL Base URL',
934
+ 'f.baseURLHint': 'FAL queue service endpoint.',
935
+ 'p.baseURL': 'https://queue.fal.run',
936
+ 'f.pollIntervalMs': 'Poll Interval (ms)',
937
+ 'f.pollIntervalMsHint': 'Frequency of checking task progress on asynchronous queues.',
938
+ 'p.pollIntervalMs': '2000',
939
+ 'f.timeoutMs': 'Timeout (ms)',
940
+ 'f.timeoutMsHint': 'Maximum overall timeout before failing a generation request.',
941
+ 'p.timeoutMs': '180000',
942
+
943
+ 'f.customBaseURL': 'Custom API Base URL',
944
+ 'f.customBaseURLHint': 'OpenAI-compatible image endpoint root (e.g. https://api.openai.com/v1).',
945
+ 'p.customBaseURL': 'https://api.openai.com/v1',
946
+ 'f.customModel': 'Custom Model ID',
947
+ 'f.customModelHint': 'Model name sent to custom images endpoint.',
948
+ 'p.customModel': 'dall-e-3',
949
+ 'f.customKeyEnv': 'Custom Key Reference',
950
+ 'f.customKeyEnvHint': 'Credential reference holding the API key for custom endpoint.',
951
+ 'p.customKeyEnv': 'OPENAI_API_KEY',
952
+ 'f.customSize': 'Custom Image Size',
953
+ 'f.customSizeHint': 'Fixed dimension sent to custom endpoint (e.g. 1024x1024).',
954
+ 'p.customSize': '1024x1024',
955
+
956
+ 'f.replicateModel': 'Replicate Model ID',
957
+ 'f.replicateModelHint': 'Target model identifier on Replicate.',
958
+ 'p.replicateModel': 'black-forest-labs/flux-schnell',
959
+ 'f.replicateKeyEnv': 'Replicate Key Reference',
960
+ 'f.replicateKeyEnvHint': 'Credential reference holding the Replicate API token.',
961
+ 'p.replicateKeyEnv': 'REPLICATE_API_TOKEN',
962
+
963
+ 'f.seedreamModel': 'SeaDream Model ID',
964
+ 'f.seedreamModelHint': 'ByteDance SeaDream model identifier.',
965
+ 'p.seedreamModel': 'seedream-4.0',
966
+ 'f.seedreamKeyEnv': 'SeaDream Key Reference',
967
+ 'f.seedreamKeyEnvHint': 'Credential reference holding the SeaDream API key.',
968
+ 'p.seedreamKeyEnv': 'SEEDREAM_API_KEY',
969
+ 'f.seedreamBaseURL': 'SeaDream Base URL',
970
+ 'f.seedreamBaseURLHint': 'SeaDream API endpoint URL (ByteDance/Volcengine Ark).',
971
+ 'p.seedreamBaseURL': 'https://api.bytedanceapi.com/v1',
972
+
973
+ 'f.geminiModel': 'Gemini Model ID',
974
+ 'f.geminiModelHint': 'Google Gemini image generation model identifier.',
975
+ 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
976
+ 'f.geminiKeyEnv': 'Gemini Key Reference',
977
+ 'f.geminiKeyEnvHint': 'Credential reference holding Google Gemini API key.',
978
+ 'p.geminiKeyEnv': 'GEMINI_API_KEY',
979
+
980
+ 'f.localKind': 'Local Engine Architecture',
981
+ 'f.localKindHint': 'Select between ComfyUI API or Automatic1111 WebUI backend.',
982
+ 'f.localBaseURL': 'Local Server URL',
983
+ 'f.localBaseURLHint': 'Server address (e.g. http://127.0.0.1:8188 for ComfyUI, http://127.0.0.1:7860 for A1111).',
984
+ 'p.localBaseURL': 'http://127.0.0.1:8188',
985
+ 'f.localModel': 'Local Model / Checkpoint',
986
+ 'f.localModelHint': 'Checkpoint name or ComfyUI workflow name.',
987
+ 'p.localModel': 'v1-5-pruned-emaonly.safetensors',
988
+ 'f.localSteps': 'Sampling Steps',
989
+ 'f.localStepsHint': 'Number of denoising sampling steps.',
990
+ 'p.localSteps': '20',
991
+ 'f.localCfg': 'CFG Scale',
992
+ 'f.localCfgHint': 'Classifier-free guidance scale.',
993
+ 'p.localCfg': '7',
994
+ 'f.localComfyUrl': 'Local ComfyUI URL (Legacy)',
995
+ 'f.localComfyUrlHint': 'Direct ComfyUI endpoint URL.',
996
+ 'p.localComfyUrl': 'http://127.0.0.1:8188',
997
+ 'f.localA1111Url': 'Local A1111 URL (Legacy)',
998
+ 'f.localA1111UrlHint': 'Direct Automatic1111 endpoint URL.',
999
+ 'p.localA1111Url': 'http://127.0.0.1:7860',
1000
+
1001
+ 'f.subscriptionQuality': 'Subscription Quality',
1002
+ 'f.subscriptionQualityHint': 'Target quality profile when using Codex or Grok subscriptions.',
1003
+
1004
+ 'f.enhancePrompt': 'LLM Prompt Enhancer',
1005
+ 'f.enhancePromptHint': 'Expand concise prompts with rich artistic details before generation.',
1006
+ 'f.enableLlmEnhancer': 'Enable LLM Enhancer (Legacy)',
1007
+ 'f.enableLlmEnhancerHint': 'Legacy toggle for LLM prompt expansion.',
1008
+ 'f.enhanceModel': 'Enhancement Model',
1009
+ 'f.enhanceModelHint': 'Dedicated LLM model used to expand prompts (empty = conversation model).',
1010
+ 'p.enhanceModel': 'Leave empty for default chat model',
1011
+ 'f.enhanceBelowChars': 'Enhancement Threshold',
1012
+ 'f.enhanceBelowCharsHint': 'Prompts longer than this character count will skip expansion.',
1013
+ 'p.enhanceBelowChars': '200',
1014
+ 'f.stylePreset': 'Artistic Style Preset',
1015
+ 'f.stylePresetHint': 'Automatic visual style suffix added to all generated prompts.',
1016
+
1017
+ 'f.qualityGate': 'Silent Quality Gate',
1018
+ 'f.qualityGateHint': 'Automatically inspects generated frames and silently re-rolls blank or broken outputs.',
1019
+ 'f.dailyBudgetUsd': 'Daily Spend Budget ($ USD)',
1020
+ 'f.dailyBudgetUsdHint': 'Hard spending cap per day across all visual generation tools (0 = no limit).',
1021
+ 'p.dailyBudgetUsd': '0.00',
1022
+ 'f.loopGuardLimit': 'Session Loop Guard Limit',
1023
+ 'f.loopGuardLimitHint': 'Maximum consecutive image generations allowed before requiring user input (0 = off).',
1024
+ 'p.loopGuardLimit': '3',
1025
+
1026
+ 'f.diskCache': 'Content-Addressed Disk Cache',
1027
+ 'f.diskCacheHint': 'Instant <50ms retrieval and zero API cost for repeated identical generations.',
1028
+ 'f.cacheBySeed': 'Cache by Seed + Prompt',
1029
+ 'f.cacheBySeedHint': 'Reuse existing result if the exact same seed and prompt are requested.',
1030
+ 'f.cacheByPrompt': 'Cache by Prompt Only',
1031
+ 'f.cacheByPromptHint': 'Reuse existing result if the prompt has already been generated.',
1032
+ 'f.pruneDays': 'Retention Period (Days)',
1033
+ 'f.pruneDaysHint': 'Automatically delete image files and cache older than this many days (0 = keep forever).',
1034
+ 'p.pruneDays': '0',
1035
+ 'f.cacheTtlDays': 'Cache Retention (Legacy)',
1036
+ 'f.cacheTtlDaysHint': 'Legacy name for retention period in days.',
1037
+ 'p.cacheTtlDays': '0',
1156
1038
  }
1157
1039
 
1158
- /**
1159
- * Mount the settings card.
1160
- * @param ctx - client root context (slots, settingsScope, locale).
1161
- */
1162
- // Зеркало настроек в браузере перечитывается ровно по двум сигналам: коммит
1163
- // документа и переподключение. Регистрация namespace ни тем, ни другим не
1164
- // считается — settings.register кладёт запись в реестр и инвалидацию не шлёт.
1165
- //
1166
- // Поэтому страница, прочитавшая settings.describe раньше, чем хост объявил
1167
- // наш раздел, не увидит его до перезагрузки: во вкладке настроек плагинов
1168
- // просто не будет карточки. Наша карточка там не смонтирована и починить
1169
- // себя не может, так что просим зеркало перечитаться отсюда, пока нас в нём
1170
- // нет. Зеркало общее — одно перечитывание возвращает и остальные карточки,
1171
- // включая ядровые.
1172
- //
1173
- // В нормальном случае это ноль работы: первая же проверка видит namespace
1174
- // и таймер не заводится.
1175
- function refreshMirrorUntilVisible(ctx) {
1176
- const visible = () => {
1177
- try {
1178
- const view = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().getSnapshot().view
1179
- return !!view && view.namespaces.some((row) => row.ns === SETTINGS_NS)
1180
- } catch (unavailableService) {
1181
- return false
1182
- }
1183
- }
1184
- if (visible()) return () => {}
1185
- let tries = 0
1186
- const timer = setInterval(() => {
1187
- if (visible() || tries >= 15) { clearInterval(timer); return }
1188
- tries += 1
1189
- try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (notReadyYet) { /* сервис ещё не поднялся */ }
1190
- }, 1000)
1191
- return () => clearInterval(timer)
1040
+ const ru = {
1041
+ 'settings.navLabel': 'Image Studio',
1042
+ 'settings.title': 'Генерация изображений',
1043
+ 'settings.description': 'Мультипровайдерная студия: генерация, инпейнтинг, вариации, апскейл и векторизация.',
1044
+ 'settings.unavailable': 'Настройки плагина инициализируются. Они появятся автоматически через несколько секунд.',
1045
+ 'settings.unsaved': 'Несохранённые изменения',
1046
+ 'settings.saving': 'Сохранение…',
1047
+ 'settings.save': 'Сохранить настройки',
1048
+ 'settings.discard': 'Сбросить изменения',
1049
+ 'settings.saveFailed': 'Не удалось сохранить настройки. Проверьте параметры и попробуйте снова.',
1050
+ 'settings.readOnly': 'Настройки доступны только для чтения.',
1051
+ 'settings.overridden': 'переопределено',
1052
+ 'settings.reset': 'Сбросить к умолчанию',
1053
+ 'settings.invalidNumber': 'Введите корректное число',
1054
+ 'settings.collapse': 'Свернуть',
1055
+ 'settings.expand': 'Развернуть',
1056
+ 'settings.credentialHint': 'Ключи API хранятся в DSH Credentials (~/.dsh/.credentials.yaml) и не попадают в файлы конфигурации.',
1057
+
1058
+ 'tab.general': 'Основные',
1059
+ 'tab.provider': 'Провайдер',
1060
+ 'tab.enhancer': 'Промпт и стили',
1061
+ 'tab.safety': 'Безопасность и бюджет',
1062
+ 'tab.cache': 'Кэш и хранение',
1063
+
1064
+ 'stat.active_provider': 'Активный провайдер',
1065
+ 'stat.format_size': 'Параметры по умолчанию',
1066
+ 'stat.safety_status': 'Защита и Loop Guard',
1067
+ 'stat.budget_cache': 'Бюджет и кэширование',
1068
+
1069
+ 'card.generating': 'Генерация изображения…',
1070
+ 'card.failed': 'Ошибка генерации',
1071
+ 'card.image': 'Результат Image Studio',
1072
+ 'card.unknownError': 'Неизвестная ошибка во время создания изображения.',
1073
+ 'card.sizePrefix': 'Размер',
1074
+ 'card.seedPrefix': 'Сид',
1075
+ 'card.actionReroll': 'Перегенерировать картинку',
1076
+ 'card.actionUpscale': 'Увеличить разрешение',
1077
+ 'card.actionRemoveBg': 'Удалить фон',
1078
+ 'card.reroll': 'Реролл',
1079
+ 'card.upscale': 'Апскейл 2x/4x',
1080
+ 'card.removeBg': 'Без фона',
1081
+ 'card.copyPrompt': 'Копировать промпт',
1082
+ 'card.origPrompt': 'Исходный промпт',
1083
+
1084
+ 'f.enabled': 'Главный выключатель',
1085
+ 'f.enabledHint': 'Включение или отключение всех инструментов генерации картинок.',
1086
+ 'f.provider': 'Провайдер генерации',
1087
+ 'f.providerHint': 'Сервис, выполняющий непосредственное создание изображений.',
1088
+ 'f.inherit': 'По умолчанию / наследовать',
1089
+ 'f.needFal': 'Требуется ключ FAL API',
1090
+ 'f.needCustom': 'Требуется OpenAI-совместимый эндпоинт',
1091
+ 'f.needCodex': 'Встроенная подписка Codex',
1092
+ 'f.needGrok': 'Встроенная подписка Grok',
1093
+ 'f.needLocal': 'Локальный ComfyUI или Automatic1111',
1094
+ 'f.needSeedream': 'Требуется ключ SeaDream API',
1095
+ 'f.needGemini': 'Требуется ключ Google Gemini API',
1096
+ 'f.needReplicate': 'Требуется токен Replicate API',
1097
+
1098
+ 'f.defaultSize': 'Соотношение сторон по умолчанию',
1099
+ 'f.defaultSizeHint': 'Разрешение и пропорции кадра, когда параметр image_size не передан.',
1100
+ 'f.defaultFormat': 'Формат файла',
1101
+ 'f.defaultFormatHint': 'Формат сохранения результатов (PNG, JPEG или WEBP).',
1102
+ 'f.deliverAs': 'Способ доставки',
1103
+ 'f.deliverAsHint': 'Способ передачи: "link" (безопасно для текстовых моделей) или "image" (для vision-моделей).',
1104
+ 'f.outputDir': 'Каталог сохранения',
1105
+ 'f.outputDirHint': 'Путь к папке сохранения сгенерированных файлов на диске.',
1106
+ 'p.outputDir': 'generated/images',
1107
+ 'f.historyLimit': 'Лимит истории',
1108
+ 'f.historyLimitHint': 'Максимальное число последних генераций, удерживаемых в памяти.',
1109
+ 'p.historyLimit': '50',
1110
+
1111
+ 'f.model': 'Модель FAL',
1112
+ 'f.modelHint': 'Идентификатор модели на сервисе FAL (например, fal-ai/flux-2/klein/9b).',
1113
+ 'p.model': 'fal-ai/flux-2/klein/9b',
1114
+ 'f.apiKeyEnv': 'Ключ FAL (credential-ref)',
1115
+ 'f.apiKeyEnvHint': 'Ссылка на ключ доступа FAL в хранилище credentials.',
1116
+ 'p.apiKeyEnv': 'FAL_API_KEY',
1117
+ 'f.baseURL': 'Базовый URL FAL',
1118
+ 'f.baseURLHint': 'Адрес сервиса очередей FAL.',
1119
+ 'p.baseURL': 'https://queue.fal.run',
1120
+ 'f.pollIntervalMs': 'Интервал опроса (мс)',
1121
+ 'f.pollIntervalMsHint': 'Периодичность проверки статуса при асинхронной генерации.',
1122
+ 'p.pollIntervalMs': '2000',
1123
+ 'f.timeoutMs': 'Таймаут (мс)',
1124
+ 'f.timeoutMsHint': 'Предельное время ожидания генерации до возврата ошибки.',
1125
+ 'p.timeoutMs': '180000',
1126
+
1127
+ 'f.customBaseURL': 'URL стороннего API',
1128
+ 'f.customBaseURLHint': 'Адрес OpenAI-совместимого эндпоинта (например, https://api.openai.com/v1).',
1129
+ 'p.customBaseURL': 'https://api.openai.com/v1',
1130
+ 'f.customModel': 'Модель стороннего API',
1131
+ 'f.customModelHint': 'Имя модели для отправки в сторонний API.',
1132
+ 'p.customModel': 'dall-e-3',
1133
+ 'f.customKeyEnv': 'Ключ стороннего API',
1134
+ 'f.customKeyEnvHint': 'Имя ссылки на ключ для стороннего API.',
1135
+ 'p.customKeyEnv': 'OPENAI_API_KEY',
1136
+ 'f.customSize': 'Фиксированный размер',
1137
+ 'f.customSizeHint': 'Точный размер кадра для стороннего API (например, 1024x1024).',
1138
+ 'p.customSize': '1024x1024',
1139
+
1140
+ 'f.replicateModel': 'Модель Replicate',
1141
+ 'f.replicateModelHint': 'Идентификатор модели на Replicate.',
1142
+ 'p.replicateModel': 'black-forest-labs/flux-schnell',
1143
+ 'f.replicateKeyEnv': 'Ключ Replicate',
1144
+ 'f.replicateKeyEnvHint': 'Имя ссылки на API-токен Replicate.',
1145
+ 'p.replicateKeyEnv': 'REPLICATE_API_TOKEN',
1146
+
1147
+ 'f.seedreamModel': 'Модель SeaDream',
1148
+ 'f.seedreamModelHint': 'Идентификатор модели ByteDance SeaDream.',
1149
+ 'p.seedreamModel': 'seedream-4.0',
1150
+ 'f.seedreamKeyEnv': 'Ключ SeaDream',
1151
+ 'f.seedreamKeyEnvHint': 'Имя ссылки на ключ API SeaDream.',
1152
+ 'p.seedreamKeyEnv': 'SEEDREAM_API_KEY',
1153
+ 'f.seedreamBaseURL': 'Базовый URL SeaDream',
1154
+ 'f.seedreamBaseURLHint': 'Адрес API SeaDream (ByteDance/Volcengine Ark).',
1155
+ 'p.seedreamBaseURL': 'https://api.bytedanceapi.com/v1',
1156
+
1157
+ 'f.geminiModel': 'Модель Gemini',
1158
+ 'f.geminiModelHint': 'Идентификатор модели генерации Google Gemini.',
1159
+ 'p.geminiModel': 'gemini-2.0-flash-exp-image-generation',
1160
+ 'f.geminiKeyEnv': 'Ключ Gemini',
1161
+ 'f.geminiKeyEnvHint': 'Имя ссылки на ключ Google Gemini API.',
1162
+ 'p.geminiKeyEnv': 'GEMINI_API_KEY',
1163
+
1164
+ 'f.localKind': 'Тип локального движка',
1165
+ 'f.localKindHint': 'Выбор между ComfyUI API и Automatic1111 WebUI.',
1166
+ 'f.localBaseURL': 'Адрес локального сервера',
1167
+ 'f.localBaseURLHint': 'Адрес локального сервера (ComfyUI: 8188, A1111: 7860).',
1168
+ 'p.localBaseURL': 'http://127.0.0.1:8188',
1169
+ 'f.localModel': 'Модель / Чекпоинт',
1170
+ 'f.localModelHint': 'Имя чекпоинта или воркфлоу ComfyUI.',
1171
+ 'p.localModel': 'v1-5-pruned-emaonly.safetensors',
1172
+ 'f.localSteps': 'Шаги сэмплирования',
1173
+ 'f.localStepsHint': 'Количество шагов генерации.',
1174
+ 'p.localSteps': '20',
1175
+ 'f.localCfg': 'Шкала CFG',
1176
+ 'f.localCfgHint': 'Сила следования промпту.',
1177
+ 'p.localCfg': '7',
1178
+ 'f.localComfyUrl': 'Локальный ComfyUI URL (Legacy)',
1179
+ 'f.localComfyUrlHint': 'Прямой адрес ComfyUI.',
1180
+ 'p.localComfyUrl': 'http://127.0.0.1:8188',
1181
+ 'f.localA1111Url': 'Локальный A1111 URL (Legacy)',
1182
+ 'f.localA1111UrlHint': 'Прямой адрес Automatic1111.',
1183
+ 'p.localA1111Url': 'http://127.0.0.1:7860',
1184
+
1185
+ 'f.subscriptionQuality': 'Качество подписки',
1186
+ 'f.subscriptionQualityHint': 'Профиль качества для провайдеров Codex и Grok.',
1187
+
1188
+ 'f.enhancePrompt': 'LLM-улучшение промпта',
1189
+ 'f.enhancePromptHint': 'Автоматическое обогащение коротких запросов художественными деталями.',
1190
+ 'f.enableLlmEnhancer': 'Включить LLM Enhancer (Legacy)',
1191
+ 'f.enableLlmEnhancerHint': 'Устаревший переключатель улучшения промптов.',
1192
+ 'f.enhanceModel': 'Модель улучшения',
1193
+ 'f.enhanceModelHint': 'Модель для расширения промптов (пусто = основная модель чата).',
1194
+ 'p.enhanceModel': 'Пусто для основной модели чата',
1195
+ 'f.enhanceBelowChars': 'Порог длины промпта',
1196
+ 'f.enhanceBelowCharsHint': 'Промпты длиннее этого количества символов не расширяются.',
1197
+ 'p.enhanceBelowChars': '200',
1198
+ 'f.stylePreset': 'Художественный стиль',
1199
+ 'f.stylePresetHint': 'Автоматический суффикс стиля для всех создаваемых изображений.',
1200
+
1201
+ 'f.qualityGate': 'Контроль качества (Quality Gate)',
1202
+ 'f.qualityGateHint': 'Автоматическая проверка кадров и скрытый реролл при дефектах или пустом кадре.',
1203
+ 'f.dailyBudgetUsd': 'Дневной бюджет ($ USD)',
1204
+ 'f.dailyBudgetUsdHint': 'Предельный лимит затрат в сутки по всем графическим инструментам (0 = без лимита).',
1205
+ 'p.dailyBudgetUsd': '0.00',
1206
+ 'f.loopGuardLimit': 'Защита от циклов (Loop Guard)',
1207
+ 'f.loopGuardLimitHint': 'Максимум последовательных генераций подряд без участия пользователя (0 = выкл).',
1208
+ 'p.loopGuardLimit': '3',
1209
+
1210
+ 'f.diskCache': 'Дисковый кэш по хэшу',
1211
+ 'f.diskCacheHint': 'Мгновенная выдача (<50 мс) и нулевая стоимость для повторных идентичных генераций.',
1212
+ 'f.cacheBySeed': 'Кэш по сиду и промпту',
1213
+ 'f.cacheBySeedHint': 'Возвращать кэш при полном совпадении сида и текста промпта.',
1214
+ 'f.cacheByPrompt': 'Кэш по тексту промпта',
1215
+ 'f.cacheByPromptHint': 'Возвращать кэш при повторении того же текста промпта.',
1216
+ 'f.pruneDays': 'Срок хранения файлов (дни)',
1217
+ 'f.pruneDaysHint': 'Автоматическое удаление файлов и записей старше указанного числа дней (0 = бессрочно).',
1218
+ 'p.pruneDays': '0',
1219
+ 'f.cacheTtlDays': 'Срок хранения кэша (Legacy)',
1220
+ 'f.cacheTtlDaysHint': 'Устаревшее имя параметра срока хранения.',
1221
+ 'p.cacheTtlDays': '0',
1192
1222
  }
1193
1223
 
1224
+ // -------------------------------------------------------------- Registration & Apply
1225
+ const inject = ['slots', 'settingsScope', 'locale', 'sessions']
1226
+
1194
1227
  function apply(ctx) {
1195
- // Язык может принести не только плагин: словарные пакеты объявляют
1196
- // русский для чужих пространств. Ядро на повторное объявление той же
1197
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
1198
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
1199
- // load plugins» с перечнем ни в чём не повинных соседей.
1200
- //
1201
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
1202
- // уступаем, свой английский при этом всё равно встаёт на место.
1203
- const addLocale = (locale, dictionary) => {
1204
- try {
1205
- return ctx.locale.register(NS, locale, dictionary)
1206
- } catch (alreadyTaken) {
1207
- return () => {}
1208
- }
1228
+ if (ctx.locale && typeof ctx.locale.define === 'function') {
1229
+ ctx.locale.define('en', NS, en)
1230
+ ctx.locale.define('ru', NS, ru)
1209
1231
  }
1210
- ctx.effect(() => {
1211
- const undo = [addLocale('en', en), addLocale('ru', ru)]
1212
- return () => { for (const off of undo) off() }
1213
- }, 'dsh-image-gen: dictionaries')
1214
- // Подпись раздела рисует не наш компонент, а боковой список, поэтому
1215
- // props.t сюда не доедет: берём переводчик, привязанный к namespace.
1216
- const t = ctx.locale.bind(NS)
1217
- ctx.effect(
1218
- () => refreshMirrorUntilVisible(ctx),
1219
- 'dsh-image-gen: re-read the settings mirror until our namespace appears',
1220
- )
1221
- // На странице, открытой не с localhost, ядро отключает настройки целиком:
1222
- // общий вид документа не читается, каждый раздел получает "unavailable",
1223
- // запись выбрасывается. Сервер это ограничение не разделяет, и плагин
1224
- // dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
1225
- // установлен — берём его службу, иначе ядровую: на loopback-странице она
1226
- // работает штатно.
1232
+
1227
1233
  let card
1228
1234
  const cardOnce = () => {
1229
1235
  if (card === undefined) {
1230
- card = new FalSettingsCardController(((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: SETTINGS_NS }))
1236
+ const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: SETTINGS_NS })
1237
+ card = new FalSettingsCardController(scope)
1231
1238
  }
1232
1239
  return card
1233
1240
  }
1234
1241
 
1235
- ctx.slots.inject('tool.call.toolview', () =>
1236
- ctx.slots.register(
1237
- { name: 'tool.call.toolview', key: 'remove_background', locale: NS, inject: () => ({ sessions: ctx.sessions }) },
1238
- FalImageCard,
1239
- ),
1240
- )
1241
- ctx.slots.inject('tool.call.toolview', () =>
1242
- ctx.slots.register(
1243
- { name: 'tool.call.toolview', key: 'upscale_image', locale: NS, inject: () => ({ sessions: ctx.sessions }) },
1244
- FalImageCard,
1245
- ),
1246
- )
1247
-
1248
- ctx.slots.inject('tool.call.toolview', () =>
1249
- ctx.slots.register(
1250
- { name: 'tool.call.toolview', key: 'generate_image', locale: NS, inject: () => ({ sessions: ctx.sessions }) },
1251
- FalImageCard,
1252
- ),
1253
- )
1254
- // Карточка во вкладке «Настройки → Плагины → Настройки плагинов» (слот settings.plugin.item).
1255
- // Ключ слота обязан равняться пространству настроек (SETTINGS_NS):
1256
- // вкладка перебирает пространства и отрисовывает карточку.
1257
- // Раздел верхнего уровня settings.section исключён, чтобы настройки не
1258
- // засоряли общий боковой список настроек.
1259
- // Отложенная регистрация через ctx.slots.inject предотвращает ошибки монтирования,
1260
- // так как дочерние слоты страниц DSH UI объявляются родительскими компонентами.
1261
1242
  function registerSlotWhenReady(slotName, registerFn) {
1262
1243
  if (!ctx.slots) return
1263
1244
  if (typeof ctx.slots.inject === 'function') {
@@ -1283,6 +1264,33 @@ function FalImageCard(props) {
1283
1264
  }
1284
1265
  }
1285
1266
 
1267
+ // Register toolviews for all 8 visual tools
1268
+ const toolviewTools = [
1269
+ 'generate_image',
1270
+ 'edit_image',
1271
+ 'vary_image',
1272
+ 'blend_images',
1273
+ 'generate_image_pack',
1274
+ 'remove_background',
1275
+ 'upscale_image',
1276
+ 'vectorize_image',
1277
+ ]
1278
+
1279
+ for (const toolName of toolviewTools) {
1280
+ registerSlotWhenReady('tool.call.toolview', () =>
1281
+ ctx.slots.register(
1282
+ {
1283
+ name: 'tool.call.toolview',
1284
+ key: toolName,
1285
+ locale: NS,
1286
+ inject: () => ({ sessions: ctx.sessions }),
1287
+ },
1288
+ (props) => react.createElement(ErrorBoundary, null, react.createElement(FalImageCard, props))
1289
+ )
1290
+ )
1291
+ }
1292
+
1293
+ // Settings card item
1286
1294
  registerSlotWhenReady('settings.plugin.item', () =>
1287
1295
  ctx.slots.register(
1288
1296
  {
@@ -1291,10 +1299,11 @@ function FalImageCard(props) {
1291
1299
  locale: NS,
1292
1300
  inject: () => cardOnce().inject(),
1293
1301
  },
1294
- FalSettingsCard,
1295
- ),
1302
+ (props) => react.createElement(ErrorBoundary, null, react.createElement(FalSettingsCard, props))
1303
+ )
1296
1304
  )
1297
1305
  }
1306
+
1298
1307
  exports.apply = apply
1299
1308
  exports.inject = inject
1300
1309
  return module.exports