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.
- package/lib/client.js +1101 -573
- package/lib/client.js.map +1 -1
- package/lib/index.js +528 -125
- package/package.json +1 -1
- package/skills/tts/SKILL.md +42 -4
- package/src/agent-audio-tools.ts +94 -7
- package/src/audio-engine.ts +215 -29
- package/src/audio-models.ts +132 -24
- package/src/audio-presets.ts +27 -33
- package/src/audio-store.ts +2 -0
- package/src/client/AudioGenPanel.tsx +140 -36
- package/src/client/SettingsCard.tsx +520 -176
- package/src/client/audio-panel.module.css +36 -0
- package/src/client/locales.ts +88 -18
- package/src/client/settings-card.module.css +424 -607
- package/src/protocol.ts +44 -2
- package/src/routes.ts +49 -4
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
105
|
-
return
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
const
|
|
165
|
+
function ChannelEditor(props: ChannelEditorProps): React.JSX.Element {
|
|
166
|
+
const { t, writable, mode, presets } = props
|
|
140
167
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
id:
|
|
160
|
-
preset:
|
|
161
|
-
name:
|
|
162
|
-
apiUrl:
|
|
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
|
|
166
|
-
|
|
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
|
|
175
|
-
|
|
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:
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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={() =>
|
|
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={() =>
|
|
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
|
-
{
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
{
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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')}
|