@dickpy/dsh-imagegen 1.3.0 → 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 -182
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +1103 -837
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +265 -135
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -418
  10. package/src/client/ImageGenPanel.tsx +1699 -1508
  11. package/src/client/SettingsCard.tsx +936 -957
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -263
  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 +169 -158
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -594
  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 -1023
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -298
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -478
  31. package/src/gallery-store.ts +286 -286
  32. package/src/generation-runtime.ts +79 -75
  33. package/src/history-store.ts +250 -244
  34. package/src/image-format.ts +11 -11
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -318
  37. package/src/model-catalog.ts +115 -98
  38. package/src/presets.ts +71 -63
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -326
  41. package/src/routes.ts +916 -906
  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,957 +1,936 @@
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
- {channel.apiUrl !== '' ? <span className={css.channelHost}>{hostOf(channel.apiUrl)}</span> : null}
253
- <span className={css.channelBadge} data-warn={keyHeld ? undefined : ''}>{keyHeld ? t('channels.keySet') : t('channels.keyMissing')}</span>
254
- <span className={css.channelBadge} data-warn={channel.models.length > 0 ? undefined : ''}>{channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}</span>
255
- {isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : null}
256
- </span>
257
- </button>
258
- <button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
259
- <button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
260
- </li>
261
- )
262
- })}
263
- </ul>
264
- )}
265
- <div className={css.channelControls}>
266
- {open && presetPickerOpen ? (
267
- <PresetPicker
268
- t={t}
269
- presets={presets}
270
- error={presetError}
271
- disabled={state.writable === false}
272
- onLoad={() => {
273
- setPresetError(null)
274
- void fetch(PRESETS_API, { method: 'POST' })
275
- .then(async response => {
276
- const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
277
- if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
278
- setPresets(body.presets)
279
- })
280
- .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
281
- }}
282
- onPick={(preset) => {
283
- const draft = newChannelDraft(preset)
284
- props.channels.setChannels([...channels, draft])
285
- setPresetPickerOpen(false)
286
- setEditingId(draft.id)
287
- }}
288
- onCustom={() => {
289
- const draft = newChannelDraft(undefined)
290
- props.channels.setChannels([...channels, draft])
291
- setPresetPickerOpen(false)
292
- setEditingId(draft.id)
293
- }}
294
- onClose={() => { setPresetPickerOpen(false) }}
295
- />
296
- ) : null}
297
-
298
- <div className={css.channelAddRow}>
299
- <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
300
- <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
301
- </div>
302
- </div>
303
- </section>
304
-
305
- <button
306
- type="button"
307
- className={css.disclosure}
308
- aria-expanded={enhancementOpen}
309
- onClick={() => { setEnhancementOpen(open => !open) }}
310
- >
311
- <span>{t('settings.promptEnhanceTitle')}</span>
312
- <span>{t('settings.optional')}</span>
313
- <span aria-hidden="true">{enhancementOpen ? '' : '⌄'}</span>
314
- </button>
315
- {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
316
- <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
317
- <div className={css.sectionHeader}>
318
- <div>
319
- <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
320
- <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
321
- </div>
322
- <button
323
- type="button"
324
- className={css.modelFetch}
325
- disabled={disabled || loadingPromptModels}
326
- onClick={() => {
327
- setLoadingPromptModels(true)
328
- setPromptModelsError(null)
329
- void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
330
- .then(async response => {
331
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
332
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
333
- setPromptModels(body.models ?? [])
334
- })
335
- .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
336
- .finally(() => { setLoadingPromptModels(false) })
337
- }}
338
- >
339
- {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
340
- </button>
341
- </div>
342
- <div className={css.modelSummary}>
343
- {state.promptModel.text.trim() !== '' ? (
344
- <span className={css.modelChip}>
345
- <span>{state.promptModel.text}</span>
346
- <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
347
- </span>
348
- ) : null}
349
- <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
350
- {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
351
- </button>
352
- </div>
353
- {manualPromptModelOpen ? (
354
- <div className={css.manualModelRow}>
355
- <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
356
- <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
357
- </div>
358
- ) : null}
359
- {promptModels.length > 0 ? (
360
- <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
361
- <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
362
- {promptModels.map(candidate => (
363
- <button
364
- key={candidate}
365
- type="button"
366
- role="radio"
367
- className={css.modelCandidate}
368
- aria-checked={state.promptModel.text === candidate}
369
- data-selected={state.promptModel.text === candidate ? '' : undefined}
370
- disabled={disabled}
371
- onClick={() => { props.edit('promptModel', candidate) }}
372
- >
373
- {candidate}
374
- </button>
375
- ))}
376
- </div>
377
- ) : null}
378
- {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
379
- <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
380
- <span>{t('settings.promptApiAdvanced')}</span>
381
- <span aria-hidden="true">{promptApiOpen ? '' : '⌄'}</span>
382
- </button>
383
- {promptApiOpen ? <div className={css.optionalContent}>
384
- <ValueField
385
- id="dsh-imagegen-settings-prompt-apiurl"
386
- label={t('settings.promptApiUrl')}
387
- hint={t('settings.promptApiUrlHint')}
388
- placeholder="https://api.openai.com/v1"
389
- {...fieldProps}
390
- {...state.promptApiUrl}
391
- onEdit={(text) => { props.edit('promptApiUrl', text) }}
392
- onReset={() => { props.resetField('promptApiUrl') }}
393
- />
394
- <ValueField
395
- id="dsh-imagegen-settings-prompt-apikey"
396
- label={t('settings.promptApiKey')}
397
- hint={t('settings.promptApiKeyHint')}
398
- placeholder="sk-…"
399
- secret
400
- {...fieldProps}
401
- {...state.promptApiKey}
402
- overridden={false}
403
- onEdit={(text) => { props.edit('promptApiKey', text) }}
404
- onReset={() => { props.resetField('promptApiKey') }}
405
- />
406
- </div> : null}
407
- </section> : null}
408
-
409
- <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
410
- <span>{t('settings.moreOptions')}</span>
411
- <span aria-hidden="true">{moreOpen ? '' : '⌄'}</span>
412
- </button>
413
- {moreOpen ? <div className={css.optionalContent}>
414
- <BooleanField
415
- id="dsh-imagegen-settings-enabled"
416
- label={t('settings.enabled')}
417
- hint={t('settings.enabledHint')}
418
- inheritLabel={t('settings.inherit')}
419
- onLabel={t('settings.on')}
420
- offLabel={t('settings.off')}
421
- {...fieldProps}
422
- {...state.enabled}
423
- onEdit={(text) => { props.edit('enabled', text) }}
424
- onReset={() => { props.resetField('enabled') }}
425
- />
426
- <BooleanField
427
- id="dsh-imagegen-settings-announce"
428
- label={t('settings.announceToAgent')}
429
- hint={t('settings.announceToAgentHint')}
430
- inheritLabel={t('settings.inherit')}
431
- onLabel={t('settings.on')}
432
- offLabel={t('settings.off')}
433
- {...fieldProps}
434
- {...state.announceToAgent}
435
- onEdit={(text) => { props.edit('announceToAgent', text) }}
436
- onReset={() => { props.resetField('announceToAgent') }}
437
- />
438
- <BooleanField
439
- id="dsh-imagegen-settings-agent-generation"
440
- label={t('settings.allowAgentImageGeneration')}
441
- hint={t('settings.allowAgentImageGenerationHint')}
442
- inheritLabel={t('settings.inherit')}
443
- onLabel={t('settings.on')}
444
- offLabel={t('settings.off')}
445
- {...fieldProps}
446
- {...state.allowAgentImageGeneration}
447
- onEdit={(text) => { props.edit('allowAgentImageGeneration', text) }}
448
- onReset={() => { props.resetField('allowAgentImageGeneration') }}
449
- />
450
- </div> : null}
451
- <div className={css.footer}>
452
- {(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
453
- <button
454
- type="button"
455
- className={css.discard}
456
- disabled={!state.dirty || state.saving || state.channels.saving}
457
- onClick={() => { props.discard(); props.channels.discard() }}
458
- >
459
- {t('settings.discard')}
460
- </button>
461
- <button
462
- type="button"
463
- className={css.save}
464
- disabled={blocked}
465
- onClick={() => { void props.channels.commit(); void props.save() }}
466
- >
467
- {t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
468
- </button>
469
- </div>
470
- </div>
471
- )
472
- : null}
473
-
474
- {open && editing !== undefined ? (
475
- <ChannelEditor
476
- key={editing.id}
477
- t={t}
478
- channel={editing}
479
- keyHeld={state.channels.keySet[editing.id] === true}
480
- usage={usage}
481
- otherChannels={channels.filter(channel => channel.id !== editing.id)}
482
- isDefault={editing.id === state.channels.defaultChannelId}
483
- writable={state.writable}
484
- onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
485
- onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
486
- onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
487
- onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
488
- 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) }}
489
- onClose={() => { setEditingId(null) }}
490
- />
491
- ) : null}
492
-
493
- </li>
494
- )
495
- }
496
-
497
- /** Channel row + dialog helpers -------------------------------------------------- */
498
-
499
- function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
500
- return {
501
- id: clientId(),
502
- preset: preset?.id ?? '',
503
- name: preset?.name ?? '',
504
- apiUrl: preset?.apiUrl ?? '',
505
- models: (preset?.models ?? []).map(model => ({ ...model })),
506
- }
507
- }
508
-
509
- function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
510
- const draft = newChannelDraft(undefined)
511
- form.setChannels([...channels, draft])
512
- openEditor(draft.id)
513
- }
514
-
515
- /** Patch one field (or models) of one staged channel. */
516
- function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
517
- form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
518
- }
519
-
520
- function hostOf(apiUrl: string): string {
521
- try {
522
- return new URL(apiUrl).hostname
523
- } catch {
524
- return apiUrl
525
- }
526
- }
527
-
528
- function clientId(): string {
529
- const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
530
- return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
531
- }
532
-
533
- /** Built-in provider picker, expanded inside the settings card. */
534
- function PresetPicker(props: {
535
- t: (key: ImageGenKey, params?: Record<string, string | number>) => string
536
- presets: PresetProviderView[]
537
- error: string | null
538
- disabled: boolean
539
- onLoad: () => void
540
- onPick: (preset: PresetProviderView) => void
541
- onCustom: () => void
542
- onClose: () => void
543
- }) {
544
- const { t } = props
545
- const loadedRef = useRef(false)
546
- useEffect(() => {
547
- if (loadedRef.current) return
548
- loadedRef.current = true
549
- props.onLoad()
550
- }, [])
551
- return (
552
- <section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
553
- <header className={css.presetInlineHeader}>
554
- <div>
555
- <h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
556
- <p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
557
- </div>
558
- <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
559
- </header>
560
- <div className={css.presetList}>
561
- {props.presets.map(preset => (
562
- <button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
563
- <span className={css.presetName}>{preset.name}</span>
564
- <span className={css.presetMeta}>{preset.apiUrl} · {t('channels.presetModelsHint', { n: preset.models.length })}</span>
565
- <span className={css.presetHint}>{preset.hint}</span>
566
- </button>
567
- ))}
568
- <button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
569
- <span className={css.presetName}>+ {t('channels.addCustom')}</span>
570
- <span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
571
- </button>
572
- {props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
573
- </div>
574
- </section>
575
- )
576
- }
577
-
578
- /** Channel editor (modal): key, display name, API URL, model catalog. */
579
- function ChannelEditor(props: {
580
- t: (key: ImageGenKey, params?: Record<string, string | number>) => string
581
- channel: ChannelDraft
582
- keyHeld: boolean
583
- usage: UsageCounters | null
584
- otherChannels: ChannelDraft[]
585
- isDefault: boolean
586
- writable: boolean
587
- onPatch: (patch: Partial<ChannelDraft>) => void
588
- onSetModels: (models: ModelMapping[]) => void
589
- onSetKey: (value: string | undefined) => void
590
- onSetDefault: () => void
591
- onRemove: () => void
592
- onClose: () => void
593
- }) {
594
- const { t, channel } = props
595
- const [keyDraft, setKeyDraft] = useState('')
596
- const [candidates, setCandidates] = useState<string[] | null>(null)
597
- const [detecting, setDetecting] = useState(false)
598
- const [detectError, setDetectError] = useState<string | null>(null)
599
- const [manualId, setManualId] = useState('')
600
- const [removeOpen, setRemoveOpen] = useState(false)
601
- const [copyFrom, setCopyFrom] = useState('')
602
-
603
- const generatedCount = (alias: string): number => {
604
- if (props.usage === null) return 0
605
- const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
606
- return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
607
- }
608
-
609
- const detect = (): void => {
610
- setDetecting(true)
611
- setDetectError(null)
612
- const payload: Record<string, unknown> = { channelId: channel.id }
613
- if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
614
- if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
615
- void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
616
- .then(async response => {
617
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
618
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
619
- setCandidates(body.models ?? [])
620
- })
621
- .catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
622
- .finally(() => { setDetecting(false) })
623
- }
624
-
625
- // Auto-detect once when the dialog opens with a complete endpoint.
626
- const autoDetected = useRef(false)
627
- useEffect(() => {
628
- if (autoDetected.current) return
629
- autoDetected.current = true
630
- if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
631
- }, [])
632
-
633
- const addManual = (): void => {
634
- const id = manualId.trim()
635
- if (id === '') return
636
- const next = [...channel.models]
637
- const alias = id
638
- if (!next.some(model => model.alias === alias)) next.push({ alias, id })
639
- props.onSetModels(next)
640
- setManualId('')
641
- }
642
-
643
- const copyFromChannel = (): void => {
644
- const source = props.otherChannels.find(chance => chance.id === copyFrom)
645
- if (source === undefined) return
646
- const merged = [...channel.models]
647
- for (const model of source.models) {
648
- const alias = model.alias
649
- // Copy with a collision suffix so both sources stay selectable.
650
- let unique = alias
651
- let suffix = 2
652
- while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
653
- merged.push({ alias: unique, id: model.id })
654
- }
655
- props.onSetModels(merged)
656
- setCopyFrom('')
657
- }
658
-
659
- const onlyKnown = (): void => {
660
- const known = (candidates ?? []).filter(id => describeModel(id).known)
661
- const merged = [...channel.models]
662
- for (const id of known) {
663
- const alias = id
664
- if (!merged.some(model => model.alias === alias)) merged.push({ alias, id })
665
- }
666
- props.onSetModels(merged)
667
- }
668
-
669
- const knownCount = (candidates ?? []).filter(id => describeModel(id).known).length
670
-
671
- return (
672
- <div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
673
- <div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
674
- <header className={css.editorHeader}>
675
- <div>
676
- <h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
677
- <p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
678
- </div>
679
- <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
680
- </header>
681
-
682
- <div className={css.editorField}>
683
- <label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
684
- <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 }) }} />
685
- </div>
686
- <div className={css.editorField}>
687
- <label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
688
- <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 }) }} />
689
- </div>
690
- <div className={css.editorField}>
691
- <div className={css.head}>
692
- <label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
693
- {props.keyHeld || keyDraft !== ''
694
- ? (
695
- <button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
696
- {t('channels.keyClear')}
697
- </button>
698
- )
699
- : null}
700
- </div>
701
- <input
702
- id="dsh-imagegen-channel-key"
703
- className={css.input}
704
- type="password"
705
- autoComplete="off"
706
- value={keyDraft}
707
- placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
708
- disabled={!props.writable}
709
- onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
710
- />
711
- </div>
712
-
713
- <div className={css.editorDivider} />
714
-
715
- <div className={css.editorSectionHeader}>
716
- <h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
717
- <button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
718
- {detecting ? t('channels.detecting') : t('channels.detect')}
719
- </button>
720
- </div>
721
- {detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
722
- {candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
723
-
724
- {channel.models.length === 0
725
- ? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
726
- : (
727
- <ul className={css.modelRows}>
728
- {channel.models.map((model, index) => {
729
- const entry = describeModel(model.id || model.alias)
730
- const generated = generatedCount(model.alias)
731
- return (
732
- <li key={`${model.alias}-${index}`} className={css.modelRow}>
733
- <div className={css.modelRowInputs}>
734
- <input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
735
- const next = [...channel.models]
736
- next[index] = { ...model, alias: event.target.value }
737
- props.onSetModels(next)
738
- }} />
739
- <span className={css.modelArrow}>→</span>
740
- <input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
741
- const next = [...channel.models]
742
- next[index] = { ...model, id: event.target.value }
743
- props.onSetModels(next)
744
- }} />
745
- </div>
746
- <div className={css.modelRowBadges}>
747
- <span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
748
- {generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
749
- <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>
750
- </div>
751
- </li>
752
- )
753
- })}
754
- </ul>
755
- )}
756
-
757
- <div className={css.editorTools}>
758
- <div className={css.manualModelRow}>
759
- <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() } }} />
760
- <button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
761
- </div>
762
- {props.otherChannels.length > 0 ? (
763
- <div className={css.manualModelRow}>
764
- <select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
765
- <option value="">{t('channels.copyFrom')}</option>
766
- {props.otherChannels.map(other => (
767
- <option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
768
- ))}
769
- </select>
770
- <button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
771
- </div>
772
- ) : null}
773
- </div>
774
-
775
- {candidates !== null && candidates.length > 0 ? (
776
- <div className={css.modelCandidateList}>
777
- <span className={css.modelCandidateLabel}>
778
- {t('channels.candidatesTitle')}
779
- <button type="button" className={css.inlineDisclosure} disabled={!props.writable || knownCount === 0} onClick={onlyKnown}>{t('channels.candidatesQuick')}</button>
780
- </span>
781
- {candidates.map(candidate => {
782
- const selected = channel.models.some(model => model.alias === candidate)
783
- const entry = describeModel(candidate)
784
- return (
785
- <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
786
- <input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
787
- const merged = selected
788
- ? channel.models.filter(model => model.alias !== candidate)
789
- : [...channel.models, { alias: candidate, id: candidate }]
790
- props.onSetModels(merged)
791
- }} />
792
- <span>{candidate}</span>
793
- {!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
794
- </label>
795
- )
796
- })}
797
- </div>
798
- ) : null}
799
-
800
- <div className={css.editorDivider} />
801
-
802
- <div className={css.editorFooter}>
803
- {props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
804
- <button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
805
- )}
806
- <span className={css.spacer} />
807
- {removeOpen
808
- ? (
809
- <>
810
- <button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
811
- <button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
812
- </>
813
- )
814
- : (
815
- <button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
816
- )}
817
- </div>
818
- </div>
819
- </div>
820
- )
821
- }
822
-
823
- /** Props every field control needs regardless of its value type. */
824
- interface FieldProps {
825
- /** Stable id associating the label with its control. */
826
- id: string
827
- /** Visible label. */
828
- label: string
829
- /** One-line explanation rendered under the control. */
830
- hint: string
831
- /** Draft text this control renders. */
832
- text: string
833
- /** True when saving would leave a user-layer entry for this field. */
834
- overridden: boolean
835
- /** True when the draft is not a value this field accepts. */
836
- invalid: boolean
837
- /** Copy for the overridden badge. */
838
- overriddenLabel: string
839
- /** Copy for the reset control. */
840
- resetLabel: string
841
- /** Copy shown in place of the hint while the draft is invalid. */
842
- invalidLabel: string
843
- /** Disables every control (read-only document, or an unavailable namespace). */
844
- disabled: boolean
845
- /** Stage draft text. */
846
- onEdit: (text: string) => void
847
- /** Stage a clear so the field re-inherits the composition layer. */
848
- onReset: () => void
849
- }
850
-
851
- /** A staged value field; `secret` renders a password control. */
852
- function ValueField(props: FieldProps & {
853
- /** Render a password control. */
854
- secret?: boolean
855
- /** Placeholder shown while the draft is empty. */
856
- placeholder?: string
857
- /** Label of the dedicated clear control (secret fields). */
858
- clearLabel?: string
859
- /** Stage a clear of the stored secret. */
860
- onClear?: () => void
861
- /** Whether a stored secret exists (enables the clear control). */
862
- canClear?: boolean
863
- }) {
864
- return (
865
- <div className={css.field}>
866
- <div className={css.head}>
867
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
868
- {props.overridden
869
- ? (
870
- <span className={css.badges}>
871
- <span className={css.badge}>{props.overriddenLabel}</span>
872
- <button
873
- type="button"
874
- className={css.reset}
875
- disabled={props.disabled}
876
- onClick={props.onReset}
877
- >
878
- {props.resetLabel}
879
- </button>
880
- </span>
881
- )
882
- : null}
883
- {props.secret === true && props.canClear === true
884
- ? (
885
- <button
886
- type="button"
887
- className={css.reset}
888
- disabled={props.disabled}
889
- onClick={props.onClear}
890
- >
891
- {props.clearLabel ?? props.resetLabel}
892
- </button>
893
- )
894
- : null}
895
- </div>
896
- <input
897
- id={props.id}
898
- className={props.invalid ? css.inputInvalid : css.input}
899
- type={props.secret === true ? 'password' : 'text'}
900
- autoComplete={props.secret === true ? 'off' : undefined}
901
- {...props.invalid ? { 'aria-invalid': true } : {}}
902
- value={props.text}
903
- placeholder={props.placeholder ?? ''}
904
- disabled={props.disabled}
905
- onChange={(event) => { props.onEdit(event.target.value) }}
906
- />
907
- <p className={props.invalid ? css.invalid : css.hint}>
908
- {props.invalid ? props.invalidLabel : props.hint}
909
- </p>
910
- </div>
911
- )
912
- }
913
-
914
- /** A staged boolean field: 继承 / 开 / 关. */
915
- function BooleanField(props: FieldProps & {
916
- /** Copy for the inherit option. */
917
- inheritLabel: string
918
- /** Copy for the on option. */
919
- onLabel: string
920
- /** Copy for the off option. */
921
- offLabel: string
922
- }) {
923
- return (
924
- <div className={css.field}>
925
- <div className={css.head}>
926
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
927
- {props.overridden
928
- ? (
929
- <span className={css.badges}>
930
- <span className={css.badge}>{props.overriddenLabel}</span>
931
- <button
932
- type="button"
933
- className={css.reset}
934
- disabled={props.disabled}
935
- onClick={props.onReset}
936
- >
937
- {props.resetLabel}
938
- </button>
939
- </span>
940
- )
941
- : null}
942
- </div>
943
- <select
944
- id={props.id}
945
- className={css.select}
946
- value={props.text}
947
- disabled={props.disabled}
948
- onChange={(event) => { props.onEdit(event.target.value) }}
949
- >
950
- <option value="">{props.inheritLabel}</option>
951
- <option value="true">{props.onLabel}</option>
952
- <option value="false">{props.offLabel}</option>
953
- </select>
954
- <p className={css.hint}>{props.hint}</p>
955
- </div>
956
- )
957
- }
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
+ }