dsh-audiogen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The AI 音频 panel: a compact audio-generation studio.
3
+ */
4
+
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import type { AudiogenApi } from './api.ts'
7
+ import type { AudiogenScope } from './settings-scope.ts'
8
+ import { audioModelOptions } from './settings-scope.ts'
9
+ import { tt } from './helpers.ts'
10
+ import { GENERATE_API, HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
11
+ import css from './audio-panel.module.css'
12
+
13
+ function useConfig(scope: AudiogenScope) {
14
+ const [value, setValue] = useState(scope.getSnapshot().value)
15
+ useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
16
+ return value
17
+ }
18
+
19
+ function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
20
+ const [entries, setEntries] = useState<HistoryEntry[]>([])
21
+ const reload = (): void => {
22
+ void fetch(HISTORY_API.list, { method: 'POST' })
23
+ .then(async response => {
24
+ const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
25
+ if (body.ok === true) setEntries(body.history ?? [])
26
+ })
27
+ .catch(() => { /* history is best-effort */ })
28
+ }
29
+ useEffect(() => { reload() }, [])
30
+ const clear = (): void => {
31
+ void fetch(HISTORY_API.clear, { method: 'POST' })
32
+ .then(() => reload())
33
+ .catch(() => { /* best-effort */ })
34
+ }
35
+ return { entries, reload, clear }
36
+ }
37
+
38
+ function dataUrlOf(audio: GeneratedAudio): string {
39
+ return `data:${audio.mime};base64,${audio.b64}`
40
+ }
41
+
42
+ export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope }) {
43
+ const { api, scope } = props
44
+ const config = useConfig(scope)
45
+ const enabled = config?.enabled ?? true
46
+ const modelOptions = audioModelOptions(config)
47
+ const channels = config?.channels ?? []
48
+ const connected = enabled && channels.some(channel => {
49
+ const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
50
+ return channel.apiUrl.trim() !== '' && keyHeld && channel.models.length > 0
51
+ })
52
+
53
+ const [mode, setMode] = useState<AudioMode>('tts')
54
+ const [prompt, setPrompt] = useState('')
55
+ const [model, setModel] = useState('')
56
+ const [voice, setVoice] = useState('')
57
+ const [speed, setSpeed] = useState('')
58
+ const [duration, setDuration] = useState('')
59
+ const [format, setFormat] = useState('mp3')
60
+ const [loading, setLoading] = useState(false)
61
+ const [error, setError] = useState<string | null>(null)
62
+ const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
63
+ const { entries, reload, clear } = useHistory()
64
+
65
+ useEffect(() => {
66
+ if (modelOptions.models.length > 0 && !modelOptions.models.includes(model)) {
67
+ setModel(modelOptions.models[0]!)
68
+ }
69
+ }, [modelOptions.models, model])
70
+
71
+ const submit = async (): Promise<void> => {
72
+ if (prompt.trim() === '') {
73
+ setError(tt('prompt.required'))
74
+ return
75
+ }
76
+ setLoading(true)
77
+ setError(null)
78
+ try {
79
+ const response = await api.generate({
80
+ mode,
81
+ model: (model || modelOptions.models[0]) ?? '',
82
+ prompt: prompt.trim(),
83
+ ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
84
+ ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
85
+ ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
86
+ ...(format.trim() !== '' ? { format: format.trim() } : {}),
87
+ })
88
+ if (!response.ok) {
89
+ setError(response.message ?? '生成失败')
90
+ return
91
+ }
92
+ setOutputs(response.outputs ?? [])
93
+ reload()
94
+ } catch (err) {
95
+ setError(err instanceof Error ? err.message : String(err))
96
+ } finally {
97
+ setLoading(false)
98
+ }
99
+ }
100
+
101
+ const modeLabel = useMemo(() => {
102
+ if (mode === 'tts') return tt('mode.tts')
103
+ if (mode === 'music') return tt('mode.music')
104
+ return tt('mode.sfx')
105
+ }, [mode])
106
+
107
+ return (
108
+ <div className={css.panel}>
109
+ <header className={css.header}>
110
+ <h2 className={css.title}>{tt('panel.title')}</h2>
111
+ <span className={css.hint}>{modeLabel}</span>
112
+ </header>
113
+ <div className={css.layout}>
114
+ <div className={css.form}>
115
+ <div className={css.modeRow}>
116
+ {(['tts', 'music', 'sfx'] as const).map(item => (
117
+ <button
118
+ key={item}
119
+ type="button"
120
+ className={css.modeButton}
121
+ data-active={mode === item ? 'true' : 'false'}
122
+ onClick={() => setMode(item)}
123
+ >
124
+ {item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : tt('mode.sfx')}
125
+ </button>
126
+ ))}
127
+ </div>
128
+ <label className={css.label}>
129
+ <span>{mode === 'tts' ? '文本' : '提示词'}</span>
130
+ <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
131
+ </label>
132
+ <label className={css.label}>
133
+ <span>{tt('model.label')}</span>
134
+ <select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
135
+ {modelOptions.models.length === 0 ? <option value="">(请在设置中添加)</option> : null}
136
+ {modelOptions.models.map(item => <option key={item} value={item}>{item}</option>)}
137
+ </select>
138
+ </label>
139
+ {mode === 'tts' ? (
140
+ <label className={css.label}>
141
+ <span>{tt('voice.label')}</span>
142
+ <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder="alloy / 自定义音色" />
143
+ </label>
144
+ ) : null}
145
+ <label className={css.label}>
146
+ <span>{tt('speed.label')}</span>
147
+ <input className={css.input} type="number" step="0.1" min="0.5" max="2" value={speed} onChange={event => setSpeed(event.target.value)} placeholder="1.0" />
148
+ </label>
149
+ <label className={css.label}>
150
+ <span>{tt('duration.label')}</span>
151
+ <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
152
+ </label>
153
+ <label className={css.label}>
154
+ <span>{tt('format.label')}</span>
155
+ <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
156
+ <option value="mp3">mp3</option>
157
+ <option value="wav">wav</option>
158
+ <option value="flac">flac</option>
159
+ <option value="ogg">ogg</option>
160
+ <option value="pcm">pcm</option>
161
+ </select>
162
+ </label>
163
+ {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
164
+ <button type="button" className={css.generate} disabled={loading || !connected} onClick={() => void submit()}>
165
+ {loading ? tt('generating') : tt('generate')}
166
+ </button>
167
+ </div>
168
+ <div className={css.result}>
169
+ {error !== null ? <p className={css.error}>{error}</p> : null}
170
+ {outputs.length === 0 ? <p className={css.empty}>{tt('result.empty')}</p> : (
171
+ <>
172
+ <p className={css.hint}>{tt('result.done', { count: outputs.length })}</p>
173
+ <div className={css.audioList}>
174
+ {outputs.map((audio, index) => (
175
+ <div className={css.audioCard} key={audio.id}>
176
+ <audio className={css.audio} controls preload="metadata" src={dataUrlOf(audio)} />
177
+ <a className={css.download} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>下载</a>
178
+ </div>
179
+ ))}
180
+ </div>
181
+ </>
182
+ )}
183
+ </div>
184
+ <aside className={css.history}>
185
+ <div className={css.historyHeader}>
186
+ <strong className={css.historyTitle}>{tt('history.title')}</strong>
187
+ <button type="button" onClick={clear} style={{ border: 0, background: 'none', cursor: 'pointer', color: 'inherit', fontSize: 12 }}>清空</button>
188
+ </div>
189
+ {entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
190
+ <div>
191
+ {entries.map(entry => (
192
+ <div className={css.historyItem} key={entry.id}>
193
+ <div className={css.historyPrompt}>{entry.prompt}</div>
194
+ <div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
195
+ {entry.audio.map((audio, index) => (
196
+ <audio key={index} className={css.historyAudio} controls preload="none" src={audio.url} />
197
+ ))}
198
+ </div>
199
+ ))}
200
+ </div>
201
+ )}
202
+ </aside>
203
+ </div>
204
+ </div>
205
+ )
206
+ }
@@ -0,0 +1,337 @@
1
+ /**
2
+ * The dsh-audiogen settings card.
3
+ *
4
+ * Registers into the official `settings.plugin.item` slot. It manages a list
5
+ * of audio channels (each with API URL, per-channel secret, and model/voice
6
+ * catalog), plus master switches.
7
+ */
8
+
9
+ import { useEffect, useState } from 'react'
10
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import { CardForm, booleanField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
+ import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
14
+ import type { AudiogenScope } from './settings-scope.ts'
15
+ import { PRESETS_API, type ModelMapping, type PresetProviderView } from '../protocol.ts'
16
+ import type { AudioGenKey } from './locales.ts'
17
+ import css from './settings-card.module.css'
18
+
19
+ export interface AudioGenSettings {
20
+ enabled?: boolean
21
+ announceToAgent?: boolean
22
+ allowAgentAudioGeneration?: boolean
23
+ defaultModel?: string
24
+ }
25
+
26
+ export interface AudioGenSettingsCardState extends CardShell {
27
+ channels: ChannelsFormState
28
+ enabled: CardFieldState
29
+ announceToAgent: CardFieldState
30
+ allowAgentAudioGeneration: CardFieldState
31
+ defaultModel: CardFieldState
32
+ }
33
+
34
+ export interface AudioGenSettingsCardFace extends CardActions {
35
+ channels: ChannelsFormActions
36
+ hooks: {
37
+ audioGenSettingsCard: SnapshotStore<AudioGenSettingsCardState>
38
+ }
39
+ }
40
+
41
+ export class AudioGenSettingsCardController {
42
+ private readonly form: CardForm<AudioGenSettings>
43
+ private readonly channelsForm: ChannelsForm
44
+
45
+ constructor(private readonly scope: AudiogenScope) {
46
+ this.form = new CardForm(scope, [
47
+ booleanField('enabled'),
48
+ booleanField('announceToAgent'),
49
+ booleanField('allowAgentAudioGeneration'),
50
+ textField('defaultModel'),
51
+ ])
52
+ this.channelsForm = new ChannelsForm(scope)
53
+ }
54
+
55
+ private projection(): AudioGenSettingsCardState {
56
+ const shell = this.form.shell()
57
+ return {
58
+ ...shell,
59
+ dirty: shell.dirty || this.channelsForm.snapshot().dirty,
60
+ channels: this.channelsForm.snapshot(),
61
+ enabled: this.form.field('enabled'),
62
+ announceToAgent: this.form.field('announceToAgent'),
63
+ allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
64
+ defaultModel: this.form.field('defaultModel'),
65
+ }
66
+ }
67
+
68
+ inject(): AudioGenSettingsCardFace {
69
+ const cardStore = this.form.bind(() => this.projection())
70
+ this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
71
+ return {
72
+ hooks: {
73
+ audioGenSettingsCard: cardStore,
74
+ },
75
+ channels: this.channelsForm.actions(),
76
+ ...this.form.actions(),
77
+ }
78
+ }
79
+ }
80
+
81
+ export type AudioGenSettingsCardProps =
82
+ PropsRuntime<'settings.plugin.item'>
83
+ & PropsLocale<'dsh-audiogen'>
84
+ & InjectFace<AudioGenSettingsCardFace>
85
+
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 })),
97
+ }
98
+ }
99
+
100
+ function modelsToText(models: ModelMapping[]): string {
101
+ return models.map(model => `${model.alias}=${model.id}`).join('\n')
102
+ }
103
+
104
+ function textToModels(text: string): ModelMapping[] {
105
+ return text.split(/\n|,/).map(line => line.trim()).filter(Boolean).map(line => {
106
+ const eq = line.indexOf('=')
107
+ if (eq < 0) return { alias: line, id: line }
108
+ return { alias: line.slice(0, eq).trim(), id: line.slice(eq + 1).trim() }
109
+ }).filter(model => model.alias !== '')
110
+ }
111
+
112
+ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
113
+ const { t } = props
114
+ const state = props.useAudioGenSettingsCard(snapshot => snapshot)
115
+ const [open, setOpen] = useState(false)
116
+ const [editingId, setEditingId] = useState<string | null>(null)
117
+ const [presetPickerOpen, setPresetPickerOpen] = useState(false)
118
+ const [presets, setPresets] = useState<PresetProviderView[]>([])
119
+ const [presetError, setPresetError] = useState<string | null>(null)
120
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
121
+
122
+ const [editName, setEditName] = useState('')
123
+ const [editUrl, setEditUrl] = useState('')
124
+ const [editKey, setEditKey] = useState('')
125
+ const [editModels, setEditModels] = useState('')
126
+ const [editDefault, setEditDefault] = useState(false)
127
+
128
+ const channels = state.channels.channels
129
+ const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
130
+
131
+ useEffect(() => {
132
+ if (editing === undefined) return
133
+ setEditName(editing.name)
134
+ setEditUrl(editing.apiUrl)
135
+ setEditKey('')
136
+ setEditModels(modelsToText(editing.models))
137
+ setEditDefault(editing.id === state.channels.defaultChannelId)
138
+ }, [editingId, editing?.id]) // eslint-disable-line react-hooks/exhaustive-deps
139
+
140
+ if (!state.available) return null
141
+
142
+ const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
143
+
144
+ const saveEdit = (): void => {
145
+ if (editingId === null) return
146
+ const existing = channels.find(channel => channel.id === editingId)
147
+ const models = textToModels(editModels)
148
+ const updated: ChannelDraft = {
149
+ id: editingId,
150
+ preset: existing?.preset ?? '',
151
+ name: editName.trim(),
152
+ apiUrl: editUrl.trim(),
153
+ models,
154
+ }
155
+ const next = existing === undefined
156
+ ? [...channels, updated]
157
+ : channels.map(channel => channel.id === editingId ? updated : channel)
158
+ props.channels.setChannels(next)
159
+ if (editKey.trim() !== '') props.channels.setChannelKey(editingId, editKey.trim())
160
+ if (editDefault) props.channels.setDefaultChannel(editingId)
161
+ setEditingId(null)
162
+ }
163
+
164
+ return (
165
+ <li className={css.card}>
166
+ <button
167
+ type="button"
168
+ className={css.header}
169
+ aria-expanded={open}
170
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${t('settings.title')}`}
171
+ onClick={() => { setOpen(!open) }}
172
+ >
173
+ <span className={css.headText}>
174
+ <span className={css.name}>{t('settings.title')}</span>
175
+ <span className={css.description}>{t('settings.description')}</span>
176
+ </span>
177
+ {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
178
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
179
+ </button>
180
+ {!open ? null : (
181
+ <div className={css.body}>
182
+ {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
183
+ <section className={css.channelSection} aria-label={t('channels.title')}>
184
+ <div className={css.sectionHeader}>
185
+ <div>
186
+ <h3 className={css.sectionTitle}>{t('channels.title')}</h3>
187
+ <p className={css.sectionHint}>{t('channels.hint')}</p>
188
+ </div>
189
+ </div>
190
+ {channels.length === 0 ? <p className={css.channelEmpty}>{t('channels.empty')}</p> : (
191
+ <ul className={css.channelList}>
192
+ {channels.map(channel => {
193
+ const keyHeld = state.channels.keySet[channel.id] === true
194
+ const ready = keyHeld && channel.models.length > 0
195
+ const isDefault = channel.id === state.channels.defaultChannelId
196
+ if (confirmDeleteId === channel.id) {
197
+ return (
198
+ <li key={channel.id} className={css.channelRow} data-action>
199
+ <span className={css.deleteConfirmText}>{t('channels.confirm')}: {channel.name || t('channels.untitled')}</span>
200
+ <button type="button" className={css.channelDanger} disabled={!state.writable} onClick={() => {
201
+ props.channels.setChannels(channels.filter(candidate => candidate.id !== channel.id))
202
+ if (isDefault && channels.length > 1) {
203
+ const next = channels.find(candidate => candidate.id !== channel.id)
204
+ if (next !== undefined) props.channels.setDefaultChannel(next.id)
205
+ }
206
+ setConfirmDeleteId(null)
207
+ if (editingId === channel.id) setEditingId(null)
208
+ }}>{t('channels.confirm')}</button>
209
+ <button type="button" className={css.channelAction} onClick={() => setConfirmDeleteId(null)}>{t('channels.cancel')}</button>
210
+ </li>
211
+ )
212
+ }
213
+ return (
214
+ <li key={channel.id} className={css.channelRow}>
215
+ <span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
216
+ <button type="button" className={css.channelMain} disabled={!state.writable} onClick={() => setEditingId(channel.id)}>
217
+ <span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
218
+ <span className={css.channelMeta}>
219
+ <span className={css.channelHost}>{channel.apiUrl || '(no url)'}</span>
220
+ <span className={css.channelBadge} data-warn={!keyHeld || channel.models.length === 0 ? '' : undefined}>
221
+ {keyHeld ? t('channels.keySet') : t('channels.keyMissing')}
222
+ {' · '}
223
+ {channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}
224
+ </span>
225
+ </span>
226
+ </button>
227
+ <button type="button" className={css.channelAction} onClick={() => setEditingId(channel.id)}>{t('channels.edit')}</button>
228
+ <button type="button" className={css.channelAction} data-danger onClick={() => setConfirmDeleteId(channel.id)}>{t('channels.delete')}</button>
229
+ </li>
230
+ )
231
+ })}
232
+ </ul>
233
+ )}
234
+ {presetPickerOpen ? (
235
+ <div className={css.channelControls}>
236
+ <p className={css.sectionHint}>{t('presets.title')}</p>
237
+ {presetError !== null ? <p className={css.failed}>{presetError}</p> : null}
238
+ <div className={css.channelAddRow}>
239
+ <button type="button" className={css.channelAdd} onClick={() => {
240
+ setPresets([])
241
+ setPresetError(null)
242
+ void fetch(PRESETS_API, { method: 'POST' })
243
+ .then(async response => {
244
+ const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
245
+ if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
246
+ setPresets(body.presets)
247
+ })
248
+ .catch(error => setPresetError(error instanceof Error ? error.message : String(error)))
249
+ }}>{t('channels.addProvider')}</button>
250
+ <button type="button" className={css.channelAdd} onClick={() => {
251
+ const draft = newChannelDraft(undefined, channels)
252
+ props.channels.setChannels([...channels, draft])
253
+ setPresetPickerOpen(false)
254
+ setEditingId(draft.id)
255
+ }}>{t('channels.addCustom')}</button>
256
+ <button type="button" className={css.channelAction} onClick={() => setPresetPickerOpen(false)}>×</button>
257
+ </div>
258
+ {presets.map(preset => (
259
+ <button key={preset.id} type="button" className={css.channelAdd} onClick={() => {
260
+ const draft = newChannelDraft(preset, channels)
261
+ props.channels.setChannels([...channels, draft])
262
+ setPresetPickerOpen(false)
263
+ setEditingId(draft.id)
264
+ }}>
265
+ {preset.name} — {preset.hint}
266
+ </button>
267
+ ))}
268
+ </div>
269
+ ) : (
270
+ <div className={css.channelAddRow}>
271
+ <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>{t('channels.addProvider')}</button>
272
+ <button type="button" className={css.channelAdd} disabled={!state.writable} onClick={() => {
273
+ const draft = newChannelDraft(undefined, channels)
274
+ props.channels.setChannels([...channels, draft])
275
+ setEditingId(draft.id)
276
+ }}>{t('channels.addCustom')}</button>
277
+ </div>
278
+ )}
279
+ </section>
280
+
281
+ {editing !== undefined ? (
282
+ <div className={css.body}>
283
+ <div className={css.field}>
284
+ <label className={css.label} htmlFor={`audiogen-name-${editing.id}`}>{t('channel.name')}</label>
285
+ <input id={`audiogen-name-${editing.id}`} className={css.input} value={editName} onChange={event => setEditName(event.target.value)} />
286
+ </div>
287
+ <div className={css.field}>
288
+ <label className={css.label} htmlFor={`audiogen-url-${editing.id}`}>{t('channel.apiUrl')}</label>
289
+ <input id={`audiogen-url-${editing.id}`} className={css.input} value={editUrl} onChange={event => setEditUrl(event.target.value)} placeholder="https://…" />
290
+ </div>
291
+ <div className={css.field}>
292
+ <label className={css.label} htmlFor={`audiogen-key-${editing.id}`}>{t('channel.apiKey')}</label>
293
+ <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] ? '••••••' : ''} />
294
+ <p className={css.sectionHint}>{t('channel.apiKeyHint')}</p>
295
+ </div>
296
+ <div className={css.field}>
297
+ <label className={css.label} htmlFor={`audiogen-models-${editing.id}`}>{t('channel.models')}</label>
298
+ <textarea id={`audiogen-models-${editing.id}`} className={css.textarea} value={editModels} onChange={event => setEditModels(event.target.value)} />
299
+ <p className={css.sectionHint}>{t('channel.modelsHint')}</p>
300
+ </div>
301
+ <label className={css.label}>
302
+ <input type="checkbox" checked={editDefault} onChange={event => setEditDefault(event.target.checked)} /> {t('channel.default')}
303
+ </label>
304
+ <div className={css.footer}>
305
+ <button type="button" className={css.discard} onClick={() => setEditingId(null)}>{t('channel.cancel')}</button>
306
+ <button type="button" className={css.save} onClick={saveEdit}>{t('channel.save')}</button>
307
+ </div>
308
+ </div>
309
+ ) : null}
310
+
311
+ <div className={css.field}>
312
+ <label className={css.label}>
313
+ <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')}
314
+ </label>
315
+ </div>
316
+ <div className={css.field}>
317
+ <label className={css.label}>
318
+ <input type="checkbox" checked={state.announceToAgent.text === 'true' || state.announceToAgent.text === ''} disabled={!state.writable} onChange={event => props.edit('announceToAgent', String(event.target.checked))} /> {t('settings.announceToAgent')}
319
+ </label>
320
+ </div>
321
+ <div className={css.field}>
322
+ <label className={css.label}>
323
+ <input type="checkbox" checked={state.allowAgentAudioGeneration.text === 'true' || state.allowAgentAudioGeneration.text === ''} disabled={!state.writable} onChange={event => props.edit('allowAgentAudioGeneration', String(event.target.checked))} /> {t('settings.allowAgentAudio')}
324
+ </label>
325
+ </div>
326
+ <div className={css.footer}>
327
+ {state.failed ? <p className={css.failed}>保存失败</p> : null}
328
+ <button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
329
+ <button type="button" className={css.save} disabled={blocked} onClick={() => { void props.save(); void props.channels.commit() }}>
330
+ {state.saving ? t('settings.saving') : t('settings.save')}
331
+ </button>
332
+ </div>
333
+ </div>
334
+ )}
335
+ </li>
336
+ )
337
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Browser-side API client for the audio generation and history routes.
3
+ */
4
+
5
+ import { GENERATE_API, HISTORY_API, type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
6
+
7
+ export interface GenerateResponse {
8
+ ok: boolean
9
+ outputs?: GeneratedAudio[]
10
+ history?: HistoryEntry[]
11
+ historyError?: string
12
+ code?: string
13
+ message?: string
14
+ }
15
+
16
+ export class AudiogenApi {
17
+ async generate(request: GenerateAudioRequest): Promise<GenerateResponse> {
18
+ const response = await fetch(GENERATE_API, {
19
+ method: 'POST',
20
+ headers: { 'content-type': 'application/json' },
21
+ body: JSON.stringify(request),
22
+ })
23
+ const body = await response.json() as GenerateResponse
24
+ return body
25
+ }
26
+
27
+ async history(): Promise<HistoryEntry[]> {
28
+ const response = await fetch(HISTORY_API.list, { method: 'POST' })
29
+ const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
30
+ return body.ok === true ? (body.history ?? []) : []
31
+ }
32
+
33
+ async clearHistory(): Promise<void> {
34
+ await fetch(HISTORY_API.clear, { method: 'POST' })
35
+ }
36
+ }