@dickpy/dsh-imagegen 1.2.3 → 1.4.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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
@@ -1,648 +1,936 @@
1
- /**
2
- * The dsh-imagegen settings card: api_url, api_key (secret, display-only
3
- * "set" state), and the plugin switches. Registers into the official
4
- * `settings.plugin.item` slot (the Settings Plugins Configurable tab),
5
- * independent of the dsh-web-ui family group, bound to the plugin's own
6
- * bridge settings scope.
7
- */
8
-
9
- import { useState } from 'react'
10
- import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
- import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { CardForm, booleanField, secretField, stringListField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
- import type { ImageGenScope } from './settings-scope.ts'
14
- import { IMAGE_MODEL_API, PROMPT_ENHANCE_API } from '../protocol.ts'
15
- import css from './settings-card.module.css'
16
-
17
- /** The fields this card edits (the namespace's full schema). */
18
- export interface ImageGenSettings {
19
- enabled?: boolean
20
- announceToAgent?: boolean
21
- allowAgentImageGeneration?: boolean
22
- apiUrl?: string
23
- apiKey?: string
24
- imageModels?: string[]
25
- promptApiUrl?: string
26
- promptApiKey?: string
27
- promptModel?: string
28
- }
29
-
30
- /** What the card renders. */
31
- export interface ImageGenSettingsCardState extends CardShell {
32
- /** Master switch. */
33
- enabled: CardFieldState
34
- /** System-prompt announcement flag. */
35
- announceToAgent: CardFieldState
36
- allowAgentImageGeneration: CardFieldState
37
- /** API base URL. */
38
- apiUrl: CardFieldState
39
- /** API key draft (the stored value is never rendered). */
40
- apiKey: CardFieldState
41
- imageModels: CardFieldState
42
- promptApiUrl: CardFieldState
43
- promptApiKey: CardFieldState
44
- promptModel: CardFieldState
45
- }
46
-
47
- /** The registration-side face the card's slot entry injects. */
48
- export interface ImageGenSettingsCardFace extends CardActions {
49
- hooks: {
50
- /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
51
- imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
52
- /** Whether a secret (apiKey) is currently stored. */
53
- imageGenKeySet: SnapshotStore<boolean>
54
- }
55
- }
56
-
57
- /** Bridges the imagegen scope onto the card's staged form. */
58
- export class ImageGenSettingsCardController {
59
- private readonly form: CardForm<ImageGenSettings>
60
-
61
- /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
62
- constructor(private readonly scope: ImageGenScope) {
63
- this.form = new CardForm(scope, [
64
- booleanField('enabled'),
65
- booleanField('announceToAgent'),
66
- booleanField('allowAgentImageGeneration'),
67
- textField('apiUrl'),
68
- secretField('apiKey'),
69
- stringListField('imageModels'),
70
- textField('promptApiUrl'),
71
- secretField('promptApiKey'),
72
- textField('promptModel'),
73
- ], {
74
- // The redacted wire view never returns the key; a save's outcome is
75
- // judged by the namespace's secrets sidecar instead.
76
- secretSettled: () => this.scope.getKeySetSnapshot(),
77
- })
78
- }
79
-
80
- private projection(): ImageGenSettingsCardState {
81
- return {
82
- ...this.form.shell(),
83
- enabled: this.form.field('enabled'),
84
- announceToAgent: this.form.field('announceToAgent'),
85
- allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
86
- apiUrl: this.form.field('apiUrl'),
87
- apiKey: this.form.field('apiKey'),
88
- imageModels: this.form.field('imageModels'),
89
- promptApiUrl: this.form.field('promptApiUrl'),
90
- promptApiKey: this.form.field('promptApiKey'),
91
- promptModel: this.form.field('promptModel'),
92
- }
93
- }
94
-
95
- /**
96
- * Build the face the card's slot registration injects.
97
- * @returns the card's snapshot, the key-set flag, and the form actions.
98
- */
99
- inject(): ImageGenSettingsCardFace {
100
- const cardStore = this.form.bind(() => this.projection())
101
- const keySetStore = createSnapshotStore(this.scope.getKeySetSnapshot())
102
- this.scope.subscribeKeySet(() => { keySetStore.set(this.scope.getKeySetSnapshot()) })
103
- return {
104
- hooks: {
105
- imageGenSettingsCard: cardStore,
106
- imageGenKeySet: keySetStore,
107
- },
108
- ...this.form.actions(),
109
- }
110
- }
111
- }
112
-
113
- /** Props the renderer binds for this card. */
114
- export type ImageGenSettingsCardProps =
115
- PropsRuntime<'settings.plugin.item'>
116
- & PropsLocale<'dsh-imagegen'>
117
- & InjectFace<ImageGenSettingsCardFace>
118
-
119
- /**
120
- * Render the card.
121
- * @param props - locale copy, the card snapshot, and the form actions.
122
- * @returns the card, or nothing while the namespace is still loading.
123
- */
124
- export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
125
- const { t } = props
126
- const state = props.useImageGenSettingsCard(snapshot => snapshot)
127
- const keySet = props.useImageGenKeySet(snapshot => snapshot)
128
- const [open, setOpen] = useState(false)
129
- const [promptModels, setPromptModels] = useState<string[]>([])
130
- const [loadingPromptModels, setLoadingPromptModels] = useState(false)
131
- const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
132
- const [imageModelCandidates, setImageModelCandidates] = useState<string[]>([])
133
- const [loadingImageModels, setLoadingImageModels] = useState(false)
134
- const [imageModelsError, setImageModelsError] = useState<string | null>(null)
135
- const [manualModelsOpen, setManualModelsOpen] = useState(false)
136
- const [manualModel, setManualModel] = useState('')
137
- const [enhancementOpen, setEnhancementOpen] = useState(false)
138
- const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
139
- const [manualPromptModel, setManualPromptModel] = useState('')
140
- const [promptApiOpen, setPromptApiOpen] = useState(false)
141
- const [moreOpen, setMoreOpen] = useState(false)
142
- if (!state.available) return null
143
- const title = t('settings.title')
144
- const blocked = !state.dirty || state.invalid || state.saving
145
- const disabled = !state.writable
146
- const fieldProps = {
147
- overriddenLabel: t('settings.overridden'),
148
- resetLabel: t('settings.reset'),
149
- invalidLabel: t('settings.invalidNumber'),
150
- disabled,
151
- }
152
- if (!state.exposed) {
153
- return (
154
- <li className={css.card}>
155
- <button
156
- type="button"
157
- className={css.header}
158
- aria-expanded={open}
159
- aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
160
- onClick={() => { setOpen(!open) }}
161
- >
162
- <span className={css.headText}>
163
- <span className={css.name}>{title}</span>
164
- <span className={css.description}>{t('settings.description')}</span>
165
- </span>
166
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
167
- </button>
168
- {open
169
- ? (
170
- <div className={css.body}>
171
- <p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
172
- </div>
173
- )
174
- : null}
175
- </li>
176
- )
177
- }
178
- return (
179
- <li className={css.card}>
180
- <button
181
- type="button"
182
- className={css.header}
183
- aria-expanded={open}
184
- aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
185
- onClick={() => { setOpen(!open) }}
186
- >
187
- <span className={css.headText}>
188
- <span className={css.name}>{title}</span>
189
- <span className={css.description}>{t('settings.description')}</span>
190
- </span>
191
- {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
192
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
193
- </button>
194
- {open
195
- ? (
196
- <div className={css.body}>
197
- {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
198
- <ValueField
199
- id="dsh-imagegen-settings-apiurl"
200
- label={t('settings.apiUrl')}
201
- hint={t('settings.apiUrlHint')}
202
- placeholder="https://api.openai.com/v1"
203
- {...fieldProps}
204
- {...state.apiUrl}
205
- onEdit={(text) => { props.edit('apiUrl', text) }}
206
- onReset={() => { props.resetField('apiUrl') }}
207
- />
208
- <ValueField
209
- id="dsh-imagegen-settings-apikey"
210
- label={t('settings.apiKey')}
211
- hint={keySet ? t('settings.apiKeySet') : t('settings.apiKeyHint')}
212
- placeholder="sk-…"
213
- secret
214
- {...fieldProps}
215
- {...state.apiKey}
216
- overridden={false}
217
- onEdit={(text) => { props.edit('apiKey', text) }}
218
- onReset={() => { props.resetField('apiKey') }}
219
- clearLabel={t('settings.apiKeyClear')}
220
- onClear={() => { props.resetField('apiKey') }}
221
- canClear={keySet}
222
- />
223
- <div className={css.sectionDivider} />
224
- <section className={css.modelSection} aria-label={t('settings.imageModelsTitle')}>
225
- <div className={css.sectionHeader}>
226
- <div>
227
- <h3 className={css.sectionTitle}>{t('settings.imageModelsTitle')}</h3>
228
- <p className={css.sectionHint}>{t('settings.imageModelsHint')}</p>
229
- </div>
230
- <button
231
- type="button"
232
- className={css.modelFetch}
233
- disabled={disabled || loadingImageModels}
234
- onClick={() => {
235
- setLoadingImageModels(true)
236
- setImageModelsError(null)
237
- void fetch(IMAGE_MODEL_API.models, { method: 'POST' })
238
- .then(async response => {
239
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
240
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
241
- setImageModelCandidates(body.models ?? [])
242
- })
243
- .catch(error => { setImageModelsError(error instanceof Error ? error.message : String(error)) })
244
- .finally(() => { setLoadingImageModels(false) })
245
- }}
246
- >
247
- {loadingImageModels ? t('settings.imageModelsLoading') : t('settings.imageModelsFetch')}
248
- </button>
249
- </div>
250
- <div className={css.modelSummary}>
251
- {splitModels(state.imageModels.text).map(model => (
252
- <span key={model} className={css.modelChip}>
253
- <span>{model}</span>
254
- <button
255
- type="button"
256
- disabled={disabled}
257
- aria-label={`${t('settings.removeModel')}: ${model}`}
258
- onClick={() => { props.edit('imageModels', splitModels(state.imageModels.text).filter(value => value !== model).join('\n')) }}
259
- >
260
- ×
261
- </button>
262
- </span>
263
- ))}
264
- <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualModelsOpen(open => !open) }}>
265
- {manualModelsOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
266
- </button>
267
- </div>
268
- {manualModelsOpen ? (
269
- <div className={css.manualModelRow}>
270
- <input
271
- className={css.input}
272
- value={manualModel}
273
- placeholder={t('settings.addModelPlaceholder')}
274
- disabled={disabled}
275
- onChange={event => { setManualModel(event.target.value) }}
276
- onKeyDown={event => {
277
- if (event.key !== 'Enter') return
278
- event.preventDefault()
279
- const next = manualModel.trim()
280
- if (next === '') return
281
- props.edit('imageModels', [...splitModels(state.imageModels.text), next].join('\n'))
282
- setManualModel('')
283
- }}
284
- />
285
- <button
286
- type="button"
287
- className={css.addModel}
288
- disabled={disabled || manualModel.trim() === ''}
289
- onClick={() => {
290
- props.edit('imageModels', [...splitModels(state.imageModels.text), manualModel.trim()].join('\n'))
291
- setManualModel('')
292
- }}
293
- >
294
- {t('settings.addModelConfirm')}
295
- </button>
296
- </div>
297
- ) : null}
298
- {imageModelCandidates.length > 0 ? (
299
- <div className={css.modelCandidateList} role="group" aria-label={t('settings.imageModelsCandidates')}>
300
- <span className={css.modelCandidateLabel}>{t('settings.imageModelsCandidates')}</span>
301
- {imageModelCandidates.map(candidate => {
302
- const selected = splitModels(state.imageModels.text).includes(candidate)
303
- return (
304
- <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
305
- <input
306
- type="checkbox"
307
- checked={selected}
308
- disabled={disabled}
309
- onChange={() => {
310
- const selectedModels = splitModels(state.imageModels.text)
311
- const next = selected ? selectedModels.filter(model => model !== candidate) : [...selectedModels, candidate]
312
- props.edit('imageModels', next.join('\n'))
313
- }}
314
- />
315
- <span>{candidate}</span>
316
- </label>
317
- )
318
- })}
319
- </div>
320
- ) : null}
321
- {imageModelsError !== null ? <p className={css.failed} role="status">{imageModelsError}</p> : null}
322
- </section>
323
- <button
324
- type="button"
325
- className={css.disclosure}
326
- aria-expanded={enhancementOpen}
327
- onClick={() => { setEnhancementOpen(open => !open) }}
328
- >
329
- <span>{t('settings.promptEnhanceTitle')}</span>
330
- <span>{t('settings.optional')}</span>
331
- <span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
332
- </button>
333
- {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
334
- <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
335
- <div className={css.sectionHeader}>
336
- <div>
337
- <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
338
- <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
339
- </div>
340
- <button
341
- type="button"
342
- className={css.modelFetch}
343
- disabled={disabled || loadingPromptModels}
344
- onClick={() => {
345
- setLoadingPromptModels(true)
346
- setPromptModelsError(null)
347
- void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
348
- .then(async response => {
349
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
350
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
351
- setPromptModels(body.models ?? [])
352
- })
353
- .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
354
- .finally(() => { setLoadingPromptModels(false) })
355
- }}
356
- >
357
- {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
358
- </button>
359
- </div>
360
- <div className={css.modelSummary}>
361
- {state.promptModel.text.trim() !== '' ? (
362
- <span className={css.modelChip}>
363
- <span>{state.promptModel.text}</span>
364
- <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
365
- </span>
366
- ) : null}
367
- <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open) }}>
368
- {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
369
- </button>
370
- </div>
371
- {manualPromptModelOpen ? (
372
- <div className={css.manualModelRow}>
373
- <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
374
- <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>
375
- {t('settings.addModelConfirm')}
376
- </button>
377
- </div>
378
- ) : null}
379
- {promptModels.length > 0 ? (
380
- <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
381
- <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
382
- {promptModels.map(candidate => (
383
- <button
384
- key={candidate}
385
- type="button"
386
- role="radio"
387
- className={css.modelCandidate}
388
- aria-checked={state.promptModel.text === candidate}
389
- data-selected={state.promptModel.text === candidate ? '' : undefined}
390
- disabled={disabled}
391
- onClick={() => { props.edit('promptModel', candidate) }}
392
- >
393
- {candidate}
394
- </button>
395
- ))}
396
- </div>
397
- ) : null}
398
- {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
399
- <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
400
- <span>{t('settings.promptApiAdvanced')}</span>
401
- <span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
402
- </button>
403
- {promptApiOpen ? <div className={css.optionalContent}>
404
- <ValueField
405
- id="dsh-imagegen-settings-prompt-apiurl"
406
- label={t('settings.promptApiUrl')}
407
- hint={t('settings.promptApiUrlHint')}
408
- placeholder="https://api.openai.com/v1"
409
- {...fieldProps}
410
- {...state.promptApiUrl}
411
- onEdit={(text) => { props.edit('promptApiUrl', text) }}
412
- onReset={() => { props.resetField('promptApiUrl') }}
413
- />
414
- <ValueField
415
- id="dsh-imagegen-settings-prompt-apikey"
416
- label={t('settings.promptApiKey')}
417
- hint={t('settings.promptApiKeyHint')}
418
- placeholder="sk-…"
419
- secret
420
- {...fieldProps}
421
- {...state.promptApiKey}
422
- overridden={false}
423
- onEdit={(text) => { props.edit('promptApiKey', text) }}
424
- onReset={() => { props.resetField('promptApiKey') }}
425
- />
426
- </div> : null}
427
- </section> : null}
428
- <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
429
- <span>{t('settings.moreOptions')}</span>
430
- <span aria-hidden="true">{moreOpen ? '' : '⌄'}</span>
431
- </button>
432
- {moreOpen ? <div className={css.optionalContent}>
433
- <BooleanField
434
- id="dsh-imagegen-settings-enabled"
435
- label={t('settings.enabled')}
436
- hint={t('settings.enabledHint')}
437
- inheritLabel={t('settings.inherit')}
438
- onLabel={t('settings.on')}
439
- offLabel={t('settings.off')}
440
- {...fieldProps}
441
- {...state.enabled}
442
- onEdit={(text) => { props.edit('enabled', text) }}
443
- onReset={() => { props.resetField('enabled') }}
444
- />
445
- <BooleanField
446
- id="dsh-imagegen-settings-announce"
447
- label={t('settings.announceToAgent')}
448
- hint={t('settings.announceToAgentHint')}
449
- inheritLabel={t('settings.inherit')}
450
- onLabel={t('settings.on')}
451
- offLabel={t('settings.off')}
452
- {...fieldProps}
453
- {...state.announceToAgent}
454
- onEdit={(text) => { props.edit('announceToAgent', text) }}
455
- onReset={() => { props.resetField('announceToAgent') }}
456
- />
457
- <BooleanField
458
- id="dsh-imagegen-settings-agent-generation"
459
- label={t('settings.allowAgentImageGeneration')}
460
- hint={t('settings.allowAgentImageGenerationHint')}
461
- inheritLabel={t('settings.inherit')}
462
- onLabel={t('settings.on')}
463
- offLabel={t('settings.off')}
464
- {...fieldProps}
465
- {...state.allowAgentImageGeneration}
466
- onEdit={(text) => { props.edit('allowAgentImageGeneration', text) }}
467
- onReset={() => { props.resetField('allowAgentImageGeneration') }}
468
- />
469
- </div> : null}
470
- <div className={css.footer}>
471
- {state.failed ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
472
- <button
473
- type="button"
474
- className={css.discard}
475
- disabled={!state.dirty || state.saving}
476
- onClick={props.discard}
477
- >
478
- {t('settings.discard')}
479
- </button>
480
- <button
481
- type="button"
482
- className={css.save}
483
- disabled={blocked}
484
- onClick={props.save}
485
- >
486
- {t(!state.saving ? 'settings.save' : 'settings.saving')}
487
- </button>
488
- </div>
489
- </div>
490
- )
491
- : null}
492
- </li>
493
- )
494
- }
495
-
496
- /** Props every field control needs regardless of its value type. */
497
- interface FieldProps {
498
- /** Stable id associating the label with its control. */
499
- id: string
500
- /** Visible label. */
501
- label: string
502
- /** One-line explanation rendered under the control. */
503
- hint: string
504
- /** Draft text this control renders. */
505
- text: string
506
- /** True when saving would leave a user-layer entry for this field. */
507
- overridden: boolean
508
- /** True when the draft is not a value this field accepts. */
509
- invalid: boolean
510
- /** Copy for the overridden badge. */
511
- overriddenLabel: string
512
- /** Copy for the reset control. */
513
- resetLabel: string
514
- /** Copy shown in place of the hint while the draft is invalid. */
515
- invalidLabel: string
516
- /** Disables every control (read-only document, or an unavailable namespace). */
517
- disabled: boolean
518
- /** Stage draft text. */
519
- onEdit: (text: string) => void
520
- /** Stage a clear so the field re-inherits the composition layer. */
521
- onReset: () => void
522
- }
523
-
524
- /** A staged value field; `secret` renders a password control. */
525
- function ValueField(props: FieldProps & {
526
- /** Render a password control. */
527
- secret?: boolean
528
- /** Placeholder shown while the draft is empty. */
529
- placeholder?: string
530
- /** Label of the dedicated clear control (secret fields). */
531
- clearLabel?: string
532
- /** Stage a clear of the stored secret. */
533
- onClear?: () => void
534
- /** Whether a stored secret exists (enables the clear control). */
535
- canClear?: boolean
536
- /** Render a multiline input for newline-separated configuration values. */
537
- multiline?: boolean
538
- }) {
539
- return (
540
- <div className={css.field}>
541
- <div className={css.head}>
542
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
543
- {props.overridden
544
- ? (
545
- <span className={css.badges}>
546
- <span className={css.badge}>{props.overriddenLabel}</span>
547
- <button
548
- type="button"
549
- className={css.reset}
550
- disabled={props.disabled}
551
- onClick={props.onReset}
552
- >
553
- {props.resetLabel}
554
- </button>
555
- </span>
556
- )
557
- : null}
558
- {props.secret === true && props.canClear === true
559
- ? (
560
- <button
561
- type="button"
562
- className={css.reset}
563
- disabled={props.disabled}
564
- onClick={props.onClear}
565
- >
566
- {props.clearLabel ?? props.resetLabel}
567
- </button>
568
- )
569
- : null}
570
- </div>
571
- {props.multiline === true ? (
572
- <textarea
573
- id={props.id}
574
- className={props.invalid ? css.textareaInvalid : css.textarea}
575
- {...props.invalid ? { 'aria-invalid': true } : {}}
576
- value={props.text}
577
- placeholder={props.placeholder ?? ''}
578
- disabled={props.disabled}
579
- onChange={(event) => { props.onEdit(event.target.value) }}
580
- />
581
- ) : (
582
- <input
583
- id={props.id}
584
- className={props.invalid ? css.inputInvalid : css.input}
585
- type={props.secret === true ? 'password' : 'text'}
586
- autoComplete={props.secret === true ? 'off' : undefined}
587
- {...props.invalid ? { 'aria-invalid': true } : {}}
588
- value={props.text}
589
- placeholder={props.placeholder ?? ''}
590
- disabled={props.disabled}
591
- onChange={(event) => { props.onEdit(event.target.value) }}
592
- />
593
- )}
594
- <p className={props.invalid ? css.invalid : css.hint}>
595
- {props.invalid ? props.invalidLabel : props.hint}
596
- </p>
597
- </div>
598
- )
599
- }
600
-
601
- function splitModels(value: string): string[] {
602
- return [...new Set(value.split(/[\n,]/).map(model => model.trim()).filter(Boolean))]
603
- }
604
-
605
- /** A staged boolean field: 继承 / 开 / 关. */
606
- function BooleanField(props: FieldProps & {
607
- /** Copy for the inherit option. */
608
- inheritLabel: string
609
- /** Copy for the on option. */
610
- onLabel: string
611
- /** Copy for the off option. */
612
- offLabel: string
613
- }) {
614
- return (
615
- <div className={css.field}>
616
- <div className={css.head}>
617
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
618
- {props.overridden
619
- ? (
620
- <span className={css.badges}>
621
- <span className={css.badge}>{props.overriddenLabel}</span>
622
- <button
623
- type="button"
624
- className={css.reset}
625
- disabled={props.disabled}
626
- onClick={props.onReset}
627
- >
628
- {props.resetLabel}
629
- </button>
630
- </span>
631
- )
632
- : null}
633
- </div>
634
- <select
635
- id={props.id}
636
- className={css.select}
637
- value={props.text}
638
- disabled={props.disabled}
639
- onChange={(event) => { props.onEdit(event.target.value) }}
640
- >
641
- <option value="">{props.inheritLabel}</option>
642
- <option value="true">{props.onLabel}</option>
643
- <option value="false">{props.offLabel}</option>
644
- </select>
645
- <p className={css.hint}>{props.hint}</p>
646
- </div>
647
- )
648
- }
1
+ /**
2
+ * The dsh-imagegen settings card: channel management (list rows with status
3
+ * dots, an editor dialog with the model-catalog alias upstream mapping, and
4
+ * built-in provider presets), plus the prompt-enhancement model and the plugin
5
+ * switches. Registers into the official `settings.plugin.item` slot (the
6
+ * Settings Plugins → Configurable tab), independent of the dsh-web-ui family
7
+ * group, bound to the plugin's own bridge settings scope.
8
+ *
9
+ * The interaction mirrors the host's model-provider page: one row per channel
10
+ * (status dot + edit/delete), two add buttons (built-in provider / custom),
11
+ * and an editor holding API key, display name, API URL, and the model catalog
12
+ * with detection.
13
+ */
14
+
15
+ import { useEffect, useRef, useState } from 'react'
16
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
17
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
18
+ import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
19
+ import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
20
+ import type { ImageGenScope } from './settings-scope.ts'
21
+ import { describeModel } from '../model-catalog.ts'
22
+ import { IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, USAGE_API, type ModelMapping, type PresetProviderView } from '../protocol.ts'
23
+ import type { ImageGenKey } from './locales.ts'
24
+ import css from './settings-card.module.css'
25
+
26
+ /** The global (non-channel) fields this card's staged form edits. */
27
+ export interface ImageGenSettings {
28
+ enabled?: boolean
29
+ announceToAgent?: boolean
30
+ allowAgentImageGeneration?: boolean
31
+ promptApiUrl?: string
32
+ promptApiKey?: string
33
+ promptModel?: string
34
+ }
35
+
36
+ /** What the card renders. */
37
+ export interface ImageGenSettingsCardState extends CardShell {
38
+ /** Channel list staging (channels + per-channel key edits + default). */
39
+ channels: ChannelsFormState
40
+ /** Master switch. */
41
+ enabled: CardFieldState
42
+ /** System-prompt announcement flag. */
43
+ announceToAgent: CardFieldState
44
+ allowAgentImageGeneration: CardFieldState
45
+ promptApiUrl: CardFieldState
46
+ promptApiKey: CardFieldState
47
+ promptModel: CardFieldState
48
+ }
49
+
50
+ /** The registration-side face the card's slot entry injects. */
51
+ export interface ImageGenSettingsCardFace extends CardActions {
52
+ /** Channel staging actions (committed together with the card's save). */
53
+ channels: ChannelsFormActions
54
+ hooks: {
55
+ /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
56
+ imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
57
+ }
58
+ }
59
+
60
+ /** Bridges the imagegen scope onto the card's staged forms. */
61
+ export class ImageGenSettingsCardController {
62
+ private readonly form: CardForm<ImageGenSettings>
63
+ private readonly channelsForm: ChannelsForm
64
+
65
+ /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
66
+ constructor(private readonly scope: ImageGenScope) {
67
+ this.form = new CardForm(scope, [
68
+ booleanField('enabled'),
69
+ booleanField('announceToAgent'),
70
+ booleanField('allowAgentImageGeneration'),
71
+ textField('promptApiUrl'),
72
+ secretField('promptApiKey'),
73
+ textField('promptModel'),
74
+ ], {
75
+ secretSettled: () => this.scope.getSecretSetSnapshot('promptApiKey'),
76
+ })
77
+ this.channelsForm = new ChannelsForm(scope)
78
+ }
79
+
80
+ private projection(): ImageGenSettingsCardState {
81
+ const shell = this.form.shell()
82
+ return {
83
+ ...shell,
84
+ dirty: shell.dirty || this.channelsForm.snapshot().dirty,
85
+ channels: this.channelsForm.snapshot(),
86
+ enabled: this.form.field('enabled'),
87
+ announceToAgent: this.form.field('announceToAgent'),
88
+ allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
89
+ promptApiUrl: this.form.field('promptApiUrl'),
90
+ promptApiKey: this.form.field('promptApiKey'),
91
+ promptModel: this.form.field('promptModel'),
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Build the face the card's slot registration injects.
97
+ * @returns the card's snapshot and the form/channel actions.
98
+ */
99
+ inject(): ImageGenSettingsCardFace {
100
+ const cardStore = this.form.bind(() => this.projection())
101
+ this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
102
+ return {
103
+ hooks: {
104
+ imageGenSettingsCard: cardStore,
105
+ },
106
+ channels: this.channelsForm.actions(),
107
+ ...this.form.actions(),
108
+ }
109
+ }
110
+ }
111
+
112
+ /** Props the renderer binds for this card. */
113
+ export type ImageGenSettingsCardProps =
114
+ PropsRuntime<'settings.plugin.item'>
115
+ & PropsLocale<'dsh-imagegen'>
116
+ & InjectFace<ImageGenSettingsCardFace>
117
+
118
+ /** Host-computed usage counters (generation-count badges). */
119
+ interface UsageCounters {
120
+ byChannel: Record<string, Record<string, number>>
121
+ totals: Record<string, number>
122
+ }
123
+
124
+ /**
125
+ * Render the card.
126
+ * @param props - locale copy, the card snapshot, and the form actions.
127
+ * @returns the card, or nothing while the namespace is still loading.
128
+ */
129
+ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
130
+ const { t } = props
131
+ const state = props.useImageGenSettingsCard(snapshot => snapshot)
132
+ const [open, setOpen] = useState(false)
133
+ // Global-section local states (prompt enhancement etc.).
134
+ const [promptModels, setPromptModels] = useState<string[]>([])
135
+ const [loadingPromptModels, setLoadingPromptModels] = useState(false)
136
+ const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
137
+ const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
138
+ const [manualPromptModel, setManualPromptModel] = useState('')
139
+ const [enhancementOpen, setEnhancementOpen] = useState(false)
140
+ const [promptApiOpen, setPromptApiOpen] = useState(false)
141
+ const [moreOpen, setMoreOpen] = useState(false)
142
+ // Channel list local states.
143
+ const [editingId, setEditingId] = useState<string | null>(null)
144
+ const [presetPickerOpen, setPresetPickerOpen] = useState(false)
145
+ const [presets, setPresets] = useState<PresetProviderView[]>([])
146
+ const [presetError, setPresetError] = useState<string | null>(null)
147
+ const [usage, setUsage] = useState<UsageCounters | null>(null)
148
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
149
+
150
+ // Usage counters: refreshed once per card open (and after a successful save).
151
+ useEffect(() => {
152
+ if (!state.exposed) return
153
+ let alive = true
154
+ void fetch(USAGE_API, { method: 'POST' })
155
+ .then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) })
156
+ .catch(() => { /* counters are best-effort */ })
157
+ return () => { alive = false }
158
+ }, [state.exposed])
159
+
160
+ if (!state.available) return null
161
+ const title = t('settings.title')
162
+ const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
163
+ const disabled = !state.writable
164
+ const fieldProps = {
165
+ overriddenLabel: t('settings.overridden'),
166
+ resetLabel: t('settings.reset'),
167
+ invalidLabel: t('settings.invalidNumber'),
168
+ disabled,
169
+ }
170
+
171
+ if (!state.exposed) {
172
+ return (
173
+ <li className={css.card}>
174
+ <button
175
+ type="button"
176
+ className={css.header}
177
+ aria-expanded={open}
178
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
179
+ onClick={() => { setOpen(!open) }}
180
+ >
181
+ <span className={css.headText}>
182
+ <span className={css.name}>{title}</span>
183
+ <span className={css.description}>{t('settings.description')}</span>
184
+ </span>
185
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
186
+ </button>
187
+ {open
188
+ ? (
189
+ <div className={css.body}>
190
+ <p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
191
+ </div>
192
+ )
193
+ : null}
194
+ </li>
195
+ )
196
+ }
197
+
198
+ const channels = state.channels.channels
199
+ const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
200
+
201
+ return (
202
+ <li className={css.card}>
203
+ <button
204
+ type="button"
205
+ className={css.header}
206
+ aria-expanded={open}
207
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
208
+ onClick={() => { setOpen(!open) }}
209
+ >
210
+ <span className={css.headText}>
211
+ <span className={css.name}>{title}</span>
212
+ <span className={css.description}>{t('settings.description')}</span>
213
+ </span>
214
+ {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
215
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
216
+ </button>
217
+ {open
218
+ ? (
219
+ <div className={css.body}>
220
+ {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
221
+
222
+ <section className={css.channelSection} aria-label={t('channels.title')}>
223
+ <div className={css.sectionHeader}>
224
+ <div>
225
+ <h3 className={css.sectionTitle}>{t('channels.title')}</h3>
226
+ <p className={css.sectionHint}>{t('channels.hint')}</p>
227
+ </div>
228
+ </div>
229
+ {channels.length === 0
230
+ ? <p className={css.channelEmpty}>{t('channels.empty')}</p>
231
+ : (
232
+ <ul className={css.channelList}>
233
+ {channels.map(channel => {
234
+ const keyHeld = state.channels.keySet[channel.id] === true
235
+ const ready = keyHeld && channel.models.length > 0
236
+ const isDefault = channel.id === state.channels.defaultChannelId
237
+ if (confirmDeleteId === channel.id) {
238
+ return (
239
+ <li key={channel.id} className={css.channelRow} data-action>
240
+ <span className={css.deleteConfirmText}>{t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}</span>
241
+ <button type="button" className={css.channelDanger} disabled={disabled} onClick={() => { props.channels.setChannels(channels.filter(candidate => candidate.id !== channel.id)); if (isDefault && channels.length > 1) { const next = channels.find(candidate => candidate.id !== channel.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setConfirmDeleteId(null); if (editingId === channel.id) setEditingId(null) }}>{t('channels.confirm')}</button>
242
+ <button type="button" className={css.channelAction} disabled={disabled} onClick={() => { setConfirmDeleteId(null) }}>{t('channels.cancel')}</button>
243
+ </li>
244
+ )
245
+ }
246
+ return (
247
+ <li key={channel.id} className={css.channelRow}>
248
+ <span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
249
+ <button type="button" className={css.channelMain} disabled={disabled} onClick={() => { setEditingId(channel.id) }}>
250
+ <span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
251
+ <span className={css.channelMeta}>
252
+ <span className={css.channelBadge} data-warn={!keyHeld || channel.models.length === 0 ? '' : undefined}>
253
+ {keyHeld ? t('channels.keySet') : t('channels.keyMissing')}
254
+ {' · '}
255
+ {channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}
256
+ </span>
257
+ </span>
258
+ </button>
259
+ <button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
260
+ <button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
261
+ </li>
262
+ )
263
+ })}
264
+ </ul>
265
+ )}
266
+ <div className={css.channelControls}>
267
+ {open && presetPickerOpen ? (
268
+ <PresetPicker
269
+ t={t}
270
+ presets={presets}
271
+ error={presetError}
272
+ disabled={state.writable === false}
273
+ onLoad={() => {
274
+ setPresetError(null)
275
+ void fetch(PRESETS_API, { method: 'POST' })
276
+ .then(async response => {
277
+ const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
278
+ if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
279
+ setPresets(body.presets)
280
+ })
281
+ .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
282
+ }}
283
+ onPick={(preset) => {
284
+ const draft = newChannelDraft(preset)
285
+ props.channels.setChannels([...channels, draft])
286
+ setPresetPickerOpen(false)
287
+ setEditingId(draft.id)
288
+ }}
289
+ onCustom={() => {
290
+ const draft = newChannelDraft(undefined)
291
+ props.channels.setChannels([...channels, draft])
292
+ setPresetPickerOpen(false)
293
+ setEditingId(draft.id)
294
+ }}
295
+ onClose={() => { setPresetPickerOpen(false) }}
296
+ />
297
+ ) : null}
298
+
299
+ <div className={css.channelAddRow}>
300
+ <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
301
+ <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
302
+ </div>
303
+ </div>
304
+ </section>
305
+
306
+ <button
307
+ type="button"
308
+ className={css.disclosure}
309
+ aria-expanded={enhancementOpen}
310
+ onClick={() => { setEnhancementOpen(open => !open) }}
311
+ >
312
+ <span>{t('settings.promptEnhanceTitle')}</span>
313
+ <span>{t('settings.optional')}</span>
314
+ <span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
315
+ </button>
316
+ {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
317
+ <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
318
+ <div className={css.sectionHeader}>
319
+ <div>
320
+ <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
321
+ <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
322
+ </div>
323
+ <button
324
+ type="button"
325
+ className={css.modelFetch}
326
+ disabled={disabled || loadingPromptModels}
327
+ onClick={() => {
328
+ setLoadingPromptModels(true)
329
+ setPromptModelsError(null)
330
+ void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
331
+ .then(async response => {
332
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
333
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
334
+ setPromptModels(body.models ?? [])
335
+ })
336
+ .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
337
+ .finally(() => { setLoadingPromptModels(false) })
338
+ }}
339
+ >
340
+ {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
341
+ </button>
342
+ </div>
343
+ <div className={css.modelSummary}>
344
+ {state.promptModel.text.trim() !== '' ? (
345
+ <span className={css.modelChip}>
346
+ <span>{state.promptModel.text}</span>
347
+ <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
348
+ </span>
349
+ ) : null}
350
+ <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
351
+ {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
352
+ </button>
353
+ </div>
354
+ {manualPromptModelOpen ? (
355
+ <div className={css.manualModelRow}>
356
+ <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
357
+ <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
358
+ </div>
359
+ ) : null}
360
+ {promptModels.length > 0 ? (
361
+ <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
362
+ <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
363
+ {promptModels.map(candidate => (
364
+ <button
365
+ key={candidate}
366
+ type="button"
367
+ role="radio"
368
+ className={css.modelCandidate}
369
+ aria-checked={state.promptModel.text === candidate}
370
+ data-selected={state.promptModel.text === candidate ? '' : undefined}
371
+ disabled={disabled}
372
+ onClick={() => { props.edit('promptModel', candidate) }}
373
+ >
374
+ {candidate}
375
+ </button>
376
+ ))}
377
+ </div>
378
+ ) : null}
379
+ {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
380
+ <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
381
+ <span>{t('settings.promptApiAdvanced')}</span>
382
+ <span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
383
+ </button>
384
+ {promptApiOpen ? <div className={css.optionalContent}>
385
+ <ValueField
386
+ id="dsh-imagegen-settings-prompt-apiurl"
387
+ label={t('settings.promptApiUrl')}
388
+ hint={t('settings.promptApiUrlHint')}
389
+ placeholder="https://api.openai.com/v1"
390
+ {...fieldProps}
391
+ {...state.promptApiUrl}
392
+ onEdit={(text) => { props.edit('promptApiUrl', text) }}
393
+ onReset={() => { props.resetField('promptApiUrl') }}
394
+ />
395
+ <ValueField
396
+ id="dsh-imagegen-settings-prompt-apikey"
397
+ label={t('settings.promptApiKey')}
398
+ hint={t('settings.promptApiKeyHint')}
399
+ placeholder="sk-…"
400
+ secret
401
+ {...fieldProps}
402
+ {...state.promptApiKey}
403
+ overridden={false}
404
+ onEdit={(text) => { props.edit('promptApiKey', text) }}
405
+ onReset={() => { props.resetField('promptApiKey') }}
406
+ />
407
+ </div> : null}
408
+ </section> : null}
409
+
410
+ <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
411
+ <span>{t('settings.moreOptions')}</span>
412
+ <span aria-hidden="true">{moreOpen ? '⌃' : ''}</span>
413
+ </button>
414
+ {moreOpen ? <div className={css.optionalContent}>
415
+ <BooleanField
416
+ id="dsh-imagegen-settings-enabled"
417
+ label={t('settings.enabled')}
418
+ hint={t('settings.enabledHint')}
419
+ inheritLabel={t('settings.inherit')}
420
+ onLabel={t('settings.on')}
421
+ offLabel={t('settings.off')}
422
+ {...fieldProps}
423
+ {...state.enabled}
424
+ onEdit={(text) => { props.edit('enabled', text) }}
425
+ onReset={() => { props.resetField('enabled') }}
426
+ />
427
+ <BooleanField
428
+ id="dsh-imagegen-settings-announce"
429
+ label={t('settings.announceToAgent')}
430
+ hint={t('settings.announceToAgentHint')}
431
+ inheritLabel={t('settings.inherit')}
432
+ onLabel={t('settings.on')}
433
+ offLabel={t('settings.off')}
434
+ {...fieldProps}
435
+ {...state.announceToAgent}
436
+ onEdit={(text) => { props.edit('announceToAgent', text) }}
437
+ onReset={() => { props.resetField('announceToAgent') }}
438
+ />
439
+ <BooleanField
440
+ id="dsh-imagegen-settings-agent-generation"
441
+ label={t('settings.allowAgentImageGeneration')}
442
+ hint={t('settings.allowAgentImageGenerationHint')}
443
+ inheritLabel={t('settings.inherit')}
444
+ onLabel={t('settings.on')}
445
+ offLabel={t('settings.off')}
446
+ {...fieldProps}
447
+ {...state.allowAgentImageGeneration}
448
+ onEdit={(text) => { props.edit('allowAgentImageGeneration', text) }}
449
+ onReset={() => { props.resetField('allowAgentImageGeneration') }}
450
+ />
451
+ </div> : null}
452
+ <div className={css.footer}>
453
+ {(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
454
+ <button
455
+ type="button"
456
+ className={css.discard}
457
+ disabled={!state.dirty || state.saving || state.channels.saving}
458
+ onClick={() => { props.discard(); props.channels.discard() }}
459
+ >
460
+ {t('settings.discard')}
461
+ </button>
462
+ <button
463
+ type="button"
464
+ className={css.save}
465
+ disabled={blocked}
466
+ onClick={() => { void props.channels.commit(); void props.save() }}
467
+ >
468
+ {t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
469
+ </button>
470
+ </div>
471
+ </div>
472
+ )
473
+ : null}
474
+
475
+ {open && editing !== undefined ? (
476
+ <ChannelEditor
477
+ key={editing.id}
478
+ t={t}
479
+ channel={editing}
480
+ keyHeld={state.channels.keySet[editing.id] === true}
481
+ usage={usage}
482
+ otherChannels={channels.filter(channel => channel.id !== editing.id)}
483
+ isDefault={editing.id === state.channels.defaultChannelId}
484
+ writable={state.writable}
485
+ onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
486
+ onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
487
+ onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
488
+ onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
489
+ onRemove={() => { props.channels.setChannels(channels.filter(channel => channel.id !== editing.id)); if (editing.id === state.channels.defaultChannelId && channels.length > 1) { const next = channels.find(channel => channel.id !== editing.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setEditingId(null) }}
490
+ onClose={() => { setEditingId(null) }}
491
+ />
492
+ ) : null}
493
+
494
+ </li>
495
+ )
496
+ }
497
+
498
+ /** Channel row + dialog helpers -------------------------------------------------- */
499
+
500
+ function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
501
+ return {
502
+ id: clientId(),
503
+ preset: preset?.id ?? '',
504
+ name: preset?.name ?? '',
505
+ apiUrl: preset?.apiUrl ?? '',
506
+ models: (preset?.models ?? []).map(model => ({ ...model })),
507
+ }
508
+ }
509
+
510
+ function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
511
+ const draft = newChannelDraft(undefined)
512
+ form.setChannels([...channels, draft])
513
+ openEditor(draft.id)
514
+ }
515
+
516
+ /** Patch one field (or models) of one staged channel. */
517
+ function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
518
+ form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
519
+ }
520
+
521
+ function clientId(): string {
522
+ const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
523
+ return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
524
+ }
525
+
526
+ /** Built-in provider picker, expanded inside the settings card. */
527
+ function PresetPicker(props: {
528
+ t: (key: ImageGenKey, params?: Record<string, string | number>) => string
529
+ presets: PresetProviderView[]
530
+ error: string | null
531
+ disabled: boolean
532
+ onLoad: () => void
533
+ onPick: (preset: PresetProviderView) => void
534
+ onCustom: () => void
535
+ onClose: () => void
536
+ }) {
537
+ const { t } = props
538
+ const loadedRef = useRef(false)
539
+ useEffect(() => {
540
+ if (loadedRef.current) return
541
+ loadedRef.current = true
542
+ props.onLoad()
543
+ }, [])
544
+ return (
545
+ <section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
546
+ <header className={css.presetInlineHeader}>
547
+ <div>
548
+ <h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
549
+ <p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
550
+ </div>
551
+ <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
552
+ </header>
553
+ <div className={css.presetList}>
554
+ {props.presets.map(preset => (
555
+ <button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
556
+ <span className={css.presetName}>{preset.name}</span>
557
+ <span className={css.presetMeta}>{preset.models.map(model => model.alias).join(' · ')}</span>
558
+ </button>
559
+ ))}
560
+ <button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
561
+ <span className={css.presetName}>+ {t('channels.addCustom')}</span>
562
+ <span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
563
+ </button>
564
+ {props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
565
+ </div>
566
+ </section>
567
+ )
568
+ }
569
+
570
+ /** Channel editor (modal): key, display name, API URL, model catalog. */
571
+ function ChannelEditor(props: {
572
+ t: (key: ImageGenKey, params?: Record<string, string | number>) => string
573
+ channel: ChannelDraft
574
+ keyHeld: boolean
575
+ usage: UsageCounters | null
576
+ otherChannels: ChannelDraft[]
577
+ isDefault: boolean
578
+ writable: boolean
579
+ onPatch: (patch: Partial<ChannelDraft>) => void
580
+ onSetModels: (models: ModelMapping[]) => void
581
+ onSetKey: (value: string | undefined) => void
582
+ onSetDefault: () => void
583
+ onRemove: () => void
584
+ onClose: () => void
585
+ }) {
586
+ const { t, channel } = props
587
+ const [keyDraft, setKeyDraft] = useState('')
588
+ const [candidates, setCandidates] = useState<string[] | null>(null)
589
+ const [detecting, setDetecting] = useState(false)
590
+ const [detectError, setDetectError] = useState<string | null>(null)
591
+ const [manualId, setManualId] = useState('')
592
+ const [removeOpen, setRemoveOpen] = useState(false)
593
+ const [copyFrom, setCopyFrom] = useState('')
594
+
595
+ const generatedCount = (alias: string): number => {
596
+ if (props.usage === null) return 0
597
+ const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
598
+ return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
599
+ }
600
+
601
+ const detect = (): void => {
602
+ setDetecting(true)
603
+ setDetectError(null)
604
+ const payload: Record<string, unknown> = { channelId: channel.id }
605
+ if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
606
+ if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
607
+ void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
608
+ .then(async response => {
609
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
610
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
611
+ setCandidates(body.models ?? [])
612
+ })
613
+ .catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
614
+ .finally(() => { setDetecting(false) })
615
+ }
616
+
617
+ // Auto-detect once when the dialog opens with a complete endpoint.
618
+ const autoDetected = useRef(false)
619
+ useEffect(() => {
620
+ if (autoDetected.current) return
621
+ autoDetected.current = true
622
+ if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
623
+ }, [])
624
+
625
+ const addManual = (): void => {
626
+ const id = manualId.trim()
627
+ if (id === '') return
628
+ const next = [...channel.models]
629
+ const alias = id
630
+ if (!next.some(model => model.alias === alias)) next.push({ alias, id })
631
+ props.onSetModels(next)
632
+ setManualId('')
633
+ }
634
+
635
+ const copyFromChannel = (): void => {
636
+ const source = props.otherChannels.find(chance => chance.id === copyFrom)
637
+ if (source === undefined) return
638
+ const merged = [...channel.models]
639
+ for (const model of source.models) {
640
+ const alias = model.alias
641
+ // Copy with a collision suffix so both sources stay selectable.
642
+ let unique = alias
643
+ let suffix = 2
644
+ while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
645
+ merged.push({ alias: unique, id: model.id })
646
+ }
647
+ props.onSetModels(merged)
648
+ setCopyFrom('')
649
+ }
650
+
651
+ return (
652
+ <div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
653
+ <div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
654
+ <header className={css.editorHeader}>
655
+ <div>
656
+ <h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
657
+ <p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
658
+ </div>
659
+ <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
660
+ </header>
661
+
662
+ <div className={css.editorField}>
663
+ <label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
664
+ <input id="dsh-imagegen-channel-name" className={css.input} value={channel.name} placeholder={t('channels.untitled')} disabled={!props.writable} onChange={event => { props.onPatch({ name: event.target.value }) }} />
665
+ </div>
666
+ <div className={css.editorField}>
667
+ <label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
668
+ <input id="dsh-imagegen-channel-url" className={css.input} value={channel.apiUrl} placeholder="https://api.example.com/v1" disabled={!props.writable} onChange={event => { props.onPatch({ apiUrl: event.target.value }) }} />
669
+ </div>
670
+ <div className={css.editorField}>
671
+ <div className={css.head}>
672
+ <label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
673
+ {props.keyHeld || keyDraft !== ''
674
+ ? (
675
+ <button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
676
+ {t('channels.keyClear')}
677
+ </button>
678
+ )
679
+ : null}
680
+ </div>
681
+ <input
682
+ id="dsh-imagegen-channel-key"
683
+ className={css.input}
684
+ type="password"
685
+ autoComplete="off"
686
+ value={keyDraft}
687
+ placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
688
+ disabled={!props.writable}
689
+ onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
690
+ />
691
+ </div>
692
+
693
+ <div className={css.editorDivider} />
694
+
695
+ <div className={css.editorSectionHeader}>
696
+ <h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
697
+ <button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
698
+ {detecting ? t('channels.detecting') : t('channels.detect')}
699
+ </button>
700
+ </div>
701
+ {detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
702
+ {candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
703
+
704
+ {channel.models.length === 0
705
+ ? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
706
+ : (
707
+ <ul className={css.modelRows}>
708
+ {channel.models.map((model, index) => {
709
+ const entry = describeModel(model.id || model.alias)
710
+ const generated = generatedCount(model.alias)
711
+ return (
712
+ <li key={`${model.alias}-${index}`} className={css.modelRow}>
713
+ <div className={css.modelRowInputs}>
714
+ <input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
715
+ const next = [...channel.models]
716
+ next[index] = { ...model, alias: event.target.value }
717
+ props.onSetModels(next)
718
+ }} />
719
+ <span className={css.modelArrow}>→</span>
720
+ <input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
721
+ const next = [...channel.models]
722
+ next[index] = { ...model, id: event.target.value }
723
+ props.onSetModels(next)
724
+ }} />
725
+ </div>
726
+ <div className={css.modelRowBadges}>
727
+ <span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
728
+ {generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
729
+ <button type="button" className={css.modelRowRemove} disabled={!props.writable} aria-label={`${t('channels.removeModel')}: ${model.alias}`} onClick={() => { props.onSetModels(channel.models.filter((_, i) => i !== index)) }}>×</button>
730
+ </div>
731
+ </li>
732
+ )
733
+ })}
734
+ </ul>
735
+ )}
736
+
737
+ <div className={css.editorTools}>
738
+ <div className={css.manualModelRow}>
739
+ <input className={css.input} value={manualId} placeholder={t('channels.manualAddPlaceholder')} disabled={!props.writable} onChange={event => { setManualId(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addManual() } }} />
740
+ <button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
741
+ </div>
742
+ {props.otherChannels.length > 0 ? (
743
+ <div className={css.manualModelRow}>
744
+ <select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
745
+ <option value="">{t('channels.copyFrom')}</option>
746
+ {props.otherChannels.map(other => (
747
+ <option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
748
+ ))}
749
+ </select>
750
+ <button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
751
+ </div>
752
+ ) : null}
753
+ </div>
754
+
755
+ {candidates !== null && candidates.length > 0 ? (
756
+ <div className={css.modelCandidateList}>
757
+ <span className={css.modelCandidateLabel}>
758
+ {t('channels.candidatesTitle')}
759
+ </span>
760
+ {candidates.map(candidate => {
761
+ const selected = channel.models.some(model => model.alias === candidate)
762
+ const entry = describeModel(candidate)
763
+ return (
764
+ <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
765
+ <input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
766
+ const merged = selected
767
+ ? channel.models.filter(model => model.alias !== candidate)
768
+ : [...channel.models, { alias: candidate, id: candidate }]
769
+ props.onSetModels(merged)
770
+ }} />
771
+ <span>{candidate}</span>
772
+ {!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
773
+ </label>
774
+ )
775
+ })}
776
+ </div>
777
+ ) : null}
778
+
779
+ <div className={css.editorDivider} />
780
+
781
+ <div className={css.editorFooter}>
782
+ {props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
783
+ <button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
784
+ )}
785
+ <span className={css.spacer} />
786
+ {removeOpen
787
+ ? (
788
+ <>
789
+ <button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
790
+ <button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
791
+ </>
792
+ )
793
+ : (
794
+ <button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
795
+ )}
796
+ </div>
797
+ </div>
798
+ </div>
799
+ )
800
+ }
801
+
802
+ /** Props every field control needs regardless of its value type. */
803
+ interface FieldProps {
804
+ /** Stable id associating the label with its control. */
805
+ id: string
806
+ /** Visible label. */
807
+ label: string
808
+ /** One-line explanation rendered under the control. */
809
+ hint: string
810
+ /** Draft text this control renders. */
811
+ text: string
812
+ /** True when saving would leave a user-layer entry for this field. */
813
+ overridden: boolean
814
+ /** True when the draft is not a value this field accepts. */
815
+ invalid: boolean
816
+ /** Copy for the overridden badge. */
817
+ overriddenLabel: string
818
+ /** Copy for the reset control. */
819
+ resetLabel: string
820
+ /** Copy shown in place of the hint while the draft is invalid. */
821
+ invalidLabel: string
822
+ /** Disables every control (read-only document, or an unavailable namespace). */
823
+ disabled: boolean
824
+ /** Stage draft text. */
825
+ onEdit: (text: string) => void
826
+ /** Stage a clear so the field re-inherits the composition layer. */
827
+ onReset: () => void
828
+ }
829
+
830
+ /** A staged value field; `secret` renders a password control. */
831
+ function ValueField(props: FieldProps & {
832
+ /** Render a password control. */
833
+ secret?: boolean
834
+ /** Placeholder shown while the draft is empty. */
835
+ placeholder?: string
836
+ /** Label of the dedicated clear control (secret fields). */
837
+ clearLabel?: string
838
+ /** Stage a clear of the stored secret. */
839
+ onClear?: () => void
840
+ /** Whether a stored secret exists (enables the clear control). */
841
+ canClear?: boolean
842
+ }) {
843
+ return (
844
+ <div className={css.field}>
845
+ <div className={css.head}>
846
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
847
+ {props.overridden
848
+ ? (
849
+ <span className={css.badges}>
850
+ <span className={css.badge}>{props.overriddenLabel}</span>
851
+ <button
852
+ type="button"
853
+ className={css.reset}
854
+ disabled={props.disabled}
855
+ onClick={props.onReset}
856
+ >
857
+ {props.resetLabel}
858
+ </button>
859
+ </span>
860
+ )
861
+ : null}
862
+ {props.secret === true && props.canClear === true
863
+ ? (
864
+ <button
865
+ type="button"
866
+ className={css.reset}
867
+ disabled={props.disabled}
868
+ onClick={props.onClear}
869
+ >
870
+ {props.clearLabel ?? props.resetLabel}
871
+ </button>
872
+ )
873
+ : null}
874
+ </div>
875
+ <input
876
+ id={props.id}
877
+ className={props.invalid ? css.inputInvalid : css.input}
878
+ type={props.secret === true ? 'password' : 'text'}
879
+ autoComplete={props.secret === true ? 'off' : undefined}
880
+ {...props.invalid ? { 'aria-invalid': true } : {}}
881
+ value={props.text}
882
+ placeholder={props.placeholder ?? ''}
883
+ disabled={props.disabled}
884
+ onChange={(event) => { props.onEdit(event.target.value) }}
885
+ />
886
+ <p className={props.invalid ? css.invalid : css.hint}>
887
+ {props.invalid ? props.invalidLabel : props.hint}
888
+ </p>
889
+ </div>
890
+ )
891
+ }
892
+
893
+ /** A staged boolean field: 继承 / 开 / 关. */
894
+ function BooleanField(props: FieldProps & {
895
+ /** Copy for the inherit option. */
896
+ inheritLabel: string
897
+ /** Copy for the on option. */
898
+ onLabel: string
899
+ /** Copy for the off option. */
900
+ offLabel: string
901
+ }) {
902
+ return (
903
+ <div className={css.field}>
904
+ <div className={css.head}>
905
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
906
+ {props.overridden
907
+ ? (
908
+ <span className={css.badges}>
909
+ <span className={css.badge}>{props.overriddenLabel}</span>
910
+ <button
911
+ type="button"
912
+ className={css.reset}
913
+ disabled={props.disabled}
914
+ onClick={props.onReset}
915
+ >
916
+ {props.resetLabel}
917
+ </button>
918
+ </span>
919
+ )
920
+ : null}
921
+ </div>
922
+ <select
923
+ id={props.id}
924
+ className={css.select}
925
+ value={props.text}
926
+ disabled={props.disabled}
927
+ onChange={(event) => { props.onEdit(event.target.value) }}
928
+ >
929
+ <option value="">{props.inheritLabel}</option>
930
+ <option value="true">{props.onLabel}</option>
931
+ <option value="false">{props.offLabel}</option>
932
+ </select>
933
+ <p className={css.hint}>{props.hint}</p>
934
+ </div>
935
+ )
936
+ }