@dickpy/dsh-imagegen 1.5.6 → 1.5.8

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,1128 +1,1128 @@
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-store'
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 { tt, type TranslateValues } from './helpers.ts'
25
- import { useImageGenLanguageTick } from './use-language.ts'
26
- import css from './settings-card.module.css'
27
-
28
- /** The global (non-channel) fields this card's staged form edits. */
29
- export interface ImageGenSettings {
30
- enabled?: boolean
31
- announceToAgent?: boolean
32
- allowAgentImageGeneration?: boolean
33
- promptApiUrl?: string
34
- promptApiKey?: string
35
- promptModel?: string
36
- localStoragePath?: string
37
- storageEnabled?: boolean
38
- storageEndpoint?: string
39
- storageRegion?: string
40
- storagePrefix?: string
41
- storageAccessKey?: string
42
- storageSecretKey?: string
43
- storageSyncGallery?: boolean
44
- storageSyncHistory?: boolean
45
- }
46
-
47
- /** What the card renders. */
48
- export interface ImageGenSettingsCardState extends CardShell {
49
- /** Channel list staging (channels + per-channel key edits + default). */
50
- channels: ChannelsFormState
51
- /** Master switch. */
52
- enabled: CardFieldState
53
- /** System-prompt announcement flag. */
54
- announceToAgent: CardFieldState
55
- allowAgentImageGeneration: CardFieldState
56
- promptApiUrl: CardFieldState
57
- promptApiKey: CardFieldState
58
- promptModel: CardFieldState
59
- localStoragePath: CardFieldState
60
- storageEnabled: CardFieldState
61
- storageEndpoint: CardFieldState
62
- storageRegion: CardFieldState
63
- storagePrefix: CardFieldState
64
- storageAccessKey: CardFieldState
65
- storageSecretKey: CardFieldState
66
- storageSyncGallery: CardFieldState
67
- storageSyncHistory: CardFieldState
68
- }
69
-
70
- /** Result of probing the configured object storage from the card. */
71
- export interface StorageTestOutcome {
72
- ok: boolean
73
- ms?: number
74
- message?: string
75
- }
76
-
77
- /** The registration-side face the card's slot entry injects. */
78
- export interface ImageGenSettingsCardFace extends CardActions {
79
- /** Channel staging actions (committed together with the card's save). */
80
- channels: ChannelsFormActions
81
- /** Save staged edits, then upload a probe object to the configured store. */
82
- storageTest: () => Promise<StorageTestOutcome>
83
- hooks: {
84
- /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
85
- imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
86
- }
87
- }
88
-
89
- /** Bridges the imagegen scope onto the card's staged forms. */
90
- export class ImageGenSettingsCardController {
91
- private readonly form: CardForm<ImageGenSettings>
92
- private readonly channelsForm: ChannelsForm
93
-
94
- /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
95
- constructor(private readonly scope: ImageGenScope) {
96
- this.form = new CardForm(scope, [
97
- booleanField('enabled'),
98
- booleanField('announceToAgent'),
99
- booleanField('allowAgentImageGeneration'),
100
- textField('promptApiUrl'),
101
- secretField('promptApiKey'),
102
- textField('promptModel'),
103
- textField('localStoragePath'),
104
- booleanField('storageEnabled'),
105
- textField('storageEndpoint'),
106
- textField('storageRegion'),
107
- textField('storagePrefix'),
108
- textField('storageAccessKey'),
109
- secretField('storageSecretKey'),
110
- booleanField('storageSyncGallery'),
111
- booleanField('storageSyncHistory'),
112
- ], {
113
- secretSettled: (field) => this.scope.getSecretSetSnapshot(field),
114
- })
115
- this.channelsForm = new ChannelsForm(scope)
116
- }
117
-
118
- private projection(): ImageGenSettingsCardState {
119
- const shell = this.form.shell()
120
- return {
121
- ...shell,
122
- dirty: shell.dirty || this.channelsForm.snapshot().dirty,
123
- channels: this.channelsForm.snapshot(),
124
- enabled: this.form.field('enabled'),
125
- announceToAgent: this.form.field('announceToAgent'),
126
- allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
127
- promptApiUrl: this.form.field('promptApiUrl'),
128
- promptApiKey: this.form.field('promptApiKey'),
129
- promptModel: this.form.field('promptModel'),
130
- localStoragePath: this.form.field('localStoragePath'),
131
- storageEnabled: this.form.field('storageEnabled'),
132
- storageEndpoint: this.form.field('storageEndpoint'),
133
- storageRegion: this.form.field('storageRegion'),
134
- storagePrefix: this.form.field('storagePrefix'),
135
- storageAccessKey: this.form.field('storageAccessKey'),
136
- storageSecretKey: this.form.field('storageSecretKey'),
137
- storageSyncGallery: this.form.field('storageSyncGallery'),
138
- storageSyncHistory: this.form.field('storageSyncHistory'),
139
- }
140
- }
141
-
142
- /**
143
- * Build the face the card's slot registration injects.
144
- * @returns the card's snapshot and the form/channel actions.
145
- */
146
- inject(): ImageGenSettingsCardFace {
147
- const cardStore = this.form.bind(() => this.projection())
148
- this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
149
- return {
150
- hooks: {
151
- imageGenSettingsCard: cardStore,
152
- },
153
- channels: this.channelsForm.actions(),
154
- // The probe needs the values the user is looking at, so staged edits are
155
- // committed first; the host route then resolves the saved config itself.
156
- storageTest: async (): Promise<StorageTestOutcome> => {
157
- await this.form.save()
158
- try {
159
- const response = await fetch('/api/dsh-imagegen/storage/test', { method: 'POST' })
160
- const body = await response.json() as { ok?: unknown; ms?: unknown; message?: unknown }
161
- if (body.ok === true) return { ok: true, ms: typeof body.ms === 'number' ? body.ms : undefined }
162
- return { ok: false, message: typeof body.message === 'string' ? body.message : `HTTP ${response.status}` }
163
- } catch (error) {
164
- return { ok: false, message: error instanceof Error ? error.message : String(error) }
165
- }
166
- },
167
- ...this.form.actions(),
168
- }
169
- }
170
- }
171
-
172
- /** Props the renderer binds for this card. */
173
- export type ImageGenSettingsCardProps =
174
- PropsRuntime<'settings.plugin.item'>
175
- & PropsLocale<'dsh-imagegen'>
176
- & InjectFace<ImageGenSettingsCardFace>
177
-
178
- /** Host-computed usage counters (generation-count badges). */
179
- interface UsageCounters {
180
- byChannel: Record<string, Record<string, number>>
181
- totals: Record<string, number>
182
- }
183
-
184
- /**
185
- * Render the card.
186
- * @param props - locale copy, the card snapshot, and the form actions.
187
- * @returns the card, or nothing while the namespace is still loading.
188
- */
189
- export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
190
- // The card renders through the plugin's own dictionary so the uiLanguage
191
- // override applies here too — the host-locale props.t would only follow the
192
- // DSH interface language.
193
- const t = tt
194
- useImageGenLanguageTick()
195
- const state = props.useImageGenSettingsCard(snapshot => snapshot)
196
- const [open, setOpen] = useState(false)
197
- // Global-section local states (prompt enhancement etc.).
198
- const [promptModels, setPromptModels] = useState<string[]>([])
199
- const [loadingPromptModels, setLoadingPromptModels] = useState(false)
200
- const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
201
- const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
202
- const [manualPromptModel, setManualPromptModel] = useState('')
203
- const [enhancementOpen, setEnhancementOpen] = useState(false)
204
- const [promptApiOpen, setPromptApiOpen] = useState(false)
205
- const [storageOpen, setStorageOpen] = useState(false)
206
- const [storageTesting, setStorageTesting] = useState(false)
207
- const [storageTestResult, setStorageTestResult] = useState<string | null>(null)
208
- const [moreOpen, setMoreOpen] = useState(false)
209
- // Channel list local states.
210
- const [editingId, setEditingId] = useState<string | null>(null)
211
- const [presetPickerOpen, setPresetPickerOpen] = useState(false)
212
- const [presets, setPresets] = useState<PresetProviderView[]>([])
213
- const [presetError, setPresetError] = useState<string | null>(null)
214
- const [usage, setUsage] = useState<UsageCounters | null>(null)
215
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
216
-
217
- // Usage counters: refreshed once per card open (and after a successful save).
218
- useEffect(() => {
219
- if (!state.exposed) return
220
- let alive = true
221
- void fetch(USAGE_API, { method: 'POST' })
222
- .then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) })
223
- .catch(() => { /* counters are best-effort */ })
224
- return () => { alive = false }
225
- }, [state.exposed])
226
-
227
- if (!state.available) return null
228
- const title = t('settings.title')
229
- const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
230
- const disabled = !state.writable
231
- const fieldProps = {
232
- overriddenLabel: t('settings.overridden'),
233
- resetLabel: t('settings.reset'),
234
- invalidLabel: t('settings.invalidNumber'),
235
- disabled,
236
- }
237
-
238
- if (!state.exposed) {
239
- return (
240
- <li className={css.card}>
241
- <button
242
- type="button"
243
- className={css.header}
244
- aria-expanded={open}
245
- aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
246
- onClick={() => { setOpen(!open) }}
247
- >
248
- <span className={css.headText}>
249
- <span className={css.name}>{title}</span>
250
- <span className={css.description}>{t('settings.description')}</span>
251
- </span>
252
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
253
- </button>
254
- {open
255
- ? (
256
- <div className={css.body}>
257
- <p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
258
- </div>
259
- )
260
- : null}
261
- </li>
262
- )
263
- }
264
-
265
- const channels = state.channels.channels
266
- const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
267
-
268
- return (
269
- <li className={css.card}>
270
- <button
271
- type="button"
272
- className={css.header}
273
- aria-expanded={open}
274
- aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
275
- onClick={() => { setOpen(!open) }}
276
- >
277
- <span className={css.headText}>
278
- <span className={css.name}>{title}</span>
279
- <span className={css.description}>{t('settings.description')}</span>
280
- </span>
281
- {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
282
- <span className={open ? css.chevronOpen : css.chevron}>▾</span>
283
- </button>
284
- {open
285
- ? (
286
- <div className={css.body}>
287
- {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
288
-
289
- <section className={css.channelSection} aria-label={t('channels.title')}>
290
- <div className={css.sectionHeader}>
291
- <div>
292
- <h3 className={css.sectionTitle}>{t('channels.title')}</h3>
293
- <p className={css.sectionHint}>{t('channels.hint')}</p>
294
- </div>
295
- </div>
296
- {channels.length === 0
297
- ? <p className={css.channelEmpty}>{t('channels.empty')}</p>
298
- : (
299
- <ul className={css.channelList}>
300
- {channels.map(channel => {
301
- const keyHeld = state.channels.keySet[channel.id] === true
302
- const ready = keyHeld && channel.models.length > 0
303
- const isDefault = channel.id === state.channels.defaultChannelId
304
- if (confirmDeleteId === channel.id) {
305
- return (
306
- <li key={channel.id} className={css.channelRow} data-action>
307
- <span className={css.deleteConfirmText}>{t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}</span>
308
- <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>
309
- <button type="button" className={css.channelAction} disabled={disabled} onClick={() => { setConfirmDeleteId(null) }}>{t('channels.cancel')}</button>
310
- </li>
311
- )
312
- }
313
- return (
314
- <li key={channel.id} className={css.channelRow}>
315
- <span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
316
- <button type="button" className={css.channelMain} disabled={disabled} onClick={() => { setEditingId(channel.id) }}>
317
- <span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
318
- <span className={css.channelMeta}>
319
- <span className={css.channelBadge} data-warn={!keyHeld || channel.models.length === 0 ? '' : undefined}>
320
- {keyHeld ? t('channels.keySet') : t('channels.keyMissing')}
321
- {' · '}
322
- {channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}
323
- </span>
324
- </span>
325
- </button>
326
- <button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
327
- <button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
328
- </li>
329
- )
330
- })}
331
- </ul>
332
- )}
333
- <div className={css.channelControls}>
334
- {open && presetPickerOpen ? (
335
- <PresetPicker
336
- t={t}
337
- presets={presets}
338
- error={presetError}
339
- disabled={state.writable === false}
340
- onLoad={() => {
341
- setPresetError(null)
342
- void fetch(PRESETS_API, { method: 'POST' })
343
- .then(async response => {
344
- const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
345
- if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
346
- setPresets(body.presets)
347
- })
348
- .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
349
- }}
350
- onPick={(preset) => {
351
- const draft = newChannelDraft(preset)
352
- props.channels.setChannels([...channels, draft])
353
- setPresetPickerOpen(false)
354
- setEditingId(draft.id)
355
- }}
356
- onCustom={() => {
357
- const draft = newChannelDraft(undefined)
358
- props.channels.setChannels([...channels, draft])
359
- setPresetPickerOpen(false)
360
- setEditingId(draft.id)
361
- }}
362
- onClose={() => { setPresetPickerOpen(false) }}
363
- />
364
- ) : null}
365
-
366
- <div className={css.channelAddRow}>
367
- <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
368
- <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
369
- </div>
370
- </div>
371
- </section>
372
-
373
- <button
374
- type="button"
375
- className={css.disclosure}
376
- aria-expanded={enhancementOpen}
377
- onClick={() => { setEnhancementOpen(open => !open) }}
378
- >
379
- <span>{t('settings.promptEnhanceTitle')}</span>
380
- <span>{t('settings.optional')}</span>
381
- <span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
382
- </button>
383
- {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
384
- <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
385
- <div className={css.sectionHeader}>
386
- <div>
387
- <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
388
- <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
389
- </div>
390
- <button
391
- type="button"
392
- className={css.modelFetch}
393
- disabled={disabled || loadingPromptModels}
394
- onClick={() => {
395
- setLoadingPromptModels(true)
396
- setPromptModelsError(null)
397
- void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
398
- .then(async response => {
399
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
400
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
401
- setPromptModels(body.models ?? [])
402
- })
403
- .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
404
- .finally(() => { setLoadingPromptModels(false) })
405
- }}
406
- >
407
- {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
408
- </button>
409
- </div>
410
- <div className={css.modelSummary}>
411
- {state.promptModel.text.trim() !== '' ? (
412
- <span className={css.modelChip}>
413
- <span>{state.promptModel.text}</span>
414
- <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
415
- </span>
416
- ) : null}
417
- <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
418
- {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
419
- </button>
420
- </div>
421
- {manualPromptModelOpen ? (
422
- <div className={css.manualModelRow}>
423
- <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
424
- <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
425
- </div>
426
- ) : null}
427
- {promptModels.length > 0 ? (
428
- <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
429
- <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
430
- {promptModels.map(candidate => (
431
- <button
432
- key={candidate}
433
- type="button"
434
- role="radio"
435
- className={css.modelCandidate}
436
- aria-checked={state.promptModel.text === candidate}
437
- data-selected={state.promptModel.text === candidate ? '' : undefined}
438
- disabled={disabled}
439
- onClick={() => { props.edit('promptModel', candidate) }}
440
- >
441
- {candidate}
442
- </button>
443
- ))}
444
- </div>
445
- ) : null}
446
- {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
447
- <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
448
- <span>{t('settings.promptApiAdvanced')}</span>
449
- <span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
450
- </button>
451
- {promptApiOpen ? <div className={css.optionalContent}>
452
- <ValueField
453
- id="dsh-imagegen-settings-prompt-apiurl"
454
- label={t('settings.promptApiUrl')}
455
- hint={t('settings.promptApiUrlHint')}
456
- placeholder="https://api.openai.com/v1"
457
- {...fieldProps}
458
- {...state.promptApiUrl}
459
- onEdit={(text) => { props.edit('promptApiUrl', text) }}
460
- onReset={() => { props.resetField('promptApiUrl') }}
461
- />
462
- <ValueField
463
- id="dsh-imagegen-settings-prompt-apikey"
464
- label={t('settings.promptApiKey')}
465
- hint={t('settings.promptApiKeyHint')}
466
- placeholder="sk-…"
467
- secret
468
- {...fieldProps}
469
- {...state.promptApiKey}
470
- overridden={false}
471
- onEdit={(text) => { props.edit('promptApiKey', text) }}
472
- onReset={() => { props.resetField('promptApiKey') }}
473
- />
474
- </div> : null}
475
- </section> : null}
476
-
477
- <button type="button" className={css.disclosure} aria-expanded={storageOpen} onClick={() => { setStorageOpen(open => !open) }}>
478
- <span>{t('settings.storageTitle')}</span>
479
- <span aria-hidden="true">{storageOpen ? '⌃' : '⌄'}</span>
480
- </button>
481
- {storageOpen ? <div className={css.optionalContent}>
482
- <ValueField
483
- id="dsh-imagegen-settings-local-storage-path"
484
- label={t('settings.localStoragePath')}
485
- hint={t('settings.localStoragePathHint')}
486
- placeholder="E:\\dsh-imagegen-data"
487
- {...fieldProps}
488
- {...state.localStoragePath}
489
- onEdit={(text) => { props.edit('localStoragePath', text) }}
490
- onReset={() => { props.resetField('localStoragePath') }}
491
- />
492
- <BooleanField
493
- id="dsh-imagegen-settings-storage-enabled"
494
- label={t('settings.storageEnabled')}
495
- hint={t('settings.storageHint')}
496
- inheritLabel={t('settings.inherit')}
497
- onLabel={t('settings.on')}
498
- offLabel={t('settings.off')}
499
- {...fieldProps}
500
- {...state.storageEnabled}
501
- onEdit={(text) => { props.edit('storageEnabled', text) }}
502
- onReset={() => { props.resetField('storageEnabled') }}
503
- />
504
- <ValueField
505
- id="dsh-imagegen-settings-storage-endpoint"
506
- label={t('settings.storageEndpoint')}
507
- hint={t('settings.storageEndpointHint')}
508
- placeholder="https://bucket-appid.cos.ap-guangzhou.myqcloud.com"
509
- {...fieldProps}
510
- {...state.storageEndpoint}
511
- onEdit={(text) => { props.edit('storageEndpoint', text) }}
512
- onReset={() => { props.resetField('storageEndpoint') }}
513
- />
514
- <ValueField
515
- id="dsh-imagegen-settings-storage-region"
516
- label={t('settings.storageRegion')}
517
- hint={t('settings.storageRegionHint')}
518
- placeholder="ap-guangzhou"
519
- {...fieldProps}
520
- {...state.storageRegion}
521
- onEdit={(text) => { props.edit('storageRegion', text) }}
522
- onReset={() => { props.resetField('storageRegion') }}
523
- />
524
- <ValueField
525
- id="dsh-imagegen-settings-storage-prefix"
526
- label={t('settings.storagePrefix')}
527
- hint={t('settings.storagePrefixHint')}
528
- placeholder="dsh-imagegen"
529
- {...fieldProps}
530
- {...state.storagePrefix}
531
- onEdit={(text) => { props.edit('storagePrefix', text) }}
532
- onReset={() => { props.resetField('storagePrefix') }}
533
- />
534
- <ValueField
535
- id="dsh-imagegen-settings-storage-accesskey"
536
- label={t('settings.storageAccessKey')}
537
- hint={t('settings.storageAccessKeyHint')}
538
- placeholder="AKID…"
539
- {...fieldProps}
540
- {...state.storageAccessKey}
541
- onEdit={(text) => { props.edit('storageAccessKey', text) }}
542
- onReset={() => { props.resetField('storageAccessKey') }}
543
- />
544
- <ValueField
545
- id="dsh-imagegen-settings-storage-secretkey"
546
- label={t('settings.storageSecretKey')}
547
- hint={t('settings.storageSecretKeyHint')}
548
- placeholder="…"
549
- secret
550
- {...fieldProps}
551
- {...state.storageSecretKey}
552
- overridden={false}
553
- onEdit={(text) => { props.edit('storageSecretKey', text) }}
554
- onReset={() => { props.resetField('storageSecretKey') }}
555
- />
556
- <BooleanField
557
- id="dsh-imagegen-settings-storage-gallery"
558
- label={t('settings.storageSyncGallery')}
559
- hint={t('settings.storageSyncGalleryHint')}
560
- inheritLabel={t('settings.inherit')}
561
- onLabel={t('settings.on')}
562
- offLabel={t('settings.off')}
563
- {...fieldProps}
564
- {...state.storageSyncGallery}
565
- onEdit={(text) => { props.edit('storageSyncGallery', text) }}
566
- onReset={() => { props.resetField('storageSyncGallery') }}
567
- />
568
- <BooleanField
569
- id="dsh-imagegen-settings-storage-history"
570
- label={t('settings.storageSyncHistory')}
571
- hint={t('settings.storageSyncHistoryHint')}
572
- inheritLabel={t('settings.inherit')}
573
- onLabel={t('settings.on')}
574
- offLabel={t('settings.off')}
575
- {...fieldProps}
576
- {...state.storageSyncHistory}
577
- onEdit={(text) => { props.edit('storageSyncHistory', text) }}
578
- onReset={() => { props.resetField('storageSyncHistory') }}
579
- />
580
- <div className={css.modelSummary}>
581
- <button
582
- type="button"
583
- className={css.addModel}
584
- disabled={disabled || storageTesting}
585
- onClick={() => {
586
- setStorageTesting(true)
587
- setStorageTestResult(null)
588
- void props.storageTest().then(outcome => {
589
- setStorageTestResult(outcome.ok
590
- ? t('settings.storageTestOk', { ms: outcome.ms ?? 0 })
591
- : t('settings.storageTestFailed', { error: outcome.message ?? 'error' }))
592
- }).finally(() => { setStorageTesting(false) })
593
- }}
594
- >
595
- {storageTesting ? t('settings.storageTesting') : t('settings.storageTest')}
596
- </button>
597
- {storageTestResult !== null ? <p className={css.failed} role="status">{storageTestResult}</p> : null}
598
- </div>
599
- <p className={css.hint}>{t('settings.storageKeyHint')}</p>
600
- </div> : null}
601
-
602
- <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
603
- <span>{t('settings.moreOptions')}</span>
604
- <span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
605
- </button>
606
- {moreOpen ? <div className={css.optionalContent}>
607
- <BooleanField
608
- id="dsh-imagegen-settings-enabled"
609
- label={t('settings.enabled')}
610
- hint={t('settings.enabledHint')}
611
- inheritLabel={t('settings.inherit')}
612
- onLabel={t('settings.on')}
613
- offLabel={t('settings.off')}
614
- {...fieldProps}
615
- {...state.enabled}
616
- onEdit={(text) => { props.edit('enabled', text) }}
617
- onReset={() => { props.resetField('enabled') }}
618
- />
619
- <BooleanField
620
- id="dsh-imagegen-settings-announce"
621
- label={t('settings.announceToAgent')}
622
- hint={t('settings.announceToAgentHint')}
623
- inheritLabel={t('settings.inherit')}
624
- onLabel={t('settings.on')}
625
- offLabel={t('settings.off')}
626
- {...fieldProps}
627
- {...state.announceToAgent}
628
- onEdit={(text) => { props.edit('announceToAgent', text) }}
629
- onReset={() => { props.resetField('announceToAgent') }}
630
- />
631
- <BooleanField
632
- id="dsh-imagegen-settings-agent-generation"
633
- label={t('settings.allowAgentImageGeneration')}
634
- hint={t('settings.allowAgentImageGenerationHint')}
635
- inheritLabel={t('settings.inherit')}
636
- onLabel={t('settings.on')}
637
- offLabel={t('settings.off')}
638
- {...fieldProps}
639
- {...state.allowAgentImageGeneration}
640
- onEdit={(text) => { props.edit('enabled', text) }}
641
- onReset={() => { props.resetField('enabled') }}
642
- />
643
- </div> : null}
644
- <div className={css.footer}>
645
- {(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
646
- <button
647
- type="button"
648
- className={css.discard}
649
- disabled={!state.dirty || state.saving || state.channels.saving}
650
- onClick={() => { props.discard(); props.channels.discard() }}
651
- >
652
- {t('settings.discard')}
653
- </button>
654
- <button
655
- type="button"
656
- className={css.save}
657
- disabled={blocked}
658
- onClick={() => { void props.channels.commit(); void props.save() }}
659
- >
660
- {t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
661
- </button>
662
- </div>
663
- </div>
664
- )
665
- : null}
666
-
667
- {open && editing !== undefined ? (
668
- <ChannelEditor
669
- key={editing.id}
670
- t={t}
671
- channel={editing}
672
- keyHeld={state.channels.keySet[editing.id] === true}
673
- usage={usage}
674
- otherChannels={channels.filter(channel => channel.id !== editing.id)}
675
- isDefault={editing.id === state.channels.defaultChannelId}
676
- writable={state.writable}
677
- onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
678
- onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
679
- onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
680
- onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
681
- 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) }}
682
- onClose={() => { setEditingId(null) }}
683
- />
684
- ) : null}
685
-
686
- </li>
687
- )
688
- }
689
-
690
- /** Channel row + dialog helpers -------------------------------------------------- */
691
-
692
- function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
693
- return {
694
- id: clientId(),
695
- preset: preset?.id ?? '',
696
- name: preset?.name ?? '',
697
- apiUrl: preset?.apiUrl ?? '',
698
- models: (preset?.models ?? []).map(model => ({ ...model })),
699
- }
700
- }
701
-
702
- function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
703
- const draft = newChannelDraft(undefined)
704
- form.setChannels([...channels, draft])
705
- openEditor(draft.id)
706
- }
707
-
708
- /** Patch one field (or models) of one staged channel. */
709
- function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
710
- form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
711
- }
712
-
713
- function clientId(): string {
714
- const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
715
- return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
716
- }
717
-
718
- /** Built-in provider picker, expanded inside the settings card. */
719
- function PresetPicker(props: {
720
- t: (key: ImageGenKey, params?: Record<string, string | number>) => string
721
- presets: PresetProviderView[]
722
- error: string | null
723
- disabled: boolean
724
- onLoad: () => void
725
- onPick: (preset: PresetProviderView) => void
726
- onCustom: () => void
727
- onClose: () => void
728
- }) {
729
- const { t } = props
730
- const loadedRef = useRef(false)
731
- useEffect(() => {
732
- if (loadedRef.current) return
733
- loadedRef.current = true
734
- props.onLoad()
735
- }, [])
736
- return (
737
- <section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
738
- <header className={css.presetInlineHeader}>
739
- <div>
740
- <h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
741
- <p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
742
- </div>
743
- <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
744
- </header>
745
- <div className={css.presetList}>
746
- {props.presets.map(preset => (
747
- <button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
748
- <span className={css.presetName}>{preset.name}</span>
749
- <span className={css.presetMeta}>{preset.models.map(model => model.alias).join(' · ')}</span>
750
- </button>
751
- ))}
752
- <button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
753
- <span className={css.presetName}>+ {t('channels.addCustom')}</span>
754
- <span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
755
- </button>
756
- {props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
757
- </div>
758
- </section>
759
- )
760
- }
761
-
762
- /** Channel editor (modal): key, display name, API URL, model catalog. */
763
- function ChannelEditor(props: {
764
- t: (key: ImageGenKey, params?: Record<string, string | number>) => string
765
- channel: ChannelDraft
766
- keyHeld: boolean
767
- usage: UsageCounters | null
768
- otherChannels: ChannelDraft[]
769
- isDefault: boolean
770
- writable: boolean
771
- onPatch: (patch: Partial<ChannelDraft>) => void
772
- onSetModels: (models: ModelMapping[]) => void
773
- onSetKey: (value: string | undefined) => void
774
- onSetDefault: () => void
775
- onRemove: () => void
776
- onClose: () => void
777
- }) {
778
- const { t, channel } = props
779
- const [keyDraft, setKeyDraft] = useState('')
780
- const [candidates, setCandidates] = useState<string[] | null>(null)
781
- const [detecting, setDetecting] = useState(false)
782
- const [detectError, setDetectError] = useState<string | null>(null)
783
- const [manualId, setManualId] = useState('')
784
- const [removeOpen, setRemoveOpen] = useState(false)
785
- const [copyFrom, setCopyFrom] = useState('')
786
-
787
- const generatedCount = (alias: string): number => {
788
- if (props.usage === null) return 0
789
- const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
790
- return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
791
- }
792
-
793
- const detect = (): void => {
794
- setDetecting(true)
795
- setDetectError(null)
796
- const payload: Record<string, unknown> = { channelId: channel.id }
797
- if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
798
- if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
799
- void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
800
- .then(async response => {
801
- const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
802
- if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
803
- setCandidates(body.models ?? [])
804
- })
805
- .catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
806
- .finally(() => { setDetecting(false) })
807
- }
808
-
809
- // Auto-detect once when the dialog opens with a complete endpoint.
810
- const autoDetected = useRef(false)
811
- useEffect(() => {
812
- if (autoDetected.current) return
813
- autoDetected.current = true
814
- if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
815
- }, [])
816
-
817
- const addManual = (): void => {
818
- const id = manualId.trim()
819
- if (id === '') return
820
- const next = [...channel.models]
821
- const alias = id
822
- if (!next.some(model => model.alias === alias)) next.push({ alias, id })
823
- props.onSetModels(next)
824
- setManualId('')
825
- }
826
-
827
- const copyFromChannel = (): void => {
828
- const source = props.otherChannels.find(chance => chance.id === copyFrom)
829
- if (source === undefined) return
830
- const merged = [...channel.models]
831
- for (const model of source.models) {
832
- const alias = model.alias
833
- // Copy with a collision suffix so both sources stay selectable.
834
- let unique = alias
835
- let suffix = 2
836
- while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
837
- merged.push({ alias: unique, id: model.id })
838
- }
839
- props.onSetModels(merged)
840
- setCopyFrom('')
841
- }
842
-
843
- return (
844
- <div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
845
- <div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
846
- <header className={css.editorHeader}>
847
- <div>
848
- <h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
849
- <p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
850
- </div>
851
- <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
852
- </header>
853
-
854
- <div className={css.editorField}>
855
- <label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
856
- <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 }) }} />
857
- </div>
858
- <div className={css.editorField}>
859
- <label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
860
- <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 }) }} />
861
- </div>
862
- <div className={css.editorField}>
863
- <div className={css.head}>
864
- <label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
865
- {props.keyHeld || keyDraft !== ''
866
- ? (
867
- <button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
868
- {t('channels.keyClear')}
869
- </button>
870
- )
871
- : null}
872
- </div>
873
- <input
874
- id="dsh-imagegen-channel-key"
875
- className={css.input}
876
- type="password"
877
- autoComplete="off"
878
- value={keyDraft}
879
- placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
880
- disabled={!props.writable}
881
- onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
882
- />
883
- </div>
884
-
885
- <div className={css.editorDivider} />
886
-
887
- <div className={css.editorSectionHeader}>
888
- <h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
889
- <button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
890
- {detecting ? t('channels.detecting') : t('channels.detect')}
891
- </button>
892
- </div>
893
- {detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
894
- {candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
895
-
896
- {channel.models.length === 0
897
- ? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
898
- : (
899
- <ul className={css.modelRows}>
900
- {channel.models.map((model, index) => {
901
- const entry = describeModel(model.id || model.alias)
902
- const generated = generatedCount(model.alias)
903
- return (
904
- <li key={`${model.alias}-${index}`} className={css.modelRow}>
905
- <div className={css.modelRowInputs}>
906
- <input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
907
- const next = [...channel.models]
908
- next[index] = { ...model, alias: event.target.value }
909
- props.onSetModels(next)
910
- }} />
911
- <span className={css.modelArrow}>→</span>
912
- <input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
913
- const next = [...channel.models]
914
- next[index] = { ...model, id: event.target.value }
915
- props.onSetModels(next)
916
- }} />
917
- </div>
918
- <div className={css.modelRowBadges}>
919
- <span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
920
- {generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
921
- <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>
922
- </div>
923
- </li>
924
- )
925
- })}
926
- </ul>
927
- )}
928
-
929
- <div className={css.editorTools}>
930
- <div className={css.manualModelRow}>
931
- <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() } }} />
932
- <button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
933
- </div>
934
- {props.otherChannels.length > 0 ? (
935
- <div className={css.manualModelRow}>
936
- <select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
937
- <option value="">{t('channels.copyFrom')}</option>
938
- {props.otherChannels.map(other => (
939
- <option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
940
- ))}
941
- </select>
942
- <button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
943
- </div>
944
- ) : null}
945
- </div>
946
-
947
- {candidates !== null && candidates.length > 0 ? (
948
- <div className={css.modelCandidateList}>
949
- <span className={css.modelCandidateLabel}>
950
- {t('channels.candidatesTitle')}
951
- </span>
952
- {candidates.map(candidate => {
953
- const selected = channel.models.some(model => model.alias === candidate)
954
- const entry = describeModel(candidate)
955
- return (
956
- <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
957
- <input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
958
- const merged = selected
959
- ? channel.models.filter(model => model.alias !== candidate)
960
- : [...channel.models, { alias: candidate, id: candidate }]
961
- props.onSetModels(merged)
962
- }} />
963
- <span>{candidate}</span>
964
- {!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
965
- </label>
966
- )
967
- })}
968
- </div>
969
- ) : null}
970
-
971
- <div className={css.editorDivider} />
972
-
973
- <div className={css.editorFooter}>
974
- {props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
975
- <button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
976
- )}
977
- <span className={css.spacer} />
978
- {removeOpen
979
- ? (
980
- <>
981
- <button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
982
- <button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
983
- </>
984
- )
985
- : (
986
- <button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
987
- )}
988
- </div>
989
- </div>
990
- </div>
991
- )
992
- }
993
-
994
- /** Props every field control needs regardless of its value type. */
995
- interface FieldProps {
996
- /** Stable id associating the label with its control. */
997
- id: string
998
- /** Visible label. */
999
- label: string
1000
- /** One-line explanation rendered under the control. */
1001
- hint: string
1002
- /** Draft text this control renders. */
1003
- text: string
1004
- /** True when saving would leave a user-layer entry for this field. */
1005
- overridden: boolean
1006
- /** True when the draft is not a value this field accepts. */
1007
- invalid: boolean
1008
- /** Copy for the overridden badge. */
1009
- overriddenLabel: string
1010
- /** Copy for the reset control. */
1011
- resetLabel: string
1012
- /** Copy shown in place of the hint while the draft is invalid. */
1013
- invalidLabel: string
1014
- /** Disables every control (read-only document, or an unavailable namespace). */
1015
- disabled: boolean
1016
- /** Stage draft text. */
1017
- onEdit: (text: string) => void
1018
- /** Stage a clear so the field re-inherits the composition layer. */
1019
- onReset: () => void
1020
- }
1021
-
1022
- /** A staged value field; `secret` renders a password control. */
1023
- function ValueField(props: FieldProps & {
1024
- /** Render a password control. */
1025
- secret?: boolean
1026
- /** Placeholder shown while the draft is empty. */
1027
- placeholder?: string
1028
- /** Label of the dedicated clear control (secret fields). */
1029
- clearLabel?: string
1030
- /** Stage a clear of the stored secret. */
1031
- onClear?: () => void
1032
- /** Whether a stored secret exists (enables the clear control). */
1033
- canClear?: boolean
1034
- }) {
1035
- return (
1036
- <div className={css.field}>
1037
- <div className={css.head}>
1038
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
1039
- {props.overridden
1040
- ? (
1041
- <span className={css.badges}>
1042
- <span className={css.badge}>{props.overriddenLabel}</span>
1043
- <button
1044
- type="button"
1045
- className={css.reset}
1046
- disabled={props.disabled}
1047
- onClick={props.onReset}
1048
- >
1049
- {props.resetLabel}
1050
- </button>
1051
- </span>
1052
- )
1053
- : null}
1054
- {props.secret === true && props.canClear === true
1055
- ? (
1056
- <button
1057
- type="button"
1058
- className={css.reset}
1059
- disabled={props.disabled}
1060
- onClick={props.onClear}
1061
- >
1062
- {props.clearLabel ?? props.resetLabel}
1063
- </button>
1064
- )
1065
- : null}
1066
- </div>
1067
- <input
1068
- id={props.id}
1069
- className={props.invalid ? css.inputInvalid : css.input}
1070
- type={props.secret === true ? 'password' : 'text'}
1071
- autoComplete={props.secret === true ? 'off' : undefined}
1072
- {...props.invalid ? { 'aria-invalid': true } : {}}
1073
- value={props.text}
1074
- placeholder={props.placeholder ?? ''}
1075
- disabled={props.disabled}
1076
- onChange={(event) => { props.onEdit(event.target.value) }}
1077
- />
1078
- <p className={props.invalid ? css.invalid : css.hint}>
1079
- {props.invalid ? props.invalidLabel : props.hint}
1080
- </p>
1081
- </div>
1082
- )
1083
- }
1084
-
1085
- /** A staged boolean field: 继承 / 开 / 关. */
1086
- function BooleanField(props: FieldProps & {
1087
- /** Copy for the inherit option. */
1088
- inheritLabel: string
1089
- /** Copy for the on option. */
1090
- onLabel: string
1091
- /** Copy for the off option. */
1092
- offLabel: string
1093
- }) {
1094
- return (
1095
- <div className={css.field}>
1096
- <div className={css.head}>
1097
- <label className={css.label} htmlFor={props.id}>{props.label}</label>
1098
- {props.overridden
1099
- ? (
1100
- <span className={css.badges}>
1101
- <span className={css.badge}>{props.overriddenLabel}</span>
1102
- <button
1103
- type="button"
1104
- className={css.reset}
1105
- disabled={props.disabled}
1106
- onClick={props.onReset}
1107
- >
1108
- {props.resetLabel}
1109
- </button>
1110
- </span>
1111
- )
1112
- : null}
1113
- </div>
1114
- <select
1115
- id={props.id}
1116
- className={css.select}
1117
- value={props.text}
1118
- disabled={props.disabled}
1119
- onChange={(event) => { props.onEdit(event.target.value) }}
1120
- >
1121
- <option value="">{props.inheritLabel}</option>
1122
- <option value="true">{props.onLabel}</option>
1123
- <option value="false">{props.offLabel}</option>
1124
- </select>
1125
- <p className={css.hint}>{props.hint}</p>
1126
- </div>
1127
- )
1128
- }
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-store'
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 { tt, type TranslateValues } from './helpers.ts'
25
+ import { useImageGenLanguageTick } from './use-language.ts'
26
+ import css from './settings-card.module.css'
27
+
28
+ /** The global (non-channel) fields this card's staged form edits. */
29
+ export interface ImageGenSettings {
30
+ enabled?: boolean
31
+ announceToAgent?: boolean
32
+ allowAgentImageGeneration?: boolean
33
+ promptApiUrl?: string
34
+ promptApiKey?: string
35
+ promptModel?: string
36
+ localStoragePath?: string
37
+ storageEnabled?: boolean
38
+ storageEndpoint?: string
39
+ storageRegion?: string
40
+ storagePrefix?: string
41
+ storageAccessKey?: string
42
+ storageSecretKey?: string
43
+ storageSyncGallery?: boolean
44
+ storageSyncHistory?: boolean
45
+ }
46
+
47
+ /** What the card renders. */
48
+ export interface ImageGenSettingsCardState extends CardShell {
49
+ /** Channel list staging (channels + per-channel key edits + default). */
50
+ channels: ChannelsFormState
51
+ /** Master switch. */
52
+ enabled: CardFieldState
53
+ /** System-prompt announcement flag. */
54
+ announceToAgent: CardFieldState
55
+ allowAgentImageGeneration: CardFieldState
56
+ promptApiUrl: CardFieldState
57
+ promptApiKey: CardFieldState
58
+ promptModel: CardFieldState
59
+ localStoragePath: CardFieldState
60
+ storageEnabled: CardFieldState
61
+ storageEndpoint: CardFieldState
62
+ storageRegion: CardFieldState
63
+ storagePrefix: CardFieldState
64
+ storageAccessKey: CardFieldState
65
+ storageSecretKey: CardFieldState
66
+ storageSyncGallery: CardFieldState
67
+ storageSyncHistory: CardFieldState
68
+ }
69
+
70
+ /** Result of probing the configured object storage from the card. */
71
+ export interface StorageTestOutcome {
72
+ ok: boolean
73
+ ms?: number
74
+ message?: string
75
+ }
76
+
77
+ /** The registration-side face the card's slot entry injects. */
78
+ export interface ImageGenSettingsCardFace extends CardActions {
79
+ /** Channel staging actions (committed together with the card's save). */
80
+ channels: ChannelsFormActions
81
+ /** Save staged edits, then upload a probe object to the configured store. */
82
+ storageTest: () => Promise<StorageTestOutcome>
83
+ hooks: {
84
+ /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
85
+ imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
86
+ }
87
+ }
88
+
89
+ /** Bridges the imagegen scope onto the card's staged forms. */
90
+ export class ImageGenSettingsCardController {
91
+ private readonly form: CardForm<ImageGenSettings>
92
+ private readonly channelsForm: ChannelsForm
93
+
94
+ /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
95
+ constructor(private readonly scope: ImageGenScope) {
96
+ this.form = new CardForm(scope, [
97
+ booleanField('enabled'),
98
+ booleanField('announceToAgent'),
99
+ booleanField('allowAgentImageGeneration'),
100
+ textField('promptApiUrl'),
101
+ secretField('promptApiKey'),
102
+ textField('promptModel'),
103
+ textField('localStoragePath'),
104
+ booleanField('storageEnabled'),
105
+ textField('storageEndpoint'),
106
+ textField('storageRegion'),
107
+ textField('storagePrefix'),
108
+ textField('storageAccessKey'),
109
+ secretField('storageSecretKey'),
110
+ booleanField('storageSyncGallery'),
111
+ booleanField('storageSyncHistory'),
112
+ ], {
113
+ secretSettled: (field) => this.scope.getSecretSetSnapshot(field),
114
+ })
115
+ this.channelsForm = new ChannelsForm(scope)
116
+ }
117
+
118
+ private projection(): ImageGenSettingsCardState {
119
+ const shell = this.form.shell()
120
+ return {
121
+ ...shell,
122
+ dirty: shell.dirty || this.channelsForm.snapshot().dirty,
123
+ channels: this.channelsForm.snapshot(),
124
+ enabled: this.form.field('enabled'),
125
+ announceToAgent: this.form.field('announceToAgent'),
126
+ allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
127
+ promptApiUrl: this.form.field('promptApiUrl'),
128
+ promptApiKey: this.form.field('promptApiKey'),
129
+ promptModel: this.form.field('promptModel'),
130
+ localStoragePath: this.form.field('localStoragePath'),
131
+ storageEnabled: this.form.field('storageEnabled'),
132
+ storageEndpoint: this.form.field('storageEndpoint'),
133
+ storageRegion: this.form.field('storageRegion'),
134
+ storagePrefix: this.form.field('storagePrefix'),
135
+ storageAccessKey: this.form.field('storageAccessKey'),
136
+ storageSecretKey: this.form.field('storageSecretKey'),
137
+ storageSyncGallery: this.form.field('storageSyncGallery'),
138
+ storageSyncHistory: this.form.field('storageSyncHistory'),
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Build the face the card's slot registration injects.
144
+ * @returns the card's snapshot and the form/channel actions.
145
+ */
146
+ inject(): ImageGenSettingsCardFace {
147
+ const cardStore = this.form.bind(() => this.projection())
148
+ this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
149
+ return {
150
+ hooks: {
151
+ imageGenSettingsCard: cardStore,
152
+ },
153
+ channels: this.channelsForm.actions(),
154
+ // The probe needs the values the user is looking at, so staged edits are
155
+ // committed first; the host route then resolves the saved config itself.
156
+ storageTest: async (): Promise<StorageTestOutcome> => {
157
+ await this.form.save()
158
+ try {
159
+ const response = await fetch('/api/dsh-imagegen/storage/test', { method: 'POST' })
160
+ const body = await response.json() as { ok?: unknown; ms?: unknown; message?: unknown }
161
+ if (body.ok === true) return { ok: true, ms: typeof body.ms === 'number' ? body.ms : undefined }
162
+ return { ok: false, message: typeof body.message === 'string' ? body.message : `HTTP ${response.status}` }
163
+ } catch (error) {
164
+ return { ok: false, message: error instanceof Error ? error.message : String(error) }
165
+ }
166
+ },
167
+ ...this.form.actions(),
168
+ }
169
+ }
170
+ }
171
+
172
+ /** Props the renderer binds for this card. */
173
+ export type ImageGenSettingsCardProps =
174
+ PropsRuntime<'settings.plugin.item'>
175
+ & PropsLocale<'dsh-imagegen'>
176
+ & InjectFace<ImageGenSettingsCardFace>
177
+
178
+ /** Host-computed usage counters (generation-count badges). */
179
+ interface UsageCounters {
180
+ byChannel: Record<string, Record<string, number>>
181
+ totals: Record<string, number>
182
+ }
183
+
184
+ /**
185
+ * Render the card.
186
+ * @param props - locale copy, the card snapshot, and the form actions.
187
+ * @returns the card, or nothing while the namespace is still loading.
188
+ */
189
+ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
190
+ // The card renders through the plugin's own dictionary so the uiLanguage
191
+ // override applies here too — the host-locale props.t would only follow the
192
+ // DSH interface language.
193
+ const t = tt
194
+ useImageGenLanguageTick()
195
+ const state = props.useImageGenSettingsCard(snapshot => snapshot)
196
+ const [open, setOpen] = useState(false)
197
+ // Global-section local states (prompt enhancement etc.).
198
+ const [promptModels, setPromptModels] = useState<string[]>([])
199
+ const [loadingPromptModels, setLoadingPromptModels] = useState(false)
200
+ const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
201
+ const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
202
+ const [manualPromptModel, setManualPromptModel] = useState('')
203
+ const [enhancementOpen, setEnhancementOpen] = useState(false)
204
+ const [promptApiOpen, setPromptApiOpen] = useState(false)
205
+ const [storageOpen, setStorageOpen] = useState(false)
206
+ const [storageTesting, setStorageTesting] = useState(false)
207
+ const [storageTestResult, setStorageTestResult] = useState<string | null>(null)
208
+ const [moreOpen, setMoreOpen] = useState(false)
209
+ // Channel list local states.
210
+ const [editingId, setEditingId] = useState<string | null>(null)
211
+ const [presetPickerOpen, setPresetPickerOpen] = useState(false)
212
+ const [presets, setPresets] = useState<PresetProviderView[]>([])
213
+ const [presetError, setPresetError] = useState<string | null>(null)
214
+ const [usage, setUsage] = useState<UsageCounters | null>(null)
215
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
216
+
217
+ // Usage counters: refreshed once per card open (and after a successful save).
218
+ useEffect(() => {
219
+ if (!state.exposed) return
220
+ let alive = true
221
+ void fetch(USAGE_API, { method: 'POST' })
222
+ .then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) })
223
+ .catch(() => { /* counters are best-effort */ })
224
+ return () => { alive = false }
225
+ }, [state.exposed])
226
+
227
+ if (!state.available) return null
228
+ const title = t('settings.title')
229
+ const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
230
+ const disabled = !state.writable
231
+ const fieldProps = {
232
+ overriddenLabel: t('settings.overridden'),
233
+ resetLabel: t('settings.reset'),
234
+ invalidLabel: t('settings.invalidNumber'),
235
+ disabled,
236
+ }
237
+
238
+ if (!state.exposed) {
239
+ return (
240
+ <li className={css.card}>
241
+ <button
242
+ type="button"
243
+ className={css.header}
244
+ aria-expanded={open}
245
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
246
+ onClick={() => { setOpen(!open) }}
247
+ >
248
+ <span className={css.headText}>
249
+ <span className={css.name}>{title}</span>
250
+ <span className={css.description}>{t('settings.description')}</span>
251
+ </span>
252
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
253
+ </button>
254
+ {open
255
+ ? (
256
+ <div className={css.body}>
257
+ <p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
258
+ </div>
259
+ )
260
+ : null}
261
+ </li>
262
+ )
263
+ }
264
+
265
+ const channels = state.channels.channels
266
+ const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
267
+
268
+ return (
269
+ <li className={css.card}>
270
+ <button
271
+ type="button"
272
+ className={css.header}
273
+ aria-expanded={open}
274
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
275
+ onClick={() => { setOpen(!open) }}
276
+ >
277
+ <span className={css.headText}>
278
+ <span className={css.name}>{title}</span>
279
+ <span className={css.description}>{t('settings.description')}</span>
280
+ </span>
281
+ {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
282
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
283
+ </button>
284
+ {open
285
+ ? (
286
+ <div className={css.body}>
287
+ {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
288
+
289
+ <section className={css.channelSection} aria-label={t('channels.title')}>
290
+ <div className={css.sectionHeader}>
291
+ <div>
292
+ <h3 className={css.sectionTitle}>{t('channels.title')}</h3>
293
+ <p className={css.sectionHint}>{t('channels.hint')}</p>
294
+ </div>
295
+ </div>
296
+ {channels.length === 0
297
+ ? <p className={css.channelEmpty}>{t('channels.empty')}</p>
298
+ : (
299
+ <ul className={css.channelList}>
300
+ {channels.map(channel => {
301
+ const keyHeld = state.channels.keySet[channel.id] === true
302
+ const ready = keyHeld && channel.models.length > 0
303
+ const isDefault = channel.id === state.channels.defaultChannelId
304
+ if (confirmDeleteId === channel.id) {
305
+ return (
306
+ <li key={channel.id} className={css.channelRow} data-action>
307
+ <span className={css.deleteConfirmText}>{t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}</span>
308
+ <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>
309
+ <button type="button" className={css.channelAction} disabled={disabled} onClick={() => { setConfirmDeleteId(null) }}>{t('channels.cancel')}</button>
310
+ </li>
311
+ )
312
+ }
313
+ return (
314
+ <li key={channel.id} className={css.channelRow}>
315
+ <span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
316
+ <button type="button" className={css.channelMain} disabled={disabled} onClick={() => { setEditingId(channel.id) }}>
317
+ <span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
318
+ <span className={css.channelMeta}>
319
+ <span className={css.channelBadge} data-warn={!keyHeld || channel.models.length === 0 ? '' : undefined}>
320
+ {keyHeld ? t('channels.keySet') : t('channels.keyMissing')}
321
+ {' · '}
322
+ {channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}
323
+ </span>
324
+ </span>
325
+ </button>
326
+ <button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
327
+ <button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
328
+ </li>
329
+ )
330
+ })}
331
+ </ul>
332
+ )}
333
+ <div className={css.channelControls}>
334
+ {open && presetPickerOpen ? (
335
+ <PresetPicker
336
+ t={t}
337
+ presets={presets}
338
+ error={presetError}
339
+ disabled={state.writable === false}
340
+ onLoad={() => {
341
+ setPresetError(null)
342
+ void fetch(PRESETS_API, { method: 'POST' })
343
+ .then(async response => {
344
+ const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
345
+ if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
346
+ setPresets(body.presets)
347
+ })
348
+ .catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
349
+ }}
350
+ onPick={(preset) => {
351
+ const draft = newChannelDraft(preset)
352
+ props.channels.setChannels([...channels, draft])
353
+ setPresetPickerOpen(false)
354
+ setEditingId(draft.id)
355
+ }}
356
+ onCustom={() => {
357
+ const draft = newChannelDraft(undefined)
358
+ props.channels.setChannels([...channels, draft])
359
+ setPresetPickerOpen(false)
360
+ setEditingId(draft.id)
361
+ }}
362
+ onClose={() => { setPresetPickerOpen(false) }}
363
+ />
364
+ ) : null}
365
+
366
+ <div className={css.channelAddRow}>
367
+ <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
368
+ <button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
369
+ </div>
370
+ </div>
371
+ </section>
372
+
373
+ <button
374
+ type="button"
375
+ className={css.disclosure}
376
+ aria-expanded={enhancementOpen}
377
+ onClick={() => { setEnhancementOpen(open => !open) }}
378
+ >
379
+ <span>{t('settings.promptEnhanceTitle')}</span>
380
+ <span>{t('settings.optional')}</span>
381
+ <span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
382
+ </button>
383
+ {enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
384
+ <p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
385
+ <div className={css.sectionHeader}>
386
+ <div>
387
+ <h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
388
+ <p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
389
+ </div>
390
+ <button
391
+ type="button"
392
+ className={css.modelFetch}
393
+ disabled={disabled || loadingPromptModels}
394
+ onClick={() => {
395
+ setLoadingPromptModels(true)
396
+ setPromptModelsError(null)
397
+ void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
398
+ .then(async response => {
399
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
400
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
401
+ setPromptModels(body.models ?? [])
402
+ })
403
+ .catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
404
+ .finally(() => { setLoadingPromptModels(false) })
405
+ }}
406
+ >
407
+ {loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
408
+ </button>
409
+ </div>
410
+ <div className={css.modelSummary}>
411
+ {state.promptModel.text.trim() !== '' ? (
412
+ <span className={css.modelChip}>
413
+ <span>{state.promptModel.text}</span>
414
+ <button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
415
+ </span>
416
+ ) : null}
417
+ <button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
418
+ {manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
419
+ </button>
420
+ </div>
421
+ {manualPromptModelOpen ? (
422
+ <div className={css.manualModelRow}>
423
+ <input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
424
+ <button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
425
+ </div>
426
+ ) : null}
427
+ {promptModels.length > 0 ? (
428
+ <div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
429
+ <span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
430
+ {promptModels.map(candidate => (
431
+ <button
432
+ key={candidate}
433
+ type="button"
434
+ role="radio"
435
+ className={css.modelCandidate}
436
+ aria-checked={state.promptModel.text === candidate}
437
+ data-selected={state.promptModel.text === candidate ? '' : undefined}
438
+ disabled={disabled}
439
+ onClick={() => { props.edit('promptModel', candidate) }}
440
+ >
441
+ {candidate}
442
+ </button>
443
+ ))}
444
+ </div>
445
+ ) : null}
446
+ {promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
447
+ <button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
448
+ <span>{t('settings.promptApiAdvanced')}</span>
449
+ <span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
450
+ </button>
451
+ {promptApiOpen ? <div className={css.optionalContent}>
452
+ <ValueField
453
+ id="dsh-imagegen-settings-prompt-apiurl"
454
+ label={t('settings.promptApiUrl')}
455
+ hint={t('settings.promptApiUrlHint')}
456
+ placeholder="https://api.openai.com/v1"
457
+ {...fieldProps}
458
+ {...state.promptApiUrl}
459
+ onEdit={(text) => { props.edit('promptApiUrl', text) }}
460
+ onReset={() => { props.resetField('promptApiUrl') }}
461
+ />
462
+ <ValueField
463
+ id="dsh-imagegen-settings-prompt-apikey"
464
+ label={t('settings.promptApiKey')}
465
+ hint={t('settings.promptApiKeyHint')}
466
+ placeholder="sk-…"
467
+ secret
468
+ {...fieldProps}
469
+ {...state.promptApiKey}
470
+ overridden={false}
471
+ onEdit={(text) => { props.edit('promptApiKey', text) }}
472
+ onReset={() => { props.resetField('promptApiKey') }}
473
+ />
474
+ </div> : null}
475
+ </section> : null}
476
+
477
+ <button type="button" className={css.disclosure} aria-expanded={storageOpen} onClick={() => { setStorageOpen(open => !open) }}>
478
+ <span>{t('settings.storageTitle')}</span>
479
+ <span aria-hidden="true">{storageOpen ? '⌃' : '⌄'}</span>
480
+ </button>
481
+ {storageOpen ? <div className={css.optionalContent}>
482
+ <ValueField
483
+ id="dsh-imagegen-settings-local-storage-path"
484
+ label={t('settings.localStoragePath')}
485
+ hint={t('settings.localStoragePathHint')}
486
+ placeholder="E:\\dsh-imagegen-data"
487
+ {...fieldProps}
488
+ {...state.localStoragePath}
489
+ onEdit={(text) => { props.edit('localStoragePath', text) }}
490
+ onReset={() => { props.resetField('localStoragePath') }}
491
+ />
492
+ <BooleanField
493
+ id="dsh-imagegen-settings-storage-enabled"
494
+ label={t('settings.storageEnabled')}
495
+ hint={t('settings.storageHint')}
496
+ inheritLabel={t('settings.inherit')}
497
+ onLabel={t('settings.on')}
498
+ offLabel={t('settings.off')}
499
+ {...fieldProps}
500
+ {...state.storageEnabled}
501
+ onEdit={(text) => { props.edit('storageEnabled', text) }}
502
+ onReset={() => { props.resetField('storageEnabled') }}
503
+ />
504
+ <ValueField
505
+ id="dsh-imagegen-settings-storage-endpoint"
506
+ label={t('settings.storageEndpoint')}
507
+ hint={t('settings.storageEndpointHint')}
508
+ placeholder="https://bucket-appid.cos.ap-guangzhou.myqcloud.com"
509
+ {...fieldProps}
510
+ {...state.storageEndpoint}
511
+ onEdit={(text) => { props.edit('storageEndpoint', text) }}
512
+ onReset={() => { props.resetField('storageEndpoint') }}
513
+ />
514
+ <ValueField
515
+ id="dsh-imagegen-settings-storage-region"
516
+ label={t('settings.storageRegion')}
517
+ hint={t('settings.storageRegionHint')}
518
+ placeholder="ap-guangzhou"
519
+ {...fieldProps}
520
+ {...state.storageRegion}
521
+ onEdit={(text) => { props.edit('storageRegion', text) }}
522
+ onReset={() => { props.resetField('storageRegion') }}
523
+ />
524
+ <ValueField
525
+ id="dsh-imagegen-settings-storage-prefix"
526
+ label={t('settings.storagePrefix')}
527
+ hint={t('settings.storagePrefixHint')}
528
+ placeholder="dsh-imagegen"
529
+ {...fieldProps}
530
+ {...state.storagePrefix}
531
+ onEdit={(text) => { props.edit('storagePrefix', text) }}
532
+ onReset={() => { props.resetField('storagePrefix') }}
533
+ />
534
+ <ValueField
535
+ id="dsh-imagegen-settings-storage-accesskey"
536
+ label={t('settings.storageAccessKey')}
537
+ hint={t('settings.storageAccessKeyHint')}
538
+ placeholder="AKID…"
539
+ {...fieldProps}
540
+ {...state.storageAccessKey}
541
+ onEdit={(text) => { props.edit('storageAccessKey', text) }}
542
+ onReset={() => { props.resetField('storageAccessKey') }}
543
+ />
544
+ <ValueField
545
+ id="dsh-imagegen-settings-storage-secretkey"
546
+ label={t('settings.storageSecretKey')}
547
+ hint={t('settings.storageSecretKeyHint')}
548
+ placeholder="…"
549
+ secret
550
+ {...fieldProps}
551
+ {...state.storageSecretKey}
552
+ overridden={false}
553
+ onEdit={(text) => { props.edit('storageSecretKey', text) }}
554
+ onReset={() => { props.resetField('storageSecretKey') }}
555
+ />
556
+ <BooleanField
557
+ id="dsh-imagegen-settings-storage-gallery"
558
+ label={t('settings.storageSyncGallery')}
559
+ hint={t('settings.storageSyncGalleryHint')}
560
+ inheritLabel={t('settings.inherit')}
561
+ onLabel={t('settings.on')}
562
+ offLabel={t('settings.off')}
563
+ {...fieldProps}
564
+ {...state.storageSyncGallery}
565
+ onEdit={(text) => { props.edit('storageSyncGallery', text) }}
566
+ onReset={() => { props.resetField('storageSyncGallery') }}
567
+ />
568
+ <BooleanField
569
+ id="dsh-imagegen-settings-storage-history"
570
+ label={t('settings.storageSyncHistory')}
571
+ hint={t('settings.storageSyncHistoryHint')}
572
+ inheritLabel={t('settings.inherit')}
573
+ onLabel={t('settings.on')}
574
+ offLabel={t('settings.off')}
575
+ {...fieldProps}
576
+ {...state.storageSyncHistory}
577
+ onEdit={(text) => { props.edit('storageSyncHistory', text) }}
578
+ onReset={() => { props.resetField('storageSyncHistory') }}
579
+ />
580
+ <div className={css.modelSummary}>
581
+ <button
582
+ type="button"
583
+ className={css.addModel}
584
+ disabled={disabled || storageTesting}
585
+ onClick={() => {
586
+ setStorageTesting(true)
587
+ setStorageTestResult(null)
588
+ void props.storageTest().then(outcome => {
589
+ setStorageTestResult(outcome.ok
590
+ ? t('settings.storageTestOk', { ms: outcome.ms ?? 0 })
591
+ : t('settings.storageTestFailed', { error: outcome.message ?? 'error' }))
592
+ }).finally(() => { setStorageTesting(false) })
593
+ }}
594
+ >
595
+ {storageTesting ? t('settings.storageTesting') : t('settings.storageTest')}
596
+ </button>
597
+ {storageTestResult !== null ? <p className={css.failed} role="status">{storageTestResult}</p> : null}
598
+ </div>
599
+ <p className={css.hint}>{t('settings.storageKeyHint')}</p>
600
+ </div> : null}
601
+
602
+ <button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
603
+ <span>{t('settings.moreOptions')}</span>
604
+ <span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
605
+ </button>
606
+ {moreOpen ? <div className={css.optionalContent}>
607
+ <BooleanField
608
+ id="dsh-imagegen-settings-enabled"
609
+ label={t('settings.enabled')}
610
+ hint={t('settings.enabledHint')}
611
+ inheritLabel={t('settings.inherit')}
612
+ onLabel={t('settings.on')}
613
+ offLabel={t('settings.off')}
614
+ {...fieldProps}
615
+ {...state.enabled}
616
+ onEdit={(text) => { props.edit('enabled', text) }}
617
+ onReset={() => { props.resetField('enabled') }}
618
+ />
619
+ <BooleanField
620
+ id="dsh-imagegen-settings-announce"
621
+ label={t('settings.announceToAgent')}
622
+ hint={t('settings.announceToAgentHint')}
623
+ inheritLabel={t('settings.inherit')}
624
+ onLabel={t('settings.on')}
625
+ offLabel={t('settings.off')}
626
+ {...fieldProps}
627
+ {...state.announceToAgent}
628
+ onEdit={(text) => { props.edit('announceToAgent', text) }}
629
+ onReset={() => { props.resetField('announceToAgent') }}
630
+ />
631
+ <BooleanField
632
+ id="dsh-imagegen-settings-agent-generation"
633
+ label={t('settings.allowAgentImageGeneration')}
634
+ hint={t('settings.allowAgentImageGenerationHint')}
635
+ inheritLabel={t('settings.inherit')}
636
+ onLabel={t('settings.on')}
637
+ offLabel={t('settings.off')}
638
+ {...fieldProps}
639
+ {...state.allowAgentImageGeneration}
640
+ onEdit={(text) => { props.edit('enabled', text) }}
641
+ onReset={() => { props.resetField('enabled') }}
642
+ />
643
+ </div> : null}
644
+ <div className={css.footer}>
645
+ {(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
646
+ <button
647
+ type="button"
648
+ className={css.discard}
649
+ disabled={!state.dirty || state.saving || state.channels.saving}
650
+ onClick={() => { props.discard(); props.channels.discard() }}
651
+ >
652
+ {t('settings.discard')}
653
+ </button>
654
+ <button
655
+ type="button"
656
+ className={css.save}
657
+ disabled={blocked}
658
+ onClick={() => { void props.channels.commit(); void props.save() }}
659
+ >
660
+ {t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
661
+ </button>
662
+ </div>
663
+ </div>
664
+ )
665
+ : null}
666
+
667
+ {open && editing !== undefined ? (
668
+ <ChannelEditor
669
+ key={editing.id}
670
+ t={t}
671
+ channel={editing}
672
+ keyHeld={state.channels.keySet[editing.id] === true}
673
+ usage={usage}
674
+ otherChannels={channels.filter(channel => channel.id !== editing.id)}
675
+ isDefault={editing.id === state.channels.defaultChannelId}
676
+ writable={state.writable}
677
+ onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
678
+ onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
679
+ onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
680
+ onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
681
+ 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) }}
682
+ onClose={() => { setEditingId(null) }}
683
+ />
684
+ ) : null}
685
+
686
+ </li>
687
+ )
688
+ }
689
+
690
+ /** Channel row + dialog helpers -------------------------------------------------- */
691
+
692
+ function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
693
+ return {
694
+ id: clientId(),
695
+ preset: preset?.id ?? '',
696
+ name: preset?.name ?? '',
697
+ apiUrl: preset?.apiUrl ?? '',
698
+ models: (preset?.models ?? []).map(model => ({ ...model })),
699
+ }
700
+ }
701
+
702
+ function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
703
+ const draft = newChannelDraft(undefined)
704
+ form.setChannels([...channels, draft])
705
+ openEditor(draft.id)
706
+ }
707
+
708
+ /** Patch one field (or models) of one staged channel. */
709
+ function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
710
+ form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
711
+ }
712
+
713
+ function clientId(): string {
714
+ const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
715
+ return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
716
+ }
717
+
718
+ /** Built-in provider picker, expanded inside the settings card. */
719
+ function PresetPicker(props: {
720
+ t: (key: ImageGenKey, params?: Record<string, string | number>) => string
721
+ presets: PresetProviderView[]
722
+ error: string | null
723
+ disabled: boolean
724
+ onLoad: () => void
725
+ onPick: (preset: PresetProviderView) => void
726
+ onCustom: () => void
727
+ onClose: () => void
728
+ }) {
729
+ const { t } = props
730
+ const loadedRef = useRef(false)
731
+ useEffect(() => {
732
+ if (loadedRef.current) return
733
+ loadedRef.current = true
734
+ props.onLoad()
735
+ }, [])
736
+ return (
737
+ <section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
738
+ <header className={css.presetInlineHeader}>
739
+ <div>
740
+ <h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
741
+ <p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
742
+ </div>
743
+ <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
744
+ </header>
745
+ <div className={css.presetList}>
746
+ {props.presets.map(preset => (
747
+ <button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
748
+ <span className={css.presetName}>{preset.name}</span>
749
+ <span className={css.presetMeta}>{preset.models.map(model => model.alias).join(' · ')}</span>
750
+ </button>
751
+ ))}
752
+ <button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
753
+ <span className={css.presetName}>+ {t('channels.addCustom')}</span>
754
+ <span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
755
+ </button>
756
+ {props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
757
+ </div>
758
+ </section>
759
+ )
760
+ }
761
+
762
+ /** Channel editor (modal): key, display name, API URL, model catalog. */
763
+ function ChannelEditor(props: {
764
+ t: (key: ImageGenKey, params?: Record<string, string | number>) => string
765
+ channel: ChannelDraft
766
+ keyHeld: boolean
767
+ usage: UsageCounters | null
768
+ otherChannels: ChannelDraft[]
769
+ isDefault: boolean
770
+ writable: boolean
771
+ onPatch: (patch: Partial<ChannelDraft>) => void
772
+ onSetModels: (models: ModelMapping[]) => void
773
+ onSetKey: (value: string | undefined) => void
774
+ onSetDefault: () => void
775
+ onRemove: () => void
776
+ onClose: () => void
777
+ }) {
778
+ const { t, channel } = props
779
+ const [keyDraft, setKeyDraft] = useState('')
780
+ const [candidates, setCandidates] = useState<string[] | null>(null)
781
+ const [detecting, setDetecting] = useState(false)
782
+ const [detectError, setDetectError] = useState<string | null>(null)
783
+ const [manualId, setManualId] = useState('')
784
+ const [removeOpen, setRemoveOpen] = useState(false)
785
+ const [copyFrom, setCopyFrom] = useState('')
786
+
787
+ const generatedCount = (alias: string): number => {
788
+ if (props.usage === null) return 0
789
+ const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
790
+ return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
791
+ }
792
+
793
+ const detect = (): void => {
794
+ setDetecting(true)
795
+ setDetectError(null)
796
+ const payload: Record<string, unknown> = { channelId: channel.id }
797
+ if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
798
+ if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
799
+ void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
800
+ .then(async response => {
801
+ const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
802
+ if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
803
+ setCandidates(body.models ?? [])
804
+ })
805
+ .catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
806
+ .finally(() => { setDetecting(false) })
807
+ }
808
+
809
+ // Auto-detect once when the dialog opens with a complete endpoint.
810
+ const autoDetected = useRef(false)
811
+ useEffect(() => {
812
+ if (autoDetected.current) return
813
+ autoDetected.current = true
814
+ if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
815
+ }, [])
816
+
817
+ const addManual = (): void => {
818
+ const id = manualId.trim()
819
+ if (id === '') return
820
+ const next = [...channel.models]
821
+ const alias = id
822
+ if (!next.some(model => model.alias === alias)) next.push({ alias, id })
823
+ props.onSetModels(next)
824
+ setManualId('')
825
+ }
826
+
827
+ const copyFromChannel = (): void => {
828
+ const source = props.otherChannels.find(chance => chance.id === copyFrom)
829
+ if (source === undefined) return
830
+ const merged = [...channel.models]
831
+ for (const model of source.models) {
832
+ const alias = model.alias
833
+ // Copy with a collision suffix so both sources stay selectable.
834
+ let unique = alias
835
+ let suffix = 2
836
+ while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
837
+ merged.push({ alias: unique, id: model.id })
838
+ }
839
+ props.onSetModels(merged)
840
+ setCopyFrom('')
841
+ }
842
+
843
+ return (
844
+ <div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
845
+ <div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
846
+ <header className={css.editorHeader}>
847
+ <div>
848
+ <h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
849
+ <p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
850
+ </div>
851
+ <button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
852
+ </header>
853
+
854
+ <div className={css.editorField}>
855
+ <label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
856
+ <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 }) }} />
857
+ </div>
858
+ <div className={css.editorField}>
859
+ <label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
860
+ <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 }) }} />
861
+ </div>
862
+ <div className={css.editorField}>
863
+ <div className={css.head}>
864
+ <label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
865
+ {props.keyHeld || keyDraft !== ''
866
+ ? (
867
+ <button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
868
+ {t('channels.keyClear')}
869
+ </button>
870
+ )
871
+ : null}
872
+ </div>
873
+ <input
874
+ id="dsh-imagegen-channel-key"
875
+ className={css.input}
876
+ type="password"
877
+ autoComplete="off"
878
+ value={keyDraft}
879
+ placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
880
+ disabled={!props.writable}
881
+ onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
882
+ />
883
+ </div>
884
+
885
+ <div className={css.editorDivider} />
886
+
887
+ <div className={css.editorSectionHeader}>
888
+ <h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
889
+ <button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
890
+ {detecting ? t('channels.detecting') : t('channels.detect')}
891
+ </button>
892
+ </div>
893
+ {detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
894
+ {candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
895
+
896
+ {channel.models.length === 0
897
+ ? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
898
+ : (
899
+ <ul className={css.modelRows}>
900
+ {channel.models.map((model, index) => {
901
+ const entry = describeModel(model.id || model.alias)
902
+ const generated = generatedCount(model.alias)
903
+ return (
904
+ <li key={`${model.alias}-${index}`} className={css.modelRow}>
905
+ <div className={css.modelRowInputs}>
906
+ <input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
907
+ const next = [...channel.models]
908
+ next[index] = { ...model, alias: event.target.value }
909
+ props.onSetModels(next)
910
+ }} />
911
+ <span className={css.modelArrow}>→</span>
912
+ <input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
913
+ const next = [...channel.models]
914
+ next[index] = { ...model, id: event.target.value }
915
+ props.onSetModels(next)
916
+ }} />
917
+ </div>
918
+ <div className={css.modelRowBadges}>
919
+ <span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
920
+ {generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
921
+ <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>
922
+ </div>
923
+ </li>
924
+ )
925
+ })}
926
+ </ul>
927
+ )}
928
+
929
+ <div className={css.editorTools}>
930
+ <div className={css.manualModelRow}>
931
+ <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() } }} />
932
+ <button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
933
+ </div>
934
+ {props.otherChannels.length > 0 ? (
935
+ <div className={css.manualModelRow}>
936
+ <select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
937
+ <option value="">{t('channels.copyFrom')}</option>
938
+ {props.otherChannels.map(other => (
939
+ <option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
940
+ ))}
941
+ </select>
942
+ <button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
943
+ </div>
944
+ ) : null}
945
+ </div>
946
+
947
+ {candidates !== null && candidates.length > 0 ? (
948
+ <div className={css.modelCandidateList}>
949
+ <span className={css.modelCandidateLabel}>
950
+ {t('channels.candidatesTitle')}
951
+ </span>
952
+ {candidates.map(candidate => {
953
+ const selected = channel.models.some(model => model.alias === candidate)
954
+ const entry = describeModel(candidate)
955
+ return (
956
+ <label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
957
+ <input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
958
+ const merged = selected
959
+ ? channel.models.filter(model => model.alias !== candidate)
960
+ : [...channel.models, { alias: candidate, id: candidate }]
961
+ props.onSetModels(merged)
962
+ }} />
963
+ <span>{candidate}</span>
964
+ {!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
965
+ </label>
966
+ )
967
+ })}
968
+ </div>
969
+ ) : null}
970
+
971
+ <div className={css.editorDivider} />
972
+
973
+ <div className={css.editorFooter}>
974
+ {props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
975
+ <button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
976
+ )}
977
+ <span className={css.spacer} />
978
+ {removeOpen
979
+ ? (
980
+ <>
981
+ <button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
982
+ <button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
983
+ </>
984
+ )
985
+ : (
986
+ <button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
987
+ )}
988
+ </div>
989
+ </div>
990
+ </div>
991
+ )
992
+ }
993
+
994
+ /** Props every field control needs regardless of its value type. */
995
+ interface FieldProps {
996
+ /** Stable id associating the label with its control. */
997
+ id: string
998
+ /** Visible label. */
999
+ label: string
1000
+ /** One-line explanation rendered under the control. */
1001
+ hint: string
1002
+ /** Draft text this control renders. */
1003
+ text: string
1004
+ /** True when saving would leave a user-layer entry for this field. */
1005
+ overridden: boolean
1006
+ /** True when the draft is not a value this field accepts. */
1007
+ invalid: boolean
1008
+ /** Copy for the overridden badge. */
1009
+ overriddenLabel: string
1010
+ /** Copy for the reset control. */
1011
+ resetLabel: string
1012
+ /** Copy shown in place of the hint while the draft is invalid. */
1013
+ invalidLabel: string
1014
+ /** Disables every control (read-only document, or an unavailable namespace). */
1015
+ disabled: boolean
1016
+ /** Stage draft text. */
1017
+ onEdit: (text: string) => void
1018
+ /** Stage a clear so the field re-inherits the composition layer. */
1019
+ onReset: () => void
1020
+ }
1021
+
1022
+ /** A staged value field; `secret` renders a password control. */
1023
+ function ValueField(props: FieldProps & {
1024
+ /** Render a password control. */
1025
+ secret?: boolean
1026
+ /** Placeholder shown while the draft is empty. */
1027
+ placeholder?: string
1028
+ /** Label of the dedicated clear control (secret fields). */
1029
+ clearLabel?: string
1030
+ /** Stage a clear of the stored secret. */
1031
+ onClear?: () => void
1032
+ /** Whether a stored secret exists (enables the clear control). */
1033
+ canClear?: boolean
1034
+ }) {
1035
+ return (
1036
+ <div className={css.field}>
1037
+ <div className={css.head}>
1038
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
1039
+ {props.overridden
1040
+ ? (
1041
+ <span className={css.badges}>
1042
+ <span className={css.badge}>{props.overriddenLabel}</span>
1043
+ <button
1044
+ type="button"
1045
+ className={css.reset}
1046
+ disabled={props.disabled}
1047
+ onClick={props.onReset}
1048
+ >
1049
+ {props.resetLabel}
1050
+ </button>
1051
+ </span>
1052
+ )
1053
+ : null}
1054
+ {props.secret === true && props.canClear === true
1055
+ ? (
1056
+ <button
1057
+ type="button"
1058
+ className={css.reset}
1059
+ disabled={props.disabled}
1060
+ onClick={props.onClear}
1061
+ >
1062
+ {props.clearLabel ?? props.resetLabel}
1063
+ </button>
1064
+ )
1065
+ : null}
1066
+ </div>
1067
+ <input
1068
+ id={props.id}
1069
+ className={props.invalid ? css.inputInvalid : css.input}
1070
+ type={props.secret === true ? 'password' : 'text'}
1071
+ autoComplete={props.secret === true ? 'off' : undefined}
1072
+ {...props.invalid ? { 'aria-invalid': true } : {}}
1073
+ value={props.text}
1074
+ placeholder={props.placeholder ?? ''}
1075
+ disabled={props.disabled}
1076
+ onChange={(event) => { props.onEdit(event.target.value) }}
1077
+ />
1078
+ <p className={props.invalid ? css.invalid : css.hint}>
1079
+ {props.invalid ? props.invalidLabel : props.hint}
1080
+ </p>
1081
+ </div>
1082
+ )
1083
+ }
1084
+
1085
+ /** A staged boolean field: 继承 / 开 / 关. */
1086
+ function BooleanField(props: FieldProps & {
1087
+ /** Copy for the inherit option. */
1088
+ inheritLabel: string
1089
+ /** Copy for the on option. */
1090
+ onLabel: string
1091
+ /** Copy for the off option. */
1092
+ offLabel: string
1093
+ }) {
1094
+ return (
1095
+ <div className={css.field}>
1096
+ <div className={css.head}>
1097
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
1098
+ {props.overridden
1099
+ ? (
1100
+ <span className={css.badges}>
1101
+ <span className={css.badge}>{props.overriddenLabel}</span>
1102
+ <button
1103
+ type="button"
1104
+ className={css.reset}
1105
+ disabled={props.disabled}
1106
+ onClick={props.onReset}
1107
+ >
1108
+ {props.resetLabel}
1109
+ </button>
1110
+ </span>
1111
+ )
1112
+ : null}
1113
+ </div>
1114
+ <select
1115
+ id={props.id}
1116
+ className={css.select}
1117
+ value={props.text}
1118
+ disabled={props.disabled}
1119
+ onChange={(event) => { props.onEdit(event.target.value) }}
1120
+ >
1121
+ <option value="">{props.inheritLabel}</option>
1122
+ <option value="true">{props.onLabel}</option>
1123
+ <option value="false">{props.offLabel}</option>
1124
+ </select>
1125
+ <p className={css.hint}>{props.hint}</p>
1126
+ </div>
1127
+ )
1128
+ }