@dickpy/dsh-imagegen 1.2.2 → 1.3.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.
@@ -1,27 +1,33 @@
1
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.
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.
7
13
  */
8
14
 
9
- import { useState } from 'react'
15
+ import { useEffect, useRef, useState } from 'react'
10
16
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
17
  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'
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'
13
20
  import type { ImageGenScope } from './settings-scope.ts'
14
- import { IMAGE_MODEL_API, PROMPT_ENHANCE_API } from '../protocol.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'
15
24
  import css from './settings-card.module.css'
16
25
 
17
- /** The fields this card edits (the namespace's full schema). */
26
+ /** The global (non-channel) fields this card's staged form edits. */
18
27
  export interface ImageGenSettings {
19
28
  enabled?: boolean
20
29
  announceToAgent?: boolean
21
30
  allowAgentImageGeneration?: boolean
22
- apiUrl?: string
23
- apiKey?: string
24
- imageModels?: string[]
25
31
  promptApiUrl?: string
26
32
  promptApiKey?: string
27
33
  promptModel?: string
@@ -29,16 +35,13 @@ export interface ImageGenSettings {
29
35
 
30
36
  /** What the card renders. */
31
37
  export interface ImageGenSettingsCardState extends CardShell {
38
+ /** Channel list staging (channels + per-channel key edits + default). */
39
+ channels: ChannelsFormState
32
40
  /** Master switch. */
33
41
  enabled: CardFieldState
34
42
  /** System-prompt announcement flag. */
35
43
  announceToAgent: CardFieldState
36
44
  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
45
  promptApiUrl: CardFieldState
43
46
  promptApiKey: CardFieldState
44
47
  promptModel: CardFieldState
@@ -46,17 +49,18 @@ export interface ImageGenSettingsCardState extends CardShell {
46
49
 
47
50
  /** The registration-side face the card's slot entry injects. */
48
51
  export interface ImageGenSettingsCardFace extends CardActions {
52
+ /** Channel staging actions (committed together with the card's save). */
53
+ channels: ChannelsFormActions
49
54
  hooks: {
50
55
  /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
51
56
  imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
52
- /** Whether a secret (apiKey) is currently stored. */
53
- imageGenKeySet: SnapshotStore<boolean>
54
57
  }
55
58
  }
56
59
 
57
- /** Bridges the imagegen scope onto the card's staged form. */
60
+ /** Bridges the imagegen scope onto the card's staged forms. */
58
61
  export class ImageGenSettingsCardController {
59
62
  private readonly form: CardForm<ImageGenSettings>
63
+ private readonly channelsForm: ChannelsForm
60
64
 
61
65
  /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
62
66
  constructor(private readonly scope: ImageGenScope) {
@@ -64,28 +68,24 @@ export class ImageGenSettingsCardController {
64
68
  booleanField('enabled'),
65
69
  booleanField('announceToAgent'),
66
70
  booleanField('allowAgentImageGeneration'),
67
- textField('apiUrl'),
68
- secretField('apiKey'),
69
- stringListField('imageModels'),
70
71
  textField('promptApiUrl'),
71
72
  secretField('promptApiKey'),
72
73
  textField('promptModel'),
73
74
  ], {
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(),
75
+ secretSettled: () => this.scope.getSecretSetSnapshot('promptApiKey'),
77
76
  })
77
+ this.channelsForm = new ChannelsForm(scope)
78
78
  }
79
79
 
80
80
  private projection(): ImageGenSettingsCardState {
81
+ const shell = this.form.shell()
81
82
  return {
82
- ...this.form.shell(),
83
+ ...shell,
84
+ dirty: shell.dirty || this.channelsForm.snapshot().dirty,
85
+ channels: this.channelsForm.snapshot(),
83
86
  enabled: this.form.field('enabled'),
84
87
  announceToAgent: this.form.field('announceToAgent'),
85
88
  allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
86
- apiUrl: this.form.field('apiUrl'),
87
- apiKey: this.form.field('apiKey'),
88
- imageModels: this.form.field('imageModels'),
89
89
  promptApiUrl: this.form.field('promptApiUrl'),
90
90
  promptApiKey: this.form.field('promptApiKey'),
91
91
  promptModel: this.form.field('promptModel'),
@@ -94,17 +94,16 @@ export class ImageGenSettingsCardController {
94
94
 
95
95
  /**
96
96
  * Build the face the card's slot registration injects.
97
- * @returns the card's snapshot, the key-set flag, and the form actions.
97
+ * @returns the card's snapshot and the form/channel actions.
98
98
  */
99
99
  inject(): ImageGenSettingsCardFace {
100
100
  const cardStore = this.form.bind(() => this.projection())
101
- const keySetStore = createSnapshotStore(this.scope.getKeySetSnapshot())
102
- this.scope.subscribeKeySet(() => { keySetStore.set(this.scope.getKeySetSnapshot()) })
101
+ this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
103
102
  return {
104
103
  hooks: {
105
104
  imageGenSettingsCard: cardStore,
106
- imageGenKeySet: keySetStore,
107
105
  },
106
+ channels: this.channelsForm.actions(),
108
107
  ...this.form.actions(),
109
108
  }
110
109
  }
@@ -116,6 +115,12 @@ export type ImageGenSettingsCardProps =
116
115
  & PropsLocale<'dsh-imagegen'>
117
116
  & InjectFace<ImageGenSettingsCardFace>
118
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
+
119
124
  /**
120
125
  * Render the card.
121
126
  * @param props - locale copy, the card snapshot, and the form actions.
@@ -124,24 +129,37 @@ export type ImageGenSettingsCardProps =
124
129
  export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
125
130
  const { t } = props
126
131
  const state = props.useImageGenSettingsCard(snapshot => snapshot)
127
- const keySet = props.useImageGenKeySet(snapshot => snapshot)
128
132
  const [open, setOpen] = useState(false)
133
+ // Global-section local states (prompt enhancement etc.).
129
134
  const [promptModels, setPromptModels] = useState<string[]>([])
130
135
  const [loadingPromptModels, setLoadingPromptModels] = useState(false)
131
136
  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
137
  const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
139
138
  const [manualPromptModel, setManualPromptModel] = useState('')
139
+ const [enhancementOpen, setEnhancementOpen] = useState(false)
140
140
  const [promptApiOpen, setPromptApiOpen] = useState(false)
141
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
+
142
160
  if (!state.available) return null
143
161
  const title = t('settings.title')
144
- const blocked = !state.dirty || state.invalid || state.saving
162
+ const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
145
163
  const disabled = !state.writable
146
164
  const fieldProps = {
147
165
  overriddenLabel: t('settings.overridden'),
@@ -149,6 +167,7 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
149
167
  invalidLabel: t('settings.invalidNumber'),
150
168
  disabled,
151
169
  }
170
+
152
171
  if (!state.exposed) {
153
172
  return (
154
173
  <li className={css.card}>
@@ -175,6 +194,10 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
175
194
  </li>
176
195
  )
177
196
  }
197
+
198
+ const channels = state.channels.channels
199
+ const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
200
+
178
201
  return (
179
202
  <li className={css.card}>
180
203
  <button
@@ -195,131 +218,90 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
195
218
  ? (
196
219
  <div className={css.body}>
197
220
  {!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')}>
221
+
222
+ <section className={css.channelSection} aria-label={t('channels.title')}>
225
223
  <div className={css.sectionHeader}>
226
224
  <div>
227
- <h3 className={css.sectionTitle}>{t('settings.imageModelsTitle')}</h3>
228
- <p className={css.sectionHint}>{t('settings.imageModelsHint')}</p>
225
+ <h3 className={css.sectionTitle}>{t('channels.title')}</h3>
226
+ <p className={css.sectionHint}>{t('channels.hint')}</p>
229
227
  </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' })
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' })
238
275
  .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 ?? [])
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)
242
279
  })
243
- .catch(error => { setImageModelsError(error instanceof Error ? error.message : String(error)) })
244
- .finally(() => { setLoadingImageModels(false) })
280
+ .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
245
281
  }}
246
- >
247
- {loadingImageModels ? t('settings.imageModelsLoading') : t('settings.imageModelsFetch')}
248
- </button>
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>
249
301
  </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
302
  </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
303
  </section>
304
+
323
305
  <button
324
306
  type="button"
325
307
  className={css.disclosure}
@@ -364,16 +346,14 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
364
346
  <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
365
347
  </span>
366
348
  ) : null}
367
- <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open) }}>
349
+ <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
368
350
  {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
369
351
  </button>
370
352
  </div>
371
353
  {manualPromptModelOpen ? (
372
354
  <div className={css.manualModelRow}>
373
355
  <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>
356
+ <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
377
357
  </div>
378
358
  ) : null}
379
359
  {promptModels.length > 0 ? (
@@ -425,6 +405,7 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
425
405
  />
426
406
  </div> : null}
427
407
  </section> : null}
408
+
428
409
  <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
429
410
  <span>{t('settings.moreOptions')}</span>
430
411
  <span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
@@ -468,12 +449,12 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
468
449
  />
469
450
  </div> : null}
470
451
  <div className={css.footer}>
471
- {state.failed ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
452
+ {(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
472
453
  <button
473
454
  type="button"
474
455
  className={css.discard}
475
- disabled={!state.dirty || state.saving}
476
- onClick={props.discard}
456
+ disabled={!state.dirty || state.saving || state.channels.saving}
457
+ onClick={() => { props.discard(); props.channels.discard() }}
477
458
  >
478
459
  {t('settings.discard')}
479
460
  </button>
@@ -481,18 +462,364 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
481
462
  type="button"
482
463
  className={css.save}
483
464
  disabled={blocked}
484
- onClick={props.save}
465
+ onClick={() => { void props.channels.commit(); void props.save() }}
485
466
  >
486
- {t(!state.saving ? 'settings.save' : 'settings.saving')}
467
+ {t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
487
468
  </button>
488
469
  </div>
489
470
  </div>
490
471
  )
491
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
+
492
493
  </li>
493
494
  )
494
495
  }
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
+
496
823
  /** Props every field control needs regardless of its value type. */
497
824
  interface FieldProps {
498
825
  /** Stable id associating the label with its control. */
@@ -533,8 +860,6 @@ function ValueField(props: FieldProps & {
533
860
  onClear?: () => void
534
861
  /** Whether a stored secret exists (enables the clear control). */
535
862
  canClear?: boolean
536
- /** Render a multiline input for newline-separated configuration values. */
537
- multiline?: boolean
538
863
  }) {
539
864
  return (
540
865
  <div className={css.field}>
@@ -568,29 +893,17 @@ function ValueField(props: FieldProps & {
568
893
  )
569
894
  : null}
570
895
  </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
- )}
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
+ />
594
907
  <p className={props.invalid ? css.invalid : css.hint}>
595
908
  {props.invalid ? props.invalidLabel : props.hint}
596
909
  </p>
@@ -598,10 +911,6 @@ function ValueField(props: FieldProps & {
598
911
  )
599
912
  }
600
913
 
601
- function splitModels(value: string): string[] {
602
- return [...new Set(value.split(/[\n,]/).map(model => model.trim()).filter(Boolean))]
603
- }
604
-
605
914
  /** A staged boolean field: 继承 / 开 / 关. */
606
915
  function BooleanField(props: FieldProps & {
607
916
  /** Copy for the inherit option. */