dsh-audiogen 0.2.0 → 0.3.2

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.
@@ -4,15 +4,21 @@
4
4
  * Registers into the official `settings.plugin.item` slot. It manages a list
5
5
  * of audio channels (each with API URL, per-channel secret, and model/voice
6
6
  * catalog), plus master switches.
7
+ *
8
+ * Channel management follows the DSH "模型" settings pattern: provider rows
9
+ * with a status dot and 编辑/删除 actions, and two equal-width add actions
10
+ * (「添加提供方」 / 「添加自定义提供方」) below. Both add and edit open the
11
+ * same inline editor card — provider select, API key, 自定义设置 (API 地址)
12
+ * and a 模型目录 with 「获取可用模型」 discovery and per-row model editing.
7
13
  */
8
14
 
9
- import { useEffect, useState } from 'react'
15
+ import { useEffect, useMemo, useState } from 'react'
10
16
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
17
  import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
18
  import { CardForm, booleanField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
19
  import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
14
20
  import type { AudiogenScope } from './settings-scope.ts'
15
- import { MODEL_API, PRESETS_API, type DiscoveredAudioModel, type ModelMapping, type PresetProviderView } from '../protocol.ts'
21
+ import { MODEL_API, PRESETS_API, type AudioModelCategory, type DiscoveredAudioModel, type ModelMapping, type PresetProviderView } from '../protocol.ts'
16
22
  import type { AudioGenKey } from './locales.ts'
17
23
  import css from './settings-card.module.css'
18
24
 
@@ -83,118 +89,160 @@ export type AudioGenSettingsCardProps =
83
89
  & PropsLocale<'dsh-audiogen'>
84
90
  & InjectFace<AudioGenSettingsCardFace>
85
91
 
86
- function newChannelDraft(preset: PresetProviderView | undefined, existing: ChannelDraft[]): ChannelDraft {
87
- const id = `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
88
- if (preset === undefined) {
89
- return { id, preset: '', name: '', apiUrl: '', models: [] }
90
- }
91
- return {
92
- id,
93
- preset: preset.id,
94
- name: preset.name,
95
- apiUrl: preset.apiUrl,
96
- models: preset.models.map(model => ({ ...model })),
92
+ /** Which editor card is open. */
93
+ type EditorMode =
94
+ | { kind: 'edit'; channelId: string }
95
+ | { kind: 'add-provider' }
96
+ | { kind: 'add-custom' }
97
+
98
+ const MODEL_CATEGORIES: Array<AudioModelCategory | undefined> = [
99
+ undefined, 'tts', 'music', 'sfx', 'voice_design', 'voice_clone',
100
+ ]
101
+
102
+ function newChannelId(): string {
103
+ return `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
104
+ }
105
+
106
+ /** Strip a draft before staging: empty rows removed, aliases default to ids. */
107
+ function cleanModels(models: ModelMapping[]): ModelMapping[] {
108
+ const cleaned = models.map(model => {
109
+ const alias = model.alias.trim()
110
+ const id = model.id.trim() === '' ? alias : model.id.trim()
111
+ if (alias === '' && id === '') return undefined
112
+ return {
113
+ alias: alias === '' ? id : alias,
114
+ id,
115
+ ...(model.category === undefined ? {} : { category: model.category }),
116
+ } as ModelMapping | undefined
117
+ }).filter((model): model is ModelMapping => model !== undefined)
118
+ return [...new Map(cleaned.map(model => [model.alias, model])).values()]
119
+ }
120
+
121
+ /** Translate one key with interpolation (the injected `t` takes no values). */
122
+ function tpl(
123
+ t: (key: AudioGenKey) => string,
124
+ key: AudioGenKey,
125
+ values?: Record<string, string | number>,
126
+ ): string {
127
+ let text = t(key)
128
+ if (values === undefined) return text
129
+ for (const [name, value] of Object.entries(values)) {
130
+ text = text.replaceAll(`{${name}}`, String(value))
97
131
  }
132
+ return text
98
133
  }
99
134
 
100
- function modelsToText(models: ModelMapping[]): string {
101
- return models.map(model => `${model.alias}=${model.id}${model.category === undefined ? '' : ` @${model.category}`}`).join('\n')
135
+ /** One model row as the editor holds it (never empty—rows are removed). */
136
+ interface ModelRow extends ModelMapping {
137
+ rowKey: string
102
138
  }
103
139
 
104
- function textToModels(text: string): ModelMapping[] {
105
- return text.split(/\n|,/).map(line => line.trim()).filter(Boolean).map(line => {
106
- const at = line.lastIndexOf(' @')
107
- const category = at >= 0 ? line.slice(at + 2).trim() : undefined
108
- const body = at >= 0 ? line.slice(0, at).trim() : line
109
- const eq = body.indexOf('=')
110
- const alias = eq >= 0 ? body.slice(0, eq).trim() : body.trim()
111
- const id = eq >= 0 ? body.slice(eq + 1).trim() : alias
112
- return {
113
- alias,
114
- id: id === '' ? alias : id,
115
- ...(category === undefined || category === '' ? {} : { category: category as NonNullable<ModelMapping['category']> }),
116
- }
117
- }).filter(model => model.alias !== '')
140
+ function rowsOf(models: ModelMapping[]): ModelRow[] {
141
+ return models.map((model, index) => ({ ...model, rowKey: `m-${index}-${model.alias}-${model.id}` }))
118
142
  }
119
143
 
120
- export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
121
- const { t } = props
122
- const state = props.useAudioGenSettingsCard(snapshot => snapshot)
123
- const [open, setOpen] = useState(false)
124
- const [editingId, setEditingId] = useState<string | null>(null)
125
- const [presetPickerOpen, setPresetPickerOpen] = useState(false)
126
- const [presets, setPresets] = useState<PresetProviderView[]>([])
127
- const [presetError, setPresetError] = useState<string | null>(null)
128
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
129
- const [discovering, setDiscovering] = useState(false)
130
- const [discoverError, setDiscoverError] = useState<string | null>(null)
144
+ // ---------------------------------------------------------------------------
145
+ // Channel editor card (add + edit share it)
146
+ // ---------------------------------------------------------------------------
131
147
 
132
- const [editName, setEditName] = useState('')
133
- const [editUrl, setEditUrl] = useState('')
134
- const [editKey, setEditKey] = useState('')
135
- const [editModels, setEditModels] = useState('')
136
- const [editDefault, setEditDefault] = useState(false)
148
+ interface ChannelEditorProps {
149
+ t: (key: AudioGenKey) => string
150
+ writable: boolean
151
+ /** 'edit' | 'add-provider' | 'add-custom' */
152
+ mode: EditorMode
153
+ /** The channel being edited (edit mode). */
154
+ channel?: ChannelDraft
155
+ /** Whether the edited channel currently holds a stored secret. */
156
+ keyHeld: boolean
157
+ /** Presets available for the 提供方 select (add-provider mode). */
158
+ presets: PresetProviderView[]
159
+ /** Whether the edited channel is the default channel. */
160
+ initiallyDefault: boolean
161
+ onCancel: () => void
162
+ onSave: (channel: ChannelDraft, key: string | undefined, isDefault: boolean) => void
163
+ }
137
164
 
138
- const channels = state.channels.channels
139
- const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
165
+ function ChannelEditor(props: ChannelEditorProps): React.JSX.Element {
166
+ const { t, writable, mode, presets } = props
140
167
 
141
- useEffect(() => {
142
- if (editing === undefined) return
143
- setEditName(editing.name)
144
- setEditUrl(editing.apiUrl)
145
- setEditKey('')
146
- setEditModels(modelsToText(editing.models))
147
- setEditDefault(editing.id === state.channels.defaultChannelId)
148
- }, [editingId, editing?.id]) // eslint-disable-line react-hooks/exhaustive-deps
168
+ const initialPresetId = mode.kind === 'add-provider' ? (presets[0]?.id ?? '') : ''
169
+ const [presetId, setPresetId] = useState(initialPresetId)
170
+ const invited = useMemo(
171
+ () => (mode.kind === 'add-provider' ? presets.find(preset => preset.id === presetId) : undefined),
172
+ [mode, presets, presetId],
173
+ )
149
174
 
150
- if (!state.available) return null
175
+ // Draft fields. In add-provider mode the preset change re-seeds them.
176
+ const [name, setName] = useState(props.channel?.name ?? (invited?.name ?? ''))
177
+ const [url, setUrl] = useState(props.channel?.apiUrl ?? (invited?.apiUrl ?? ''))
178
+ const [models, setModels] = useState<ModelRow[]>(rowsOf(props.channel?.models ?? invited?.models ?? []))
179
+ const [key, setKey] = useState('')
180
+ const [keyAction, setKeyAction] = useState<'none' | 'clear'>('none')
181
+ const [isDefault, setIsDefault] = useState(props.initiallyDefault)
151
182
 
152
- const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
183
+ const [discovering, setDiscovering] = useState(false)
184
+ const [discoverError, setDiscoverError] = useState<string | null>(null)
185
+ const [candidates, setCandidates] = useState<DiscoveredAudioModel[] | null>(null)
186
+ const [picked, setPicked] = useState<ReadonlySet<string>>(new Set())
187
+ const [sourceNote, setSourceNote] = useState<string | null>(null)
188
+
189
+ const usePreset = (preset: PresetProviderView | undefined): void => {
190
+ setPresetId(preset?.id ?? '')
191
+ setName(preset?.name ?? '')
192
+ setUrl(preset?.apiUrl ?? '')
193
+ setModels(rowsOf(preset?.models ?? []))
194
+ setKey('')
195
+ setKeyAction('none')
196
+ }
153
197
 
154
- const saveEdit = (): void => {
155
- if (editingId === null) return
156
- const existing = channels.find(channel => channel.id === editingId)
157
- const models = textToModels(editModels)
158
- const updated: ChannelDraft = {
159
- id: editingId,
160
- preset: existing?.preset ?? '',
161
- name: editName.trim(),
162
- apiUrl: editUrl.trim(),
163
- models,
198
+ const effectiveKeyHeld = props.keyHeld && keyAction !== 'clear'
199
+
200
+ const save = (): void => {
201
+ const currentId = props.channel?.id ?? newChannelId()
202
+ const channel: ChannelDraft = {
203
+ id: currentId,
204
+ preset: mode.kind === 'edit' ? (props.channel?.preset ?? '') : (mode.kind === 'add-custom' ? '' : invited?.id ?? ''),
205
+ name: name.trim(),
206
+ apiUrl: url.trim(),
207
+ models: cleanModels(models),
164
208
  }
165
- const next = existing === undefined
166
- ? [...channels, updated]
167
- : channels.map(channel => channel.id === editingId ? updated : channel)
168
- props.channels.setChannels(next)
169
- if (editKey.trim() !== '') props.channels.setChannelKey(editingId, editKey.trim())
170
- if (editDefault) props.channels.setDefaultChannel(editingId)
171
- setEditingId(null)
209
+ const stagedKey = key.trim() !== '' ? key.trim() : (keyAction === 'clear' ? '' : undefined)
210
+ props.onSave(channel, stagedKey, isDefault)
172
211
  }
173
212
 
174
- const discoverModels = async (): Promise<void> => {
175
- if (editingId === null) return
213
+ const discoverable = url.trim() !== '' && (key.trim() !== '' || effectiveKeyHeld)
214
+
215
+ const discover = async (): Promise<void> => {
216
+ if (!discoverable) return
176
217
  setDiscovering(true)
177
218
  setDiscoverError(null)
219
+ setSourceNote(null)
178
220
  try {
179
- const existing = channels.find(channel => channel.id === editingId)
180
221
  const response = await fetch(MODEL_API.discover, {
181
222
  method: 'POST',
182
223
  headers: { 'content-type': 'application/json' },
183
224
  body: JSON.stringify({
184
- channelId: editingId,
185
- ...(editUrl.trim() !== '' ? { apiUrl: editUrl.trim() } : {}),
186
- ...(editKey.trim() !== '' ? { apiKey: editKey.trim() } : {}),
225
+ channelId: props.channel?.id ?? 'preview',
226
+ preset: mode.kind === 'add-provider' ? (invited?.id ?? '') : (props.channel?.preset ?? ''),
227
+ apiUrl: url.trim(),
228
+ ...(key.trim() !== '' ? { apiKey: key.trim() } : {}),
187
229
  }),
188
230
  })
189
231
  const body = await response.json() as { ok?: boolean; models?: DiscoveredAudioModel[]; message?: string; source?: string }
190
232
  if (body.ok !== true || body.models === undefined) {
191
233
  throw new Error(body.message ?? `HTTP ${response.status}`)
192
234
  }
193
- setEditModels(modelsToText([
194
- ...body.models,
195
- ...textToModels(editModels).filter(existingModel => !body.models!.some(model => model.id === existingModel.id)),
196
- ]))
197
- void existing
235
+ const known = new Set(models.map(model => model.id.trim()).filter(Boolean))
236
+ const found = body.models
237
+ .map(model => ({ ...model, id: model.id.trim() }))
238
+ .filter(model => model.id !== '')
239
+ if (found.length === 0) {
240
+ setDiscoverError(t('channel.fetchEmpty'))
241
+ return
242
+ }
243
+ setCandidates(found)
244
+ setPicked(new Set(found.filter(model => !known.has(model.id)).map(model => model.id)))
245
+ setSourceNote(body.source ?? null)
198
246
  } catch (error) {
199
247
  setDiscoverError(error instanceof Error ? error.message : String(error))
200
248
  } finally {
@@ -202,6 +250,362 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
202
250
  }
203
251
  }
204
252
 
253
+ const closeCandidates = (): void => {
254
+ setCandidates(null)
255
+ setPicked(new Set())
256
+ }
257
+
258
+ const adoptCandidates = (): void => {
259
+ if (candidates === null) return
260
+ const existing = [...models]
261
+ const byId = new Map(existing.map((model, index) => [model.id.trim(), { model, index }]))
262
+ for (const candidate of candidates) {
263
+ const id = candidate.id.trim()
264
+ if (id === '' || !picked.has(id)) continue
265
+ if (byId.has(id)) continue
266
+ byId.set(id, { model: { rowKey: `m-${Date.now()}-${existing.length}`, alias: candidate.alias, id, ...(candidate.category === undefined ? {} : { category: candidate.category }) }, index: existing.length })
267
+ existing.push(byId.get(id)!.model)
268
+ }
269
+ setModels(existing)
270
+ closeCandidates()
271
+ }
272
+
273
+ const patchModel = (index: number, next: Partial<ModelMapping>): void => {
274
+ setModels(models.map((model, at) => at === index ? { ...model, ...next } : model))
275
+ }
276
+
277
+ const presetPlaceholder = invited?.apiUrl ?? t('channel.apiUrlPlaceholder')
278
+
279
+ return (
280
+ <div className={css.channelEditor}>
281
+ {mode.kind === 'add-provider' ? (
282
+ <div className={css.field}>
283
+ <label className={css.label} htmlFor="audiogen-editor-provider">{t('channel.provider')}</label>
284
+ <select
285
+ id="audiogen-editor-provider"
286
+ className={css.select}
287
+ value={presetId}
288
+ disabled={!writable}
289
+ onChange={event => usePreset(presets.find(preset => preset.id === event.target.value))}
290
+ >
291
+ {presets.map(preset => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
292
+ </select>
293
+ {invited?.site !== undefined ? (
294
+ <p className={css.sectionHint}>{t('channel.site')}:<a className={css.link} href={invited.site} target="_blank" rel="noreferrer">{invited.site}</a></p>
295
+ ) : null}
296
+ </div>
297
+ ) : null}
298
+ {mode.kind === 'add-custom' ? (
299
+ <div className={css.field}>
300
+ <span className={css.label}>{t('channel.provider')}</span>
301
+ <p className={css.sectionHint}>{t('channel.providerCustom')} — {t('channel.providerCustomHint')}</p>
302
+ </div>
303
+ ) : null}
304
+ {mode.kind === 'edit' ? (
305
+ <div className={css.editorHeader}>
306
+ <span className={css.editorTitle}>{props.channel?.name.trim() !== '' ? props.channel?.name : t('channels.untitled')}</span>
307
+ <span className={css.editorTag}>{mode.kind === 'edit' ? t('channel.editTitle') : ''}</span>
308
+ </div>
309
+ ) : null}
310
+ <div className={css.field}>
311
+ <label className={css.label} htmlFor="audiogen-editor-name">{t('channel.name')}</label>
312
+ <input id="audiogen-editor-name" className={css.input} value={name} placeholder={t('channel.namePlaceholder')} disabled={!writable} onChange={event => setName(event.target.value)} />
313
+ </div>
314
+ <div className={css.field}>
315
+ <div className={css.head}>
316
+ <label className={css.label} htmlFor="audiogen-editor-key">{t('channel.apiKey')}</label>
317
+ {effectiveKeyHeld && key.trim() === '' ? (
318
+ <button type="button" className={css.reset} disabled={!writable} onClick={() => { setKeyAction('clear'); setKey('') }}>
319
+ {t('channel.clearKey')}
320
+ </button>
321
+ ) : null}
322
+ </div>
323
+ <input
324
+ id="audiogen-editor-key"
325
+ className={css.input}
326
+ type="password"
327
+ value={key}
328
+ autoComplete="off"
329
+ placeholder={effectiveKeyHeld ? '••••••••' : ''}
330
+ disabled={!writable}
331
+ onChange={event => { setKey(event.target.value); if (event.target.value !== '') setKeyAction('none') }}
332
+ />
333
+ <p className={css.sectionHint}>{effectiveKeyHeld ? t('channel.apiKeyStoredHint') : t('channel.apiKeyHint')}</p>
334
+ </div>
335
+ <details className={css.customSettings}>
336
+ <summary className={css.customSettingsSummary}>{t('channel.customSettings')}</summary>
337
+ <div className={css.customSettingsBody}>
338
+ <div className={css.field}>
339
+ <label className={css.label} htmlFor="audiogen-editor-url">{t('channel.apiUrl')}</label>
340
+ <input
341
+ id="audiogen-editor-url"
342
+ className={css.input}
343
+ type="text"
344
+ value={url}
345
+ placeholder={presetPlaceholder}
346
+ disabled={!writable}
347
+ onChange={event => setUrl(event.target.value)}
348
+ />
349
+ <p className={css.sectionHint}>{t('channel.apiUrlHint')}</p>
350
+ </div>
351
+ </div>
352
+ </details>
353
+ <ModelCatalog
354
+ t={t}
355
+ writable={writable}
356
+ models={models}
357
+ discoverable={discoverable}
358
+ discovering={discovering}
359
+ discoverError={discoverError}
360
+ candidates={candidates}
361
+ picked={picked}
362
+ sourceNote={sourceNote}
363
+ onPatchModel={patchModel}
364
+ onRemoveModel={index => setModels(models.filter((_model, at) => at !== index))}
365
+ onAddModel={() => setModels([...models, { rowKey: `m-${Date.now()}-${models.length}`, alias: '', id: '' }])}
366
+ onDiscover={() => void discover()}
367
+ onTogglePicked={id => {
368
+ setPicked(current => {
369
+ const next = new Set(current)
370
+ if (!next.delete(id)) next.add(id)
371
+ return next
372
+ })
373
+ }}
374
+ onToggleAllPicked={() => {
375
+ setPicked(current => (candidates !== null && candidates.length > 0 && candidates.every(candidate => current.has(candidate.id)))
376
+ ? new Set()
377
+ : new Set((candidates ?? []).map(candidate => candidate.id)))
378
+ }}
379
+ onAdopt={() => adoptCandidates()}
380
+ onCloseCandidates={closeCandidates}
381
+ />
382
+ <label className={css.field}>
383
+ <span className={css.label}>
384
+ <input type="checkbox" checked={isDefault} disabled={!writable} onChange={event => setIsDefault(event.target.checked)} /> {t('channel.default')}
385
+ </span>
386
+ </label>
387
+ <div className={css.editorFooter}>
388
+ <button type="button" className={css.discard} onClick={props.onCancel}>{t('channel.cancel')}</button>
389
+ <button type="button" className={css.save} onClick={save}>{t('channel.save')}</button>
390
+ </div>
391
+ </div>
392
+ )
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Model catalog (目录) inside the channel editor
397
+ // ---------------------------------------------------------------------------
398
+
399
+ interface ModelCatalogProps {
400
+ t: (key: AudioGenKey) => string
401
+ writable: boolean
402
+ models: ModelRow[]
403
+ discoverable: boolean
404
+ discovering: boolean
405
+ discoverError: string | null
406
+ candidates: DiscoveredAudioModel[] | null
407
+ picked: ReadonlySet<string>
408
+ sourceNote: string | null
409
+ onPatchModel: (index: number, next: Partial<ModelMapping>) => void
410
+ onRemoveModel: (index: number) => void
411
+ onAddModel: () => void
412
+ onDiscover: () => void
413
+ onTogglePicked: (id: string) => void
414
+ onToggleAllPicked: () => void
415
+ onAdopt: () => void
416
+ onCloseCandidates: () => void
417
+ }
418
+
419
+ function ModelCatalog(props: ModelCatalogProps): React.JSX.Element {
420
+ const { t, writable, models, discoverable, discovering, candidates, picked } = props
421
+ const allPicked = candidates !== null && candidates.length > 0 && candidates.every(candidate => picked.has(candidate.id))
422
+ return (
423
+ <section className={css.modelCatalog} aria-label={t('channel.modelsTitle')}>
424
+ <div className={css.modelCatalogHead}>
425
+ <div>
426
+ <span className={css.modelCatalogTitle}>{t('channel.modelsTitle')}</span>
427
+ <span className={css.modelCatalogMeta}>
428
+ {models.length > 0 ? tpl(t, 'channel.modelsCount', { n: models.length }) : t('channel.modelsNone')}
429
+ </span>
430
+ </div>
431
+ <button
432
+ type="button"
433
+ className={css.linkButton}
434
+ disabled={!writable || discovering || !discoverable}
435
+ title={discoverable ? undefined : t('channel.discoverNeedsUrlKey')}
436
+ onClick={props.onDiscover}
437
+ >
438
+ {discovering ? t('channel.fetchingModels') : t('channel.fetchModels')}
439
+ </button>
440
+ </div>
441
+ {models.length === 0 ? <p className={css.modelEmpty}>{t('channel.modelsEmpty')}</p> : (
442
+ <ul className={css.modelRows}>
443
+ {models.map((model, index) => (
444
+ <li key={`${model.rowKey}-${index}`} className={css.modelRow}>
445
+ <input
446
+ className={`${css.input} ${css.modelInput}`}
447
+ type="text"
448
+ value={model.alias}
449
+ placeholder={t('channel.modelAlias')}
450
+ aria-label={`${t('channel.modelAlias')} ${index + 1}`}
451
+ disabled={!writable}
452
+ onChange={event => props.onPatchModel(index, { alias: event.target.value })}
453
+ />
454
+ <span className={css.modelArrow} aria-hidden="true">→</span>
455
+ <input
456
+ className={`${css.input} ${css.modelInput}`}
457
+ type="text"
458
+ value={model.id}
459
+ placeholder={t('channel.modelId')}
460
+ aria-label={`${t('channel.modelId')} ${index + 1}`}
461
+ disabled={!writable}
462
+ onChange={event => props.onPatchModel(index, { id: event.target.value })}
463
+ />
464
+ <select
465
+ className={`${css.select} ${css.modelCategorySelect}`}
466
+ value={model.category ?? ''}
467
+ aria-label={`${t('channel.modelCategory')} ${index + 1}`}
468
+ disabled={!writable}
469
+ onChange={event => {
470
+ const value = event.target.value as AudioModelCategory | ''
471
+ props.onPatchModel(index, value === '' ? { category: undefined } : { category: value })
472
+ }}
473
+ >
474
+ {MODEL_CATEGORIES.map(category => (
475
+ <option key={category ?? 'auto'} value={category ?? ''}>{category === undefined ? t('channel.category.auto') : t(`channel.category.${category}`)}</option>
476
+ ))}
477
+ </select>
478
+ <button
479
+ type="button"
480
+ className={css.modelRowRemove}
481
+ aria-label={t('channel.removeModel')}
482
+ disabled={!writable}
483
+ onClick={() => props.onRemoveModel(index)}
484
+ >
485
+ ×
486
+ </button>
487
+ </li>
488
+ ))}
489
+ </ul>
490
+ )}
491
+ <div className={css.modelCatalogTools}>
492
+ <button type="button" className={css.addModel} disabled={!writable} onClick={props.onAddModel}>
493
+ {t('channel.addModel')}
494
+ </button>
495
+ </div>
496
+ {props.sourceNote !== null && props.candidates !== null ? (
497
+ <p className={css.detectOk}>{tpl(t, 'channel.discoverSource', { source: props.sourceNote })}</p>
498
+ ) : null}
499
+ {props.discoverError !== null ? <p className={css.failed}>{props.discoverError}</p> : null}
500
+ {candidates === null ? null : (
501
+ <div className={css.candidatePanel}>
502
+ <div className={css.candidateHead}>
503
+ <span className={css.candidateTitle}>{tpl(t, 'channel.candidates', { n: candidates.length })}</span>
504
+ <button type="button" className={css.linkButton} onClick={props.onToggleAllPicked}>
505
+ {allPicked ? t('channel.clearSelection') : t('channel.selectAll')}
506
+ </button>
507
+ </div>
508
+ <ul className={css.candidateList}>
509
+ {candidates.map(candidate => (
510
+ <li key={candidate.id} className={css.candidate}>
511
+ <label className={css.candidateLabel}>
512
+ <input
513
+ type="checkbox"
514
+ checked={picked.has(candidate.id)}
515
+ disabled={models.some(model => model.id.trim() === candidate.id)}
516
+ onChange={() => props.onTogglePicked(candidate.id)}
517
+ />
518
+ <span className={css.candidateId}>{candidate.alias === candidate.id ? candidate.id : `${candidate.alias}(${candidate.id})`}</span>
519
+ {candidate.category !== undefined ? <span className={css.modelBadge}>{t(`channel.category.${candidate.category}`)}</span> : null}
520
+ </label>
521
+ </li>
522
+ ))}
523
+ </ul>
524
+ <div className={css.candidateActions}>
525
+ <button type="button" className={css.discard} onClick={props.onCloseCandidates}>{t('channel.cancel')}</button>
526
+ <button type="button" className={css.save} onClick={props.onAdopt}>
527
+ {tpl(t, 'channel.adoptSelected', { n: Array.from(picked).length })}
528
+ </button>
529
+ </div>
530
+ </div>
531
+ )}
532
+ </section>
533
+ )
534
+ }
535
+
536
+ // ---------------------------------------------------------------------------
537
+ // Card
538
+ // ---------------------------------------------------------------------------
539
+
540
+ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
541
+ const { t } = props
542
+ const state = props.useAudioGenSettingsCard(snapshot => snapshot)
543
+ const [open, setOpen] = useState(false)
544
+ const [editor, setEditor] = useState<EditorMode | null>(null)
545
+ const [presets, setPresets] = useState<PresetProviderView[]>([])
546
+ const [presetLoading, setPresetLoading] = useState(false)
547
+ const [presetError, setPresetError] = useState<string | null>(null)
548
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
549
+
550
+ const channels = state.channels.channels
551
+ const editingChannel = editor !== null && editor.kind === 'edit'
552
+ ? channels.find(channel => channel.id === editor.channelId)
553
+ : undefined
554
+
555
+ useEffect(() => {
556
+ if (editor?.kind === 'add-provider' && presets.length === 0 && presetError === null && !presetLoading) {
557
+ setPresetLoading(true)
558
+ setPresetError(null)
559
+ void fetch(PRESETS_API, { method: 'POST' })
560
+ .then(async response => {
561
+ const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
562
+ if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
563
+ setPresets(body.presets)
564
+ })
565
+ .catch(error => setPresetError(error instanceof Error ? error.message : String(error)))
566
+ .finally(() => setPresetLoading(false))
567
+ }
568
+ }, [editor, presets.length, presetError, presetLoading])
569
+
570
+ if (!state.available) return null
571
+
572
+ const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
573
+
574
+ const closeEditor = (): void => { setEditor(null) }
575
+
576
+ const saveEditor = (channel: ChannelDraft, key: string | undefined, isDefault: boolean): void => {
577
+ const exists = channels.some(candidate => candidate.id === channel.id)
578
+ const next = exists
579
+ ? channels.map(candidate => candidate.id === channel.id ? channel : candidate)
580
+ : [...channels, channel]
581
+ props.channels.setChannels(next)
582
+ // key === undefined: no staged change; key === '': clear; otherwise set.
583
+ if (key !== undefined) props.channels.setChannelKey(channel.id, key)
584
+ if (isDefault) props.channels.setDefaultChannel(channel.id)
585
+ closeEditor()
586
+ }
587
+
588
+ const removeChannel = (channel: ChannelDraft): void => {
589
+ const isDefault = channel.id === state.channels.defaultChannelId
590
+ const next = channels.filter(candidate => candidate.id !== channel.id)
591
+ props.channels.setChannels(next)
592
+ if (isDefault && next.length > 0) {
593
+ props.channels.setDefaultChannel(next[0]!.id)
594
+ }
595
+ setConfirmDeleteId(null)
596
+ if (editor?.kind === 'edit' && editor.channelId === channel.id) closeEditor()
597
+ }
598
+
599
+ const openAddProvider = (): void => {
600
+ setPresetError(null)
601
+ setEditor({ kind: 'add-provider' })
602
+ }
603
+
604
+ const openAddCustom = (): void => {
605
+ setPresetError(null)
606
+ setEditor({ kind: 'add-custom' })
607
+ }
608
+
205
609
  return (
206
610
  <li className={css.card}>
207
611
  <button
@@ -238,15 +642,7 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
238
642
  return (
239
643
  <li key={channel.id} className={css.channelRow} data-action>
240
644
  <span className={css.deleteConfirmText}>{t('channels.confirm')}: {channel.name || t('channels.untitled')}</span>
241
- <button type="button" className={css.channelDanger} disabled={!state.writable} onClick={() => {
242
- props.channels.setChannels(channels.filter(candidate => candidate.id !== channel.id))
243
- if (isDefault && channels.length > 1) {
244
- const next = channels.find(candidate => candidate.id !== channel.id)
245
- if (next !== undefined) props.channels.setDefaultChannel(next.id)
246
- }
247
- setConfirmDeleteId(null)
248
- if (editingId === channel.id) setEditingId(null)
249
- }}>{t('channels.confirm')}</button>
645
+ <button type="button" className={css.channelDanger} disabled={!state.writable} onClick={() => removeChannel(channel)}>{t('channels.confirm')}</button>
250
646
  <button type="button" className={css.channelAction} onClick={() => setConfirmDeleteId(null)}>{t('channels.cancel')}</button>
251
647
  </li>
252
648
  )
@@ -254,7 +650,7 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
254
650
  return (
255
651
  <li key={channel.id} className={css.channelRow}>
256
652
  <span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
257
- <button type="button" className={css.channelMain} disabled={!state.writable} onClick={() => setEditingId(channel.id)}>
653
+ <button type="button" className={css.channelMain} disabled={!state.writable} onClick={() => { setEditor({ kind: 'edit', channelId: channel.id }) }}>
258
654
  <span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
259
655
  <span className={css.channelMeta}>
260
656
  <span className={css.channelHost}>{channel.apiUrl || '(no url)'}</span>
@@ -265,96 +661,44 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
265
661
  </span>
266
662
  </span>
267
663
  </button>
268
- <button type="button" className={css.channelAction} onClick={() => setEditingId(channel.id)}>{t('channels.edit')}</button>
664
+ <button type="button" className={css.channelAction} onClick={() => { setEditor({ kind: 'edit', channelId: channel.id }) }}>{t('channels.edit')}</button>
269
665
  <button type="button" className={css.channelAction} data-danger onClick={() => setConfirmDeleteId(channel.id)}>{t('channels.delete')}</button>
270
666
  </li>
271
667
  )
272
668
  })}
273
669
  </ul>
274
670
  )}
275
- {presetPickerOpen ? (
276
- <div className={css.channelControls}>
277
- <p className={css.sectionHint}>{t('presets.title')}</p>
278
- {presetError !== null ? <p className={css.failed}>{presetError}</p> : null}
279
- <div className={css.channelAddRow}>
280
- <button type="button" className={css.channelAdd} onClick={() => {
281
- setPresets([])
282
- setPresetError(null)
283
- void fetch(PRESETS_API, { method: 'POST' })
284
- .then(async response => {
285
- const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
286
- if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
287
- setPresets(body.presets)
288
- })
289
- .catch(error => setPresetError(error instanceof Error ? error.message : String(error)))
290
- }}>{t('channels.addProvider')}</button>
291
- <button type="button" className={css.channelAdd} onClick={() => {
292
- const draft = newChannelDraft(undefined, channels)
293
- props.channels.setChannels([...channels, draft])
294
- setPresetPickerOpen(false)
295
- setEditingId(draft.id)
296
- }}>{t('channels.addCustom')}</button>
297
- <button type="button" className={css.channelAction} onClick={() => setPresetPickerOpen(false)}>×</button>
671
+ {presetError !== null ? <p className={css.failed}>{presetError}</p> : null}
672
+ <div className={css.channelAddRow}>
673
+ <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={openAddProvider}>
674
+ {presetLoading ? t('channels.addProviderLoading') : t('channels.addProvider')}
675
+ </button>
676
+ <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={openAddCustom}>
677
+ {t('channels.addCustom')}
678
+ </button>
679
+ </div>
680
+ {editor !== null ? (
681
+ editor.kind === 'add-provider' && presets.length === 0 && presetError === null ? (
682
+ <p className={css.sectionHint}>{presetLoading ? t('channels.addProviderLoading') : t('channels.addProviderFailed')}</p>
683
+ ) : (
684
+ <div className={css.editorWrap}>
685
+ <ChannelEditor
686
+ key={editor.kind === 'edit' ? `edit-${editor.channelId}` : editor.kind}
687
+ t={t}
688
+ writable={state.writable}
689
+ mode={editor}
690
+ channel={editingChannel}
691
+ keyHeld={editor.kind === 'edit' ? state.channels.keySet[editor.channelId] === true : false}
692
+ presets={presets}
693
+ initiallyDefault={editor.kind === 'edit' ? editor.channelId === state.channels.defaultChannelId : channels.length === 0}
694
+ onCancel={closeEditor}
695
+ onSave={saveEditor}
696
+ />
298
697
  </div>
299
- {presets.map(preset => (
300
- <button key={preset.id} type="button" className={css.channelAdd} onClick={() => {
301
- const draft = newChannelDraft(preset, channels)
302
- props.channels.setChannels([...channels, draft])
303
- setPresetPickerOpen(false)
304
- setEditingId(draft.id)
305
- }}>
306
- {preset.name} — {preset.hint}
307
- </button>
308
- ))}
309
- </div>
310
- ) : (
311
- <div className={css.channelAddRow}>
312
- <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>{t('channels.addProvider')}</button>
313
- <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={() => {
314
- const draft = newChannelDraft(undefined, channels)
315
- props.channels.setChannels([...channels, draft])
316
- setEditingId(draft.id)
317
- }}>{t('channels.addCustom')}</button>
318
- </div>
319
- )}
698
+ )
699
+ ) : null}
320
700
  </section>
321
701
 
322
- {editing !== undefined ? (
323
- <div className={css.body}>
324
- <div className={css.field}>
325
- <label className={css.label} htmlFor={`audiogen-name-${editing.id}`}>{t('channel.name')}</label>
326
- <input id={`audiogen-name-${editing.id}`} className={css.input} value={editName} onChange={event => setEditName(event.target.value)} />
327
- </div>
328
- <div className={css.field}>
329
- <label className={css.label} htmlFor={`audiogen-url-${editing.id}`}>{t('channel.apiUrl')}</label>
330
- <input id={`audiogen-url-${editing.id}`} className={css.input} value={editUrl} onChange={event => setEditUrl(event.target.value)} placeholder="https://…" />
331
- </div>
332
- <div className={css.field}>
333
- <label className={css.label} htmlFor={`audiogen-key-${editing.id}`}>{t('channel.apiKey')}</label>
334
- <input id={`audiogen-key-${editing.id}`} className={css.input} type="password" value={editKey} onChange={event => setEditKey(event.target.value)} placeholder={state.channels.keySet[editing.id] ? '••••••' : ''} />
335
- <p className={css.sectionHint}>{t('channel.apiKeyHint')}</p>
336
- </div>
337
- <div className={css.field}>
338
- <label className={css.label} htmlFor={`audiogen-models-${editing.id}`}>{t('channel.models')}</label>
339
- <textarea id={`audiogen-models-${editing.id}`} className={css.textarea} value={editModels} onChange={event => setEditModels(event.target.value)} />
340
- <p className={css.sectionHint}>{t('channel.modelsHint')}</p>
341
- <div className={css.channelAddRow}>
342
- <button type="button" className={css.channelAdd} disabled={discovering || !state.writable} onClick={() => void discoverModels()}>
343
- {discovering ? '获取中…' : '获取可用模型'}
344
- </button>
345
- </div>
346
- {discoverError !== null ? <p className={css.failed}>{discoverError}</p> : null}
347
- </div>
348
- <label className={css.label}>
349
- <input type="checkbox" checked={editDefault} onChange={event => setEditDefault(event.target.checked)} /> {t('channel.default')}
350
- </label>
351
- <div className={css.footer}>
352
- <button type="button" className={css.discard} onClick={() => setEditingId(null)}>{t('channel.cancel')}</button>
353
- <button type="button" className={css.save} onClick={saveEdit}>{t('channel.save')}</button>
354
- </div>
355
- </div>
356
- ) : null}
357
-
358
702
  <div className={css.field}>
359
703
  <label className={css.label}>
360
704
  <input type="checkbox" checked={state.enabled.text === 'true' || state.enabled.text === ''} disabled={!state.writable} onChange={event => props.edit('enabled', String(event.target.checked))} /> {t('settings.enabled')}