@goodandready/dsh-image-gen 0.8.0

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 ADDED
@@ -0,0 +1,828 @@
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
+
11
+ window.__ModuleLoader__.load({
12
+ id: '@goodandready/dsh-image-gen',
13
+ factory: (require) => {
14
+ var module = { exports: {} }
15
+ 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
+ let runtime = require('@deepseek-ai/dsh-client-runtime/client')
20
+
21
+ // ---------------------------------------------------------------- css
22
+ const css =
23
+ '.fal_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:8px;list-style:none;transition:border-color .16s,background .16s;overflow:hidden}' +
24
+ '.fal_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}' +
25
+ '.fal_header{cursor:pointer;text-align:left;width:100%;font:inherit;background:0 0;border:0;align-items:center;gap:8px;padding:10px 14px;display:flex}' +
26
+ '.fal_header:hover{background:var(--dsw-alias-interactive-bg-hover)}' +
27
+ '.fal_headText{flex-direction:column;flex:1;gap:2px;min-width:0;display:flex}' +
28
+ '.fal_name{color:var(--dsw-alias-label-primary);font-weight:600}' +
29
+ '.fal_description{color:var(--dsw-alias-label-tertiary);font-size:12px}' +
30
+ '.fal_pending{color:var(--dsw-alias-state-warn-primary);font-size:12px}' +
31
+ '.fal_chevron{color:var(--dsw-alias-label-tertiary);transition:transform .12s}' +
32
+ '.fal_chevronOpen{transform:rotate(180deg)}' +
33
+ '.fal_body{flex-direction:column;gap:12px;padding:0 14px 14px;display:flex}' +
34
+ '.fal_readOnly{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px}' +
35
+ '.fal_footer{justify-content:flex-end;align-items:center;gap:8px;display:flex}' +
36
+ '.fal_failed{color:var(--dsw-alias-state-error-primary);margin:0 auto 0 0;font-size:12px}' +
37
+ '.fal_discard,.fal_save{font:inherit;cursor:pointer;border-radius:6px;padding:5px 12px;font-size:13px}' +
38
+ '.fal_discard{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0}' +
39
+ '.fal_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}' +
40
+ '.fal_save{border:1px solid var(--dsw-alias-button-info-fill);background:var(--dsw-alias-button-info-fill);color:var(--dsw-alias-label-primary-foreground)}' +
41
+ '.fal_save:hover:not(:disabled){border-color:var(--dsw-alias-button-info-hover);background:var(--dsw-alias-button-info-hover)}' +
42
+ '.fal_discard:active:not(:disabled),.fal_save:active:not(:disabled){transform:translateY(1px)}' +
43
+ '.fal_discard:focus-visible:not(:disabled),.fal_save:focus-visible:not(:disabled){outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
44
+ '.fal_discard:disabled,.fal_save:disabled{opacity:.5;cursor:default}' +
45
+ '.fal_field{flex-direction:column;gap:4px;display:flex}' +
46
+ '.fal_head{align-items:center;gap:8px;display:flex}' +
47
+ '.fal_label{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500}' +
48
+ '.fal_badges{align-items:center;gap:6px;display:flex}' +
49
+ '.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}' +
50
+ '.fal_reset{color:var(--dsw-alias-state-business-primary);cursor:pointer;background:0 0;border:0;padding:0;font-size:11px}' +
51
+ '.fal_reset:hover:not(:disabled){text-decoration:underline}' +
52
+ '.fal_reset:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:-2px}' +
53
+ '.fal_input,.fal_select{border:1px solid var(--dsw-alias-border-l2);font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border-radius:6px;padding:6px 8px;font-size:13px;min-height:32px}' +
54
+ '.fal_inputInvalid{border:1px solid var(--dsw-alias-state-error-primary);font:inherit;color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
55
+ '.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
56
+ '.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
57
+ '.fal_invalid{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}' +
58
+ '.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
59
+ const tagId = 'dsh-image-gen/settings-card.module.css'
60
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
61
+ const tag = document.createElement('style')
62
+ tag.dataset.plugin = 'dsh-image-gen'
63
+ tag.dataset.pluginCss = tagId
64
+ tag.textContent = css
65
+ document.head.appendChild(tag)
66
+ }
67
+ const cssDefault = {
68
+ badge: 'fal_badge',
69
+ badges: 'fal_badges',
70
+ body: 'fal_body',
71
+ card: 'fal_card',
72
+ cardOpen: 'fal_cardOpen',
73
+ chevron: 'fal_chevron',
74
+ chevronOpen: 'fal_chevronOpen',
75
+ description: 'fal_description',
76
+ discard: 'fal_discard',
77
+ failed: 'fal_failed',
78
+ field: 'fal_field',
79
+ footer: 'fal_footer',
80
+ head: 'fal_head',
81
+ header: 'fal_header',
82
+ headText: 'fal_headText',
83
+ hint: 'fal_hint',
84
+ input: 'fal_input',
85
+ inputInvalid: 'fal_inputInvalid',
86
+ invalid: 'fal_invalid',
87
+ label: 'fal_label',
88
+ name: 'fal_name',
89
+ pending: 'fal_pending',
90
+ readOnly: 'fal_readOnly',
91
+ reset: 'fal_reset',
92
+ save: 'fal_save',
93
+ select: 'fal_select',
94
+ sep: 'fal_sep',
95
+ }
96
+
97
+ // -------------------------------------------------------------- chrome
98
+ /** Shared card chrome: disclosure header, controls, save/discard footer. */
99
+ function PluginSettingsCard(props) {
100
+ // Карточка живёт в собственном разделе настроек и на странице одна, так
101
+ // что открыта сразу: свёрнутая выглядела бы как пустая страница.
102
+ const [open, setOpen] = react.useState(true)
103
+ const { state } = props
104
+ // Раньше здесь было `return null`, и раздел оказывался пустым, если хост
105
+ // ещё не объявил настройки. Пустая страница ничего не объясняет — лучше
106
+ // сказать прямо, что происходит.
107
+ if (!state.available) {
108
+ return react_jsx_runtime.jsx('li', {
109
+ className: cssDefault.card,
110
+ children: react_jsx_runtime.jsx('p', {
111
+ className: cssDefault.hint,
112
+ role: 'status',
113
+ children: props.t('settings.unavailable'),
114
+ }),
115
+ })
116
+ }
117
+ const title = props.t(props.titleKey)
118
+ const blocked = !state.dirty || state.invalid || state.saving
119
+ return react_jsx_runtime.jsxs('li', {
120
+ className: cssDefault.card + (open ? ' ' + cssDefault.cardOpen : ''),
121
+ children: [
122
+ react_jsx_runtime.jsxs('button', {
123
+ type: 'button',
124
+ className: cssDefault.header,
125
+ 'aria-expanded': open,
126
+ 'aria-label': `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`,
127
+ onClick: () => setOpen(!open),
128
+ children: [
129
+ react_jsx_runtime.jsxs('span', {
130
+ className: cssDefault.headText,
131
+ children: [
132
+ react_jsx_runtime.jsx('span', { className: cssDefault.name, children: title }),
133
+ react_jsx_runtime.jsx('span', { className: cssDefault.description, children: props.t(props.descriptionKey) }),
134
+ ],
135
+ }),
136
+ state.dirty ? react_jsx_runtime.jsx('span', { className: cssDefault.pending, children: props.t('settings.unsaved') }) : null,
137
+ react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children: '\u25be' }),
138
+ ],
139
+ }),
140
+ open
141
+ ? react_jsx_runtime.jsxs('div', {
142
+ className: cssDefault.body,
143
+ children: [
144
+ !state.writable ? react_jsx_runtime.jsx('p', { className: cssDefault.readOnly, role: 'status', children: props.t('settings.readOnly') }) : null,
145
+ props.children,
146
+ react_jsx_runtime.jsxs('div', {
147
+ className: cssDefault.footer,
148
+ children: [
149
+ state.failed ? react_jsx_runtime.jsx('p', { className: cssDefault.failed, role: 'status', children: props.t('settings.saveFailed') }) : null,
150
+ react_jsx_runtime.jsx('button', {
151
+ type: 'button',
152
+ className: cssDefault.discard,
153
+ disabled: !state.dirty || state.saving,
154
+ onClick: props.onDiscard,
155
+ children: props.t('settings.discard'),
156
+ }),
157
+ react_jsx_runtime.jsx('button', {
158
+ type: 'button',
159
+ className: cssDefault.save,
160
+ disabled: blocked,
161
+ onClick: props.onSave,
162
+ children: props.t(state.saving ? 'settings.saving' : 'settings.save'),
163
+ }),
164
+ ],
165
+ }),
166
+ ],
167
+ })
168
+ : null,
169
+ ],
170
+ })
171
+ }
172
+
173
+ /** A plain text (or numeric) value field. */
174
+ function ValueField(props) {
175
+ return react_jsx_runtime.jsxs('div', {
176
+ className: cssDefault.field,
177
+ children: [
178
+ react_jsx_runtime.jsxs('div', {
179
+ className: cssDefault.head,
180
+ children: [
181
+ react_jsx_runtime.jsx('label', { className: cssDefault.label, htmlFor: props.id, children: props.label }),
182
+ props.overridden
183
+ ? react_jsx_runtime.jsxs('span', {
184
+ className: cssDefault.badges,
185
+ children: [
186
+ react_jsx_runtime.jsx('span', { className: cssDefault.badge, children: props.overriddenLabel }),
187
+ react_jsx_runtime.jsx('button', { type: 'button', className: cssDefault.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel }),
188
+ ],
189
+ })
190
+ : null,
191
+ ],
192
+ }),
193
+ react_jsx_runtime.jsx('input', {
194
+ id: props.id,
195
+ className: props.invalid ? cssDefault.inputInvalid : cssDefault.input,
196
+ type: 'text',
197
+ ...(props.numeric === true ? { inputMode: 'numeric' } : {}),
198
+ ...(props.invalid ? { 'aria-invalid': true } : {}),
199
+ value: props.text,
200
+ placeholder: props.placeholder ?? '',
201
+ disabled: props.disabled,
202
+ onChange: (event) => props.onEdit(event.target.value),
203
+ }),
204
+ react_jsx_runtime.jsx('p', { className: props.invalid ? cssDefault.invalid : cssDefault.hint, children: props.invalid ? props.invalidLabel : props.hint }),
205
+ ],
206
+ })
207
+ }
208
+
209
+ /** A select field (enum values; empty = inherit the default). */
210
+ function SelectField(props) {
211
+ return react_jsx_runtime.jsxs('div', {
212
+ className: cssDefault.field,
213
+ children: [
214
+ react_jsx_runtime.jsxs('div', {
215
+ className: cssDefault.head,
216
+ children: [
217
+ react_jsx_runtime.jsx('label', { className: cssDefault.label, htmlFor: props.id, children: props.label }),
218
+ props.overridden
219
+ ? react_jsx_runtime.jsxs('span', {
220
+ className: cssDefault.badges,
221
+ children: [
222
+ react_jsx_runtime.jsx('span', { className: cssDefault.badge, children: props.overriddenLabel }),
223
+ react_jsx_runtime.jsx('button', { type: 'button', className: cssDefault.reset, disabled: props.disabled, onClick: props.onReset, children: props.resetLabel }),
224
+ ],
225
+ })
226
+ : null,
227
+ ],
228
+ }),
229
+ react_jsx_runtime.jsxs('select', {
230
+ id: props.id,
231
+ className: cssDefault.select,
232
+ value: props.text,
233
+ disabled: props.disabled,
234
+ onChange: (event) => props.onEdit(event.target.value),
235
+ children: [
236
+ react_jsx_runtime.jsx('option', { value: '', children: props.inheritLabel }),
237
+ props.options.map((option) => react_jsx_runtime.jsx('option', { value: option, children: option }, option)),
238
+ ],
239
+ }),
240
+ react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: props.hint }),
241
+ ],
242
+ })
243
+ }
244
+
245
+ // --------------------------------------------------------------- form
246
+ /** A staged text value (empty text clears the override). */
247
+ function textField(field) {
248
+ return {
249
+ field,
250
+ format: (value) => (typeof value === 'string' ? value : ''),
251
+ parse: (text) => (text === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
252
+ }
253
+ }
254
+
255
+ /** A staged numeric value. */
256
+ function numberField(field) {
257
+ return {
258
+ field,
259
+ format: (value) => (typeof value === 'number' ? String(value) : ''),
260
+ parse: (text) => {
261
+ if (text === '') return { kind: 'clear' }
262
+ const value = Number(text)
263
+ return Number.isFinite(value) ? { kind: 'set', value } : void 0
264
+ },
265
+ }
266
+ }
267
+
268
+ /** A staged enum value (empty text clears the override). */
269
+ function selectField(field, options) {
270
+ const set = new Set(options)
271
+ return {
272
+ field,
273
+ format: (value) => (typeof value === 'string' ? value : ''),
274
+ parse: (text) => {
275
+ if (text === '') return { kind: 'clear' }
276
+ return set.has(text) ? { kind: 'set', value: text } : void 0
277
+ },
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Stages one card's edits over one settings namespace and writes them on
283
+ * save. The Host is the only authority on whether a value was accepted, so
284
+ * the outcome is read back from the section rather than predicted here.
285
+ */
286
+ var CardForm = class {
287
+ constructor(scope, specs) {
288
+ this.scope = scope
289
+ this.specs = new Map(specs.map((spec) => [spec.field, spec]))
290
+ this.staged = new Map()
291
+ this.listeners = new Set()
292
+ this.saving = false
293
+ this.failed = false
294
+ scope.subscribe(() => this.publish())
295
+ }
296
+ bind(project) {
297
+ const store = runtime.createSnapshotStore(project())
298
+ this.listeners.add(() => store.set(project()))
299
+ return store
300
+ }
301
+ shell() {
302
+ const snapshot = this.scope.getSnapshot()
303
+ const plan = this.plan()
304
+ return {
305
+ available: snapshot.status === 'ready',
306
+ writable: snapshot.writable,
307
+ dirty: plan.length > 0,
308
+ invalid: plan.some((item) => item.run === void 0),
309
+ saving: this.saving,
310
+ failed: this.failed,
311
+ }
312
+ }
313
+ field(field) {
314
+ const spec = this.specOf(field)
315
+ const staged = this.staged.get(field)
316
+ const snapshot = this.scope.getSnapshot()
317
+ if (staged === void 0) {
318
+ return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
319
+ }
320
+ const write = staged.clear ? { kind: 'clear' } : spec.parse(staged.text)
321
+ return { text: staged.text, overridden: write?.kind === 'set', invalid: write === void 0 }
322
+ }
323
+ actions() {
324
+ return {
325
+ edit: (field, text) => this.stage(field, { text, clear: false }),
326
+ resetField: (field) => {
327
+ const spec = this.specOf(field)
328
+ this.stage(field, { text: spec.format(this.baseValue(field)), clear: true })
329
+ },
330
+ save: () => this.save(),
331
+ discard: () => {
332
+ if (this.staged.size === 0 && !this.failed) return
333
+ this.staged.clear()
334
+ this.failed = false
335
+ this.publish()
336
+ },
337
+ }
338
+ }
339
+ async save() {
340
+ const plan = this.plan()
341
+ const writes = plan.flatMap((item) => (item.run === void 0 ? [] : [item.run]))
342
+ if (plan.length === 0 || this.saving || writes.length !== plan.length) return
343
+ this.saving = true
344
+ this.failed = false
345
+ this.publish()
346
+ let landed = true
347
+ for (const write of writes) landed = (await write()) && landed
348
+ if (landed) this.staged.clear()
349
+ this.saving = false
350
+ this.failed = !landed
351
+ this.publish()
352
+ }
353
+ plan() {
354
+ const plan = []
355
+ for (const [field, staged] of this.staged) {
356
+ const spec = this.specOf(field)
357
+ if (staged.clear) {
358
+ if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
359
+ continue
360
+ }
361
+ if (staged.text === spec.format(this.sectionValue(field))) continue
362
+ const write = spec.parse(staged.text)
363
+ if (write === void 0) plan.push({ field, run: void 0 })
364
+ else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
365
+ else plan.push({ field, run: () => this.store(field, write.value) })
366
+ }
367
+ return plan
368
+ }
369
+ async clear(field) {
370
+ await this.scope.unset(field)
371
+ return !this.stored(field)
372
+ }
373
+ async store(field, value) {
374
+ await this.scope.set(field, value)
375
+ return this.userLayer()?.[field] === value
376
+ }
377
+ stage(field, edit) {
378
+ this.staged.set(field, edit)
379
+ this.failed = false
380
+ this.publish()
381
+ }
382
+ specOf(field) {
383
+ const spec = this.specs.get(field)
384
+ if (spec === void 0) throw new Error('settings card has no field ' + field)
385
+ return spec
386
+ }
387
+ snapshotOf() {
388
+ return this.scope.getSnapshot()
389
+ }
390
+ sectionValue(field) {
391
+ return this.snapshotOf().value?.[field]
392
+ }
393
+ baseValue(field) {
394
+ return this.snapshotOf().base?.[field]
395
+ }
396
+ userLayer() {
397
+ return this.snapshotOf().user
398
+ }
399
+ stored(field) {
400
+ const user = this.userLayer()
401
+ return user !== void 0 && Object.hasOwn(user, field)
402
+ }
403
+ publish() {
404
+ for (const listener of this.listeners) listener()
405
+ }
406
+ }
407
+
408
+ // ------------------------------------------------------------- fields
409
+ const IMAGE_SIZES = ['square_hd', 'square', 'portrait_4_3', 'portrait_16_9', 'landscape_4_3', 'landscape_16_9']
410
+ const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
411
+ const DELIVERY_MODES = ['link', 'image']
412
+ const PROVIDERS = ['fal', 'custom', 'codex', 'grok']
413
+
414
+ /** Field specs in display order. */
415
+ const FIELDS = [
416
+ { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint' },
417
+ { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
418
+ { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
419
+ { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
420
+ { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint' },
421
+ { field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
422
+ { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs' },
423
+ { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs' },
424
+ { field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
425
+ { field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL' },
426
+ { field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel' },
427
+ { field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv' },
428
+ { field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize' },
429
+ { field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
430
+ ]
431
+
432
+ function Field(props) {
433
+ const { entry, state, fieldProps, t, onEdit, onReset } = props
434
+ const base = {
435
+ id: 'dsh-image-gen-' + entry.field,
436
+ label: t(entry.labelKey),
437
+ hint: t(entry.hintKey),
438
+ overriddenLabel: fieldProps.overriddenLabel,
439
+ resetLabel: fieldProps.resetLabel,
440
+ invalidLabel: fieldProps.invalidLabel,
441
+ disabled: fieldProps.disabled,
442
+ text: state.text,
443
+ overridden: state.overridden,
444
+ invalid: state.invalid,
445
+ onEdit,
446
+ onReset,
447
+ }
448
+ if (entry.kind === 'select') {
449
+ return react_jsx_runtime.jsx(SelectField, { ...base, options: entry.options, inheritLabel: t('f.inherit') })
450
+ }
451
+ return react_jsx_runtime.jsx(ValueField, { ...base, numeric: entry.kind === 'number', placeholder: entry.placeholderKey ? t(entry.placeholderKey) : '' })
452
+ }
453
+
454
+ /** Render the plugin settings card. */
455
+ function FalSettingsCard(props) {
456
+ const { t } = props
457
+ const state = props.useFalSettingsCard((snapshot) => snapshot)
458
+ const disabled = !state.writable
459
+ const fieldProps = {
460
+ overriddenLabel: t('settings.overridden'),
461
+ resetLabel: t('settings.reset'),
462
+ invalidLabel: t('settings.invalidNumber'),
463
+ disabled,
464
+ }
465
+ return react_jsx_runtime.jsx(PluginSettingsCard, {
466
+ t,
467
+ titleKey: 'settings.title',
468
+ descriptionKey: 'settings.description',
469
+ state,
470
+ onSave: props.save,
471
+ onDiscard: props.discard,
472
+ children: [
473
+ react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: t('settings.credentialHint') }),
474
+ FIELDS.map((entry) =>
475
+ react_jsx_runtime.jsx(
476
+ Field,
477
+ {
478
+ entry,
479
+ state: state[entry.field] ?? { text: '', overridden: false, invalid: false },
480
+ fieldProps,
481
+ t,
482
+ onEdit: (text) => props.edit(entry.field, text),
483
+ onReset: () => props.resetField(entry.field),
484
+ },
485
+ entry.field,
486
+ ),
487
+ ),
488
+ ],
489
+ })
490
+ }
491
+
492
+ /** Bridges the `dsh-image-gen` scope onto the card's staged form. */
493
+ var FalSettingsCardController = class {
494
+ constructor(scope) {
495
+ this.form = new CardForm(scope, FIELDS.map((entry) => entry.spec))
496
+ this.store = this.form.bind(() => this.projection())
497
+ }
498
+ projection() {
499
+ return {
500
+ ...this.form.shell(),
501
+ ...Object.fromEntries(FIELDS.map((entry) => [entry.field, this.form.field(entry.field)])),
502
+ }
503
+ }
504
+ inject() {
505
+ return { hooks: { falSettingsCard: this.store }, ...this.form.actions() }
506
+ }
507
+ }
508
+
509
+ // -------------------------------------------------------------- apply
510
+ const NS = 'dsh-image-gen'
511
+ /** Settings namespace the card edits (the Host plugin registers it). */
512
+ const SETTINGS_NS = 'dsh-image-gen'
513
+ /** Required client services. */
514
+ const inject = ['slots', 'settingsScope', 'locale']
515
+
516
+ const en = {
517
+ 'settings.navLabel': 'Image generation',
518
+ 'settings.title': 'Image generation',
519
+ 'settings.description': 'generate_image tool. Provider: the FAL queue, or any OpenAI-compatible images API.',
520
+ '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.',
521
+ 'settings.credentialHint': 'API keys live in Credentials under the references set below (Settings → Credentials).',
522
+ 'settings.expand': 'Expand',
523
+ 'settings.collapse': 'Collapse',
524
+ 'settings.unsaved': 'Unsaved changes',
525
+ 'settings.readOnly': 'This deployment is read-only: settings cannot be changed from the GUI.',
526
+ 'settings.overridden': 'Override',
527
+ 'settings.reset': 'Reset',
528
+ 'settings.invalidNumber': 'Enter a valid number',
529
+ 'settings.discard': 'Discard',
530
+ 'settings.save': 'Save',
531
+ 'settings.saving': 'Saving…',
532
+ 'settings.saveFailed': 'Save failed',
533
+ 'f.inherit': 'Inherit (default)',
534
+ 'f.provider': 'Provider',
535
+ 'f.providerHint': 'fal — the FAL queue configured below. custom — any OpenAI-compatible API, configured further down. '
536
+ + 'codex and grok — through a subscription you are logged into, no key needed here.',
537
+ 'f.model': 'Model (FAL)',
538
+ 'f.modelHint': 'FAL model id, called as {baseURL}/{model}. Used when provider is fal.',
539
+ 'f.apiKeyEnv': 'API key reference',
540
+ 'f.apiKeyEnvHint': 'Credential reference / env var holding the FAL API key. The "Key " auth prefix is added automatically.',
541
+ 'f.baseURL': 'Queue base URL',
542
+ 'f.baseURLHint': 'FAL queue base URL (queue.fal.run).',
543
+ 'f.defaultSize': 'Default image size',
544
+ 'f.defaultSizeHint': 'Used when the tool call omits image_size.',
545
+ 'f.defaultFormat': 'Default format',
546
+ 'f.defaultFormatHint': 'Used when the tool call omits output_format.',
547
+ 'f.pollIntervalMs': 'Poll interval (ms)',
548
+ 'f.pollIntervalMsHint': 'Status polling interval while a job runs.',
549
+ 'f.timeoutMs': 'Timeout (ms)',
550
+ 'f.timeoutMsHint': 'Total generation timeout: submit + poll + download.',
551
+ 'f.deliverAs': 'How the image reaches the chat',
552
+ '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.',
553
+ 'f.customBaseURL': 'Custom API: base URL',
554
+ 'f.customBaseURLHint': 'API root without a trailing slash. The request goes to {base}/images/generations.',
555
+ 'f.customModel': 'Custom API: model',
556
+ 'f.customModelHint': 'Model id sent to that API, e.g. gpt-image-1.',
557
+ 'f.customKeyEnv': 'Custom API: key reference',
558
+ 'f.customKeyEnvHint': 'Credential reference / env var with the API key. Empty means no authorization header, for gateways that need none.',
559
+ 'f.customSize': 'Custom API: fixed size',
560
+ 'f.customSizeHint': 'Leave empty and the named size is translated automatically (square_hd → 1024x1024). Set it only for an API picky about sizes.',
561
+ 'f.outputDir': 'Output directory',
562
+ 'f.outputDirHint': 'Relative to the session working directory; an absolute path is used as given.',
563
+ 'p.model': 'Example: fal-ai/flux-2/klein/9b',
564
+ 'p.apiKeyEnv': 'Example: FAL_API_KEY',
565
+ 'p.baseURL': 'Example: https://queue.fal.run',
566
+ 'p.pollIntervalMs': 'Example: 2000',
567
+ 'p.timeoutMs': 'Example: 180000',
568
+ 'p.customBaseURL': 'Example: https://api.openai.com/v1',
569
+ 'p.customModel': 'Example: gpt-image-1',
570
+ 'p.customKeyEnv': 'Example: OPENAI_API_KEY',
571
+ 'p.customSize': 'Example: 1024x1024',
572
+ 'p.outputDir': 'Example: generated/images',
573
+ }
574
+
575
+ const ru = {
576
+ 'settings.navLabel': 'Генерация изображений',
577
+ 'settings.title': 'Генерация изображений',
578
+ 'settings.description': 'Инструмент generate_image. Провайдер: очередь FAL или любой OpenAI-совместимый API картинок.',
579
+ 'settings.unavailable': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, раздел появится сам через несколько секунд.',
580
+ 'settings.credentialHint': 'Ключи API хранятся в Credentials под указанными ссылками (Настройки → Credentials).',
581
+ 'settings.expand': 'Развернуть',
582
+ 'settings.collapse': 'Свернуть',
583
+ 'settings.unsaved': 'Есть несохранённые изменения',
584
+ 'settings.readOnly': 'Этот деплой read-only: настройки нельзя менять из GUI.',
585
+ 'settings.overridden': 'Переопределено',
586
+ 'settings.reset': 'Сбросить',
587
+ 'settings.invalidNumber': 'Введите корректное число',
588
+ 'settings.discard': 'Отменить',
589
+ 'settings.save': 'Сохранить',
590
+ 'settings.saving': 'Сохранение…',
591
+ 'settings.saveFailed': 'Не удалось сохранить',
592
+ 'f.inherit': 'Унаследовать (по умолчанию)',
593
+ 'f.provider': 'Провайдер',
594
+ 'f.providerHint': 'fal — очередь FAL, настроенная ниже. custom — любой OpenAI-совместимый API, настраивается дальше. '
595
+ + 'codex и grok — через подписку, в которую вы вошли; ключ здесь не нужен.',
596
+ 'f.model': 'Модель (FAL)',
597
+ 'f.modelHint': 'ID модели FAL, вызывается как {baseURL}/{model}. Используется при провайдере fal.',
598
+ 'f.apiKeyEnv': 'Ссылка на ключ API',
599
+ 'f.apiKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом FAL. Префикс "Key " добавляется автоматически.',
600
+ 'f.baseURL': 'Базовый URL очереди',
601
+ 'f.baseURLHint': 'Базовый URL FAL queue (queue.fal.run).',
602
+ 'f.defaultSize': 'Размер по умолчанию',
603
+ 'f.defaultSizeHint': 'Используется, когда вызов инструмента не указывает image_size.',
604
+ 'f.defaultFormat': 'Формат по умолчанию',
605
+ 'f.defaultFormatHint': 'Используется, когда вызов инструмента не указывает output_format.',
606
+ 'f.pollIntervalMs': 'Интервал опроса (мс)',
607
+ 'f.pollIntervalMsHint': 'Интервал опроса статуса задания.',
608
+ 'f.timeoutMs': 'Таймаут (мс)',
609
+ 'f.timeoutMsHint': 'Общий таймаут генерации: submit + poll + download.',
610
+ 'f.deliverAs': 'Как картинка попадает в чат',
611
+ 'f.deliverAsHint': 'link — в результате текст со ссылкой, карточка рисует картинку по ней; работает с любой чат-моделью. image — в результате сама картинка, текстовая модель её прочитать не может, поэтому нужен dsh-vision-bridge или модель с vision.',
612
+ 'f.customBaseURL': 'Свой API: базовый URL',
613
+ 'f.customBaseURLHint': 'Корень API без завершающего слэша. Запрос уходит на {base}/images/generations.',
614
+ 'f.customModel': 'Свой API: модель',
615
+ 'f.customModelHint': 'ID модели для этого API, например gpt-image-1.',
616
+ 'f.customKeyEnv': 'Свой API: ссылка на ключ',
617
+ 'f.customKeyEnvHint': 'Имя credential-ссылки / переменной окружения с ключом. Пусто — без заголовка авторизации, для шлюзов, которым он не нужен.',
618
+ 'f.customSize': 'Свой API: фиксированный размер',
619
+ 'f.customSizeHint': 'Пусто — именованный размер переводится сам (square_hd → 1024x1024). Заполняйте только для API, придирчивого к размерам.',
620
+ 'f.outputDir': 'Папка вывода',
621
+ 'f.outputDirHint': 'Относительно рабочей папки сессии; абсолютный путь используется как есть.',
622
+ 'p.model': 'Пример: fal-ai/flux-2/klein/9b',
623
+ 'p.apiKeyEnv': 'Пример: FAL_API_KEY',
624
+ 'p.baseURL': 'Пример: https://queue.fal.run',
625
+ 'p.pollIntervalMs': 'Пример: 2000',
626
+ 'p.timeoutMs': 'Пример: 180000',
627
+ 'p.customBaseURL': 'Пример: https://api.openai.com/v1',
628
+ 'p.customModel': 'Пример: gpt-image-1',
629
+ 'p.customKeyEnv': 'Пример: OPENAI_API_KEY',
630
+ 'p.customSize': 'Пример: 1024x1024',
631
+ 'p.outputDir': 'Пример: generated/images',
632
+ }
633
+
634
+ // ── карточка результата generate_image ────────────────────────────────
635
+ //
636
+ // Карточки инструментов не рисуют image-блоки (это умеют только сообщения
637
+ // ассистента), поэтому сгенерированная картинка показывается здесь —
638
+ // keyed-запись слота tool.call.toolview по имени инструмента.
639
+ const CARD_CSS = '.fal-card{display:flex;flex-direction:column;gap:8px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-1);max-width:520px}'
640
+ + '.fal-card .fal-head{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}'
641
+ + '.fal-card img{max-width:100%;border-radius:8px;display:block}'
642
+ + '.fal-card .fal-prompt{font-size:12px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}'
643
+ + '.fal-card .fal-meta{font-size:11px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}'
644
+ + '.fal-card .fal-err{font-size:12px;color:var(--dsw-alias-state-error-primary)}'
645
+ const CARD_CSS_ID = 'dsh-image-gen/tool-card.module.css'
646
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + CARD_CSS_ID + '"]')) {
647
+ const tag = document.createElement('style')
648
+ tag.textContent = CARD_CSS
649
+ tag.setAttribute('data-plugin', 'dsh-image-gen')
650
+ tag.dataset.pluginCss = CARD_CSS_ID
651
+ document.head.appendChild(tag)
652
+ }
653
+
654
+ /**
655
+ * Вытащить из результата всё, чем можно показать картинку.
656
+ *
657
+ * В режиме «image» она приходит блоком вложения. В режиме «link» результат
658
+ * намеренно только текстовый — иначе чат-модель без vision на нём падает —
659
+ * и ссылка на наш же роут разбирается отсюда.
660
+ */
661
+ // Старое имя оставлено намеренно: ссылки в уже отправленных сообщениях
662
+ // указывают на прежний маршрут, и без него картинки в истории разговоров
663
+ // перестали бы показываться.
664
+ const IMAGE_URL_RE = /\/dsh-(?:fal-)?image-gen\/image\?[^\s)]+/
665
+
666
+ function readResult(block) {
667
+ const content = block && Array.isArray(block.content) ? block.content : []
668
+ let attachment
669
+ let text = ''
670
+ for (const part of content) {
671
+ if (part && part.type === 'image' && part.attachment) {
672
+ attachment = part.attachment
673
+ } else if (part && part.type === 'text' && typeof part.text === 'string') {
674
+ text += (text ? String.fromCharCode(10) : '') + part.text
675
+ }
676
+ }
677
+ const linked = text.match(IMAGE_URL_RE)
678
+ return {
679
+ attachment: attachment,
680
+ url: linked ? linked[0] : '',
681
+ // Ссылку из подписи убираем: она уже стала картинкой.
682
+ text: linked ? text.replace(linked[0], '').trim() : text,
683
+ }
684
+ }
685
+
686
+ function FalImageCard(props) {
687
+ const block = props.block
688
+ const running = !('kind' in (block || {}))
689
+ const failed = block && (block.isError || block.error !== undefined)
690
+ const parsed = readResult(block)
691
+ let prompt = ''
692
+ try {
693
+ const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
694
+ prompt = raw ? (JSON.parse(raw).prompt || '') : ''
695
+ } catch (e) { prompt = '' }
696
+
697
+ const head = react.createElement('div', { className: 'fal-head' },
698
+ running ? 'Генерирую изображение…' : failed ? 'Генерация не удалась' : 'Изображение')
699
+
700
+ const body = []
701
+ if (prompt) body.push(react.createElement('div', { className: 'fal-prompt', key: 'p' }, prompt))
702
+ if (failed) {
703
+ body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || 'неизвестная ошибка'))
704
+ } else if (!parsed.attachment && parsed.url) {
705
+ body.push(react.createElement('img', {
706
+ key: 'i',
707
+ src: parsed.url,
708
+ alt: prompt || 'generated image',
709
+ loading: 'lazy',
710
+ }))
711
+ if (parsed.text) body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
712
+ } else if (parsed.attachment) {
713
+ // The attachment store verifies the whole reference, so the card hands
714
+ // back the metadata the tool result already carries.
715
+ const a = parsed.attachment
716
+ const query = 'id=' + encodeURIComponent(a.attachmentId ?? a.id ?? '')
717
+ + '&mt=' + encodeURIComponent(a.mediaType || 'image/png')
718
+ + '&b=' + encodeURIComponent(String(a.bytes ?? 0))
719
+ + '&w=' + encodeURIComponent(String(a.width ?? 0))
720
+ + '&h=' + encodeURIComponent(String(a.height ?? 0))
721
+ body.push(react.createElement('img', {
722
+ key: 'i',
723
+ src: '/dsh-image-gen/image?' + query,
724
+ alt: prompt || 'generated image',
725
+ loading: 'lazy',
726
+ }))
727
+ if (parsed.text) body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
728
+ } else if (!running && parsed.text) {
729
+ body.push(react.createElement('div', { className: 'fal-meta', key: 'm' }, parsed.text))
730
+ }
731
+
732
+ return react.createElement('div', { className: 'fal-card' }, head, body)
733
+ }
734
+
735
+ /**
736
+ * Mount the settings card.
737
+ * @param ctx - client root context (slots, settingsScope, locale).
738
+ */
739
+ // Зеркало настроек в браузере перечитывается ровно по двум сигналам: коммит
740
+ // документа и переподключение. Регистрация namespace ни тем, ни другим не
741
+ // считается — settings.register кладёт запись в реестр и инвалидацию не шлёт.
742
+ //
743
+ // Поэтому страница, прочитавшая settings.describe раньше, чем хост объявил
744
+ // наш раздел, не увидит его до перезагрузки: во вкладке настроек плагинов
745
+ // просто не будет карточки. Наша карточка там не смонтирована и починить
746
+ // себя не может, так что просим зеркало перечитаться отсюда, пока нас в нём
747
+ // нет. Зеркало общее — одно перечитывание возвращает и остальные карточки,
748
+ // включая ядровые.
749
+ //
750
+ // В нормальном случае это ноль работы: первая же проверка видит namespace
751
+ // и таймер не заводится.
752
+ function refreshMirrorUntilVisible(ctx) {
753
+ const visible = () => {
754
+ try {
755
+ const view = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().getSnapshot().view
756
+ return !!view && view.namespaces.some((row) => row.ns === SETTINGS_NS)
757
+ } catch (unavailableService) {
758
+ return false
759
+ }
760
+ }
761
+ if (visible()) return () => {}
762
+ let tries = 0
763
+ const timer = setInterval(() => {
764
+ if (visible() || tries >= 15) { clearInterval(timer); return }
765
+ tries += 1
766
+ try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (notReadyYet) { /* сервис ещё не поднялся */ }
767
+ }, 1000)
768
+ return () => clearInterval(timer)
769
+ }
770
+
771
+ function apply(ctx) {
772
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-image-gen: dictionaries')
773
+ // Подпись раздела рисует не наш компонент, а боковой список, поэтому
774
+ // props.t сюда не доедет: берём переводчик, привязанный к namespace.
775
+ const t = ctx.locale.bind(NS)
776
+ ctx.effect(
777
+ () => refreshMirrorUntilVisible(ctx),
778
+ 'dsh-image-gen: re-read the settings mirror until our namespace appears',
779
+ )
780
+ // На странице, открытой не с localhost, ядро отключает настройки целиком:
781
+ // общий вид документа не читается, каждый раздел получает "unavailable",
782
+ // запись выбрасывается. Сервер это ограничение не разделяет, и плагин
783
+ // dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
784
+ // установлен — берём его службу, иначе ядровую: на loopback-странице она
785
+ // работает штатно.
786
+ let card
787
+ const cardOnce = () => {
788
+ if (card === undefined) {
789
+ card = new FalSettingsCardController(((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: SETTINGS_NS }))
790
+ }
791
+ return card
792
+ }
793
+ ctx.slots.inject('tool.call.toolview', () =>
794
+ ctx.slots.register(
795
+ { name: 'tool.call.toolview', key: 'generate_image', locale: NS },
796
+ FalImageCard,
797
+ ),
798
+ )
799
+ // Свой раздел в боковом списке настроек, а не карточка во вкладке
800
+ // «Настройки плагинов».
801
+ //
802
+ // Ту вкладку рисует ядро, и в ней карточка появляется только если
803
+ // namespace успел попасть в общий вид настроек. У части установок эта
804
+ // вкладка остаётся пустой — плагины при этом грузятся штатно, ошибок нет,
805
+ // и повлиять на неё из плагина нечем. Собственный раздел рисуем мы сами,
806
+ // рядом с Vision и Голосом, и он от этой вкладки не зависит.
807
+ //
808
+ // Заодно отпадает возня с keyed-слотом: settings.section принимает id на
809
+ // всех версиях ядра, и держать сразу id и key больше не нужно.
810
+ ctx.slots.inject('settings.section', () =>
811
+ ctx.slots.register(
812
+ {
813
+ name: 'settings.section',
814
+ id: '@goodandready/dsh-image-gen',
815
+ order: 34,
816
+ locale: NS,
817
+ label: () => t('settings.navLabel'),
818
+ inject: () => cardOnce().inject(),
819
+ },
820
+ FalSettingsCard,
821
+ ),
822
+ )
823
+ }
824
+ exports.apply = apply
825
+ exports.inject = inject
826
+ return module.exports
827
+ },
828
+ })