@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/LICENSE +21 -0
- package/README.md +202 -0
- package/cordis.patch.yml +7 -0
- package/lib/client.js +828 -0
- package/lib/index.js +389 -0
- package/lib/providers.js +266 -0
- package/package.json +69 -0
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
|
+
})
|