dsh-audiogen 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,399 +1,82 @@
1
-
2
1
  /**
3
- * The AI 音频 panel: a compact audio-generation studio.
4
- * TTS / music / SFX / voice design are separated; each mode only lists
5
- * compatible models and shows its own parameters.
2
+ * The AI 音频 panel shell: header with 生成 / 资源库 tabs, toast host, and
3
+ * cross-tab hooks (library refresh after saves, «use this voice» reuse).
6
4
  */
7
5
 
8
- import { useEffect, useMemo, useState } from 'react'
6
+ import { useRef, useState } from 'react'
9
7
  import type { AudiogenApi } from './api.ts'
10
8
  import type { AudiogenScope } from './settings-scope.ts'
11
- import { audioModelOptions } from './settings-scope.ts'
12
9
  import { tt } from './helpers.ts'
13
- import { HISTORY_API, type AudioMode, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
10
+ import { GridIcon, ListIcon } from './icons.tsx'
11
+ import { StudioView, type StudioReuse } from './studio-view.tsx'
12
+ import { LibraryView } from './library-view.tsx'
14
13
  import css from './audio-panel.module.css'
15
14
 
16
- function useConfig(scope: AudiogenScope) {
17
- const [value, setValue] = useState(scope.getSnapshot().value)
18
- useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
19
- return value
20
- }
21
-
22
- function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
23
- const [entries, setEntries] = useState<HistoryEntry[]>([])
24
- const reload = (): void => {
25
- void fetch(HISTORY_API.list, { method: 'POST' })
26
- .then(async response => {
27
- const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
28
- if (body.ok === true) setEntries(body.history ?? [])
29
- })
30
- .catch(() => { /* history is best-effort */ })
31
- }
32
- useEffect(() => { reload() }, [])
33
- const clear = (): void => {
34
- void fetch(HISTORY_API.clear, { method: 'POST' })
35
- .then(() => reload())
36
- .catch(() => { /* best-effort */ })
37
- }
38
- return { entries, reload, clear }
39
- }
40
-
41
- function dataUrlOf(audio: GeneratedAudio): string {
42
- return `data:${audio.mime};base64,${audio.b64}`
43
- }
15
+ type Tab = 'studio' | 'library'
44
16
 
45
17
  export function AudioGenPanel(props: { api: AudiogenApi; scope: AudiogenScope }) {
46
18
  const { api, scope } = props
47
- const config = useConfig(scope)
48
- const enabled = config?.enabled ?? true
49
- const modelOptions = audioModelOptions(config)
50
- const channels = config?.channels ?? []
51
- const connected = enabled && channels.some(channel => {
52
- const keyHeld = scope.getSecretSetSnapshot(`channelSecrets.${channel.id}`)
53
- return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
54
- })
55
-
56
- const [mode, setMode] = useState<AudioMode>('tts')
57
- const [prompt, setPrompt] = useState('')
58
- const [previewText, setPreviewText] = useState('')
59
- const [model, setModel] = useState('')
60
- const [voice, setVoice] = useState('')
61
- const [speed, setSpeed] = useState('')
62
- const [duration, setDuration] = useState('')
63
- const [lyrics, setLyrics] = useState('')
64
- const [instrumental, setInstrumental] = useState(false)
65
- // ElevenLabs 音效参数
66
- const [loop, setLoop] = useState(false)
67
- const [promptInfluence, setPromptInfluence] = useState('')
68
- const [format, setFormat] = useState('mp3')
69
- // MiniMax TTS 高级参数(其他厂商忽略)
70
- const [emotion, setEmotion] = useState('')
71
- const [vol, setVol] = useState('')
72
- const [pitch, setPitch] = useState('')
73
- const [toneText, setToneText] = useState('')
74
- const [sampleRate, setSampleRate] = useState('')
75
- const [bitrate, setBitrate] = useState('')
76
- const [audioChannel, setAudioChannel] = useState('')
77
- const [subtitle, setSubtitle] = useState(false)
78
- const [loading, setLoading] = useState(false)
79
- const [error, setError] = useState<string | null>(null)
80
- const [outputs, setOutputs] = useState<GeneratedAudio[]>([])
81
- const { entries, reload, clear } = useHistory()
82
- // 音色设计模式的厂商/渠道选择(默认渠道)
83
- const [designChannelId, setDesignChannelId] = useState('')
84
-
85
- const isMiniMaxChannel = useMemo(() => {
86
- const target = channels.find(candidate => candidate.id === modelOptions.defaultChannelId) ?? channels[0]
87
- return target !== undefined && (target.preset === 'minimax' || /minimax/i.test(target.apiUrl))
88
- }, [channels, modelOptions.defaultChannelId])
89
-
90
- useEffect(() => {
91
- if (channels.length === 0) return
92
- if (designChannelId === '' || !channels.some(candidate => candidate.id === designChannelId)) {
93
- setDesignChannelId(modelOptions.defaultChannelId ?? channels[0]!.id)
94
- }
95
- }, [channels, modelOptions.defaultChannelId, designChannelId])
96
-
97
- const visibleModels = useMemo(() => {
98
- if (mode === 'voice_design') return []
99
- return modelOptions.models
100
- .filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
101
- .map(entry => entry.alias)
102
- }, [modelOptions.models, mode])
103
-
104
- useEffect(() => {
105
- if (visibleModels.length > 0 && !visibleModels.includes(model)) {
106
- setModel(visibleModels[0]!)
107
- }
108
- }, [visibleModels, model])
109
-
110
- const submit = async (): Promise<void> => {
111
- if (prompt.trim() === '') {
112
- setError(tt('prompt.required'))
113
- return
114
- }
115
- setLoading(true)
116
- setError(null)
117
- try {
118
- const response = await api.generate({
119
- mode,
120
- model: mode === 'voice_design' ? '' : (model || visibleModels[0]) ?? '',
121
- prompt: prompt.trim(),
122
- ...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
123
- ...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
124
- ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
125
- ...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
126
- ...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
127
- ...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
128
- ...(instrumental ? { isInstrumental: true } : {}),
129
- ...(loop ? { loop: true } : {}),
130
- ...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
131
- ...(format.trim() !== '' ? { format: format.trim() } : {}),
132
- ...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
133
- ...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
134
- ...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
135
- ...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
136
- ...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
137
- ...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
138
- ...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
139
- ...(subtitle ? { subtitleEnable: true } : {}),
140
- })
141
- if (!response.ok) {
142
- setError(response.message ?? '生成失败')
143
- return
144
- }
145
- setOutputs(response.outputs ?? [])
146
- reload()
147
- } catch (err) {
148
- setError(err instanceof Error ? err.message : String(err))
149
- } finally {
150
- setLoading(false)
151
- }
19
+ const [tab, setTab] = useState<Tab>('studio')
20
+ const [libraryRev, setLibraryRev] = useState(0)
21
+ const [reuse, setReuse] = useState<StudioReuse | null>(null)
22
+ const [toast, setToast] = useState<string | null>(null)
23
+ const toastTimer = useRef<number | undefined>(undefined)
24
+
25
+ const showToast = (text: string): void => {
26
+ setToast(text)
27
+ if (toastTimer.current !== undefined) window.clearTimeout(toastTimer.current)
28
+ toastTimer.current = window.setTimeout(() => setToast(null), 2400)
152
29
  }
153
30
 
154
- const modeLabel = useMemo(() => {
155
- if (mode === 'tts') return tt('mode.tts')
156
- if (mode === 'music') return tt('mode.music')
157
- if (mode === 'sfx') return tt('mode.sfx')
158
- return tt('mode.voiceDesign')
159
- }, [mode])
160
-
161
- const needModel = mode !== 'voice_design'
162
-
163
31
  return (
164
32
  <div className={css.panel}>
165
33
  <header className={css.header}>
166
34
  <h2 className={css.title}>{tt('panel.title')}</h2>
167
- <span className={css.hint}>{modeLabel}</span>
168
- </header>
169
- <div className={css.layout}>
170
- <div className={css.form}>
171
- <div className={css.modeRow}>
172
- {(['tts', 'music', 'sfx', 'voice_design'] as const).map(item => (
173
- <button
174
- key={item}
175
- type="button"
176
- className={css.modeButton}
177
- data-active={mode === item ? 'true' : 'false'}
178
- onClick={() => setMode(item)}
179
- >
180
- {item === 'tts' ? tt('mode.tts') : item === 'music' ? tt('mode.music') : item === 'sfx' ? tt('mode.sfx') : tt('mode.voiceDesign')}
181
- </button>
182
- ))}
183
- </div>
184
-
185
- <label className={css.label}>
186
- <span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
187
- <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
188
- </label>
189
-
190
- {mode === 'voice_design' ? (
191
- <>
192
- <label className={css.label}>
193
- <span>厂商 / 渠道</span>
194
- <select className={css.select} value={designChannelId} onChange={event => setDesignChannelId(event.target.value)}>
195
- {channels.length === 0 ? <option value="">(尚未配置渠道)</option> : null}
196
- {channels.map(candidate => (
197
- <option key={candidate.id} value={candidate.id}>
198
- {candidate.name}({candidate.preset === 'minimax' ? 'MiniMax' : candidate.preset === 'elevenlabs' ? 'ElevenLabs' : candidate.preset || '自定义'})
199
- </option>
200
- ))}
201
- </select>
202
- </label>
203
- <p className={css.hint}>MiniMax:/v1/voice_design;ElevenLabs:/v1/text-to-voice/design(试听文本 100-1000 字符,过短将自动生成)</p>
204
- <label className={css.label}>
205
- <span>试听文本</span>
206
- <input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
207
- </label>
208
- </>
209
- ) : null}
210
-
211
- {needModel ? (
212
- <label className={css.label}>
213
- <span>{tt('model.label')}</span>
214
- <select className={css.select} value={model} onChange={event => setModel(event.target.value)}>
215
- {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
216
- {visibleModels.map(item => <option key={item} value={item}>{item}</option>)}
217
- </select>
218
- </label>
219
- ) : null}
220
-
221
- {mode === 'tts' ? (
222
- <label className={css.label}>
223
- <span>{tt('voice.label')}</span>
224
- <input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={isMiniMaxChannel ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
225
- </label>
226
- ) : null}
227
-
228
- {mode === 'tts' ? (
229
- <label className={css.label}>
230
- <span>{tt('speed.label')}</span>
231
- <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" />
232
- </label>
233
- ) : null}
234
-
235
- {mode === 'tts' && isMiniMaxChannel ? (
236
- <details className={css.advanced}>
237
- <summary>MiniMax 高级参数</summary>
238
- <label className={css.label}>
239
- <span>情绪 emotion</span>
240
- <input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
241
- </label>
242
- <div className={css.row}>
243
- <label className={css.label}>
244
- <span>音量 vol (0-10)</span>
245
- <input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
246
- </label>
247
- <label className={css.label}>
248
- <span>音调 pitch (-12~12)</span>
249
- <input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
250
- </label>
251
- </div>
252
- <div className={css.row}>
253
- <label className={css.label}>
254
- <span>采样率</span>
255
- <input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
256
- </label>
257
- <label className={css.label}>
258
- <span>码率 bps</span>
259
- <input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
260
- </label>
261
- <label className={css.label}>
262
- <span>声道</span>
263
- <select className={css.select} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
264
- <option value="">默认(1)</option>
265
- <option value="1">1</option>
266
- <option value="2">2</option>
267
- </select>
268
- </label>
269
- </div>
270
- <label className={css.label}>
271
- <span>发音词典(每行一条:"文字/读音")</span>
272
- <textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
273
- </label>
274
- <label className={css.checkbox}>
275
- <input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
276
- <span>生成字幕 subtitle_enable</span>
277
- </label>
278
- </details>
279
- ) : null}
280
-
281
- {mode === 'music' || mode === 'sfx' ? (
282
- <label className={css.label}>
283
- <span>{tt('duration.label')}</span>
284
- <input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
285
- </label>
286
- ) : null}
287
-
288
- {mode === 'sfx' ? (
289
- <>
290
- <label className={css.checkbox}>
291
- <input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
292
- <span>循环音效 loop(无缝循环,需 eleven_text_to_sound_v2)</span>
293
- </label>
294
- <label className={css.label}>
295
- <span>提示词影响度 prompt_influence (0-1)</span>
296
- <input className={css.input} type="number" step="0.1" min="0" max="1" value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder="0.3" />
297
- </label>
298
- </>
299
- ) : null}
300
-
301
- {mode === 'music' ? (
302
- <>
303
- <label className={css.label}>
304
- <span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
305
- <textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
306
- </label>
307
- <label className={css.checkbox}>
308
- <input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
309
- <span>纯音乐(无歌词/人声)is_instrumental</span>
310
- </label>
311
- <div className={css.row}>
312
- <label className={css.label}>
313
- <span>采样率</span>
314
- <select className={css.select} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
315
- <option value="">默认(44100)</option>
316
- <option value="16000">16000</option>
317
- <option value="24000">24000</option>
318
- <option value="32000">32000</option>
319
- <option value="44100">44100</option>
320
- </select>
321
- </label>
322
- <label className={css.label}>
323
- <span>码率 bps</span>
324
- <select className={css.select} value={bitrate} onChange={event => setBitrate(event.target.value)}>
325
- <option value="">默认(256000)</option>
326
- <option value="32000">32000</option>
327
- <option value="64000">64000</option>
328
- <option value="128000">128000</option>
329
- <option value="256000">256000</option>
330
- </select>
331
- </label>
332
- </div>
333
- </>
334
- ) : null}
335
-
336
- {needModel ? (
337
- <label className={css.label}>
338
- <span>{tt('format.label')}</span>
339
- <select className={css.select} value={format} onChange={event => setFormat(event.target.value)}>
340
- <option value="mp3">mp3</option>
341
- <option value="wav">wav</option>
342
- {mode === 'tts' ? (
343
- <>
344
- <option value="flac">flac</option>
345
- <option value="ogg">ogg</option>
346
- </>
347
- ) : null}
348
- <option value="pcm">pcm</option>
349
- </select>
350
- </label>
351
- ) : null}
352
-
353
- {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
354
- <button type="button" className={css.generate} disabled={loading || !connected || (needModel && visibleModels.length === 0)} onClick={() => void submit()}>
355
- {loading ? tt('generating') : tt('generate')}
35
+ <div className={css.tabs} role="tablist" aria-label="AI 音频">
36
+ <button
37
+ type="button"
38
+ role="tab"
39
+ aria-selected={tab === 'studio'}
40
+ className={css.tab}
41
+ data-active={tab === 'studio' ? 'true' : 'false'}
42
+ onClick={() => setTab('studio')}
43
+ >
44
+ <GridIcon /> {tt('tab.studio')}
45
+ </button>
46
+ <button
47
+ type="button"
48
+ role="tab"
49
+ aria-selected={tab === 'library'}
50
+ className={css.tab}
51
+ data-active={tab === 'library' ? 'true' : 'false'}
52
+ onClick={() => setTab('library')}
53
+ >
54
+ <ListIcon /> {tt('tab.library')}
356
55
  </button>
357
56
  </div>
57
+ </header>
358
58
 
359
- <div className={css.result}>
360
- {error !== null ? <p className={css.error}>{error}</p> : null}
361
- {outputs.length === 0 ? <p className={css.empty}>{tt('result.empty')}</p> : (
362
- <>
363
- <p className={css.hint}>{tt('result.done', { count: outputs.length })}</p>
364
- <div className={css.audioList}>
365
- {outputs.map((audio, index) => (
366
- <div className={css.audioCard} key={audio.id}>
367
- {audio.voiceId !== undefined ? <p className={css.hint}>新音色 ID:{audio.voiceId}</p> : null}
368
- <audio className={css.audio} controls preload="metadata" src={dataUrlOf(audio)} />
369
- <a className={css.download} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>下载</a>
370
- </div>
371
- ))}
372
- </div>
373
- </>
374
- )}
375
- </div>
376
-
377
- <aside className={css.history}>
378
- <div className={css.historyHeader}>
379
- <strong className={css.historyTitle}>{tt('history.title')}</strong>
380
- <button type="button" onClick={clear} style={{ border: 0, background: 'none', cursor: 'pointer', color: 'inherit', fontSize: 12 }}>清空</button>
381
- </div>
382
- {entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
383
- <div>
384
- {entries.map(entry => (
385
- <div className={css.historyItem} key={entry.id}>
386
- <div className={css.historyPrompt}>{entry.prompt}</div>
387
- <div className={css.historyMeta}>{entry.mode} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
388
- {entry.audio.map((audio, index) => (
389
- <audio key={index} className={css.historyAudio} controls preload="none" src={audio.url} />
390
- ))}
391
- </div>
392
- ))}
393
- </div>
394
- )}
395
- </aside>
396
- </div>
59
+ {tab === 'studio' ? (
60
+ <StudioView
61
+ api={api}
62
+ scope={scope}
63
+ reuse={reuse}
64
+ onLibraryChanged={() => setLibraryRev(revision => revision + 1)}
65
+ showToast={showToast}
66
+ />
67
+ ) : (
68
+ <LibraryView
69
+ api={api}
70
+ revision={libraryRev}
71
+ showToast={showToast}
72
+ onReuseVoice={payload => {
73
+ setReuse({ nonce: Date.now(), mode: payload.mode, ...(payload.voice === undefined ? {} : { voice: payload.voice }), ...(payload.voiceId === undefined ? {} : { voiceId: payload.voiceId }), ...(payload.model === undefined ? {} : { model: payload.model }) })
74
+ setTab('studio')
75
+ }}
76
+ />
77
+ )}
78
+
79
+ {toast !== null ? <div className={css.toast} role="status">{toast}</div> : null}
397
80
  </div>
398
81
  )
399
82
  }
@@ -27,6 +27,7 @@ export interface AudioGenSettings {
27
27
  announceToAgent?: boolean
28
28
  allowAgentAudioGeneration?: boolean
29
29
  defaultModel?: string
30
+ autoSaveToLibrary?: boolean
30
31
  }
31
32
 
32
33
  export interface AudioGenSettingsCardState extends CardShell {
@@ -35,6 +36,7 @@ export interface AudioGenSettingsCardState extends CardShell {
35
36
  announceToAgent: CardFieldState
36
37
  allowAgentAudioGeneration: CardFieldState
37
38
  defaultModel: CardFieldState
39
+ autoSaveToLibrary: CardFieldState
38
40
  }
39
41
 
40
42
  export interface AudioGenSettingsCardFace extends CardActions {
@@ -54,6 +56,7 @@ export class AudioGenSettingsCardController {
54
56
  booleanField('announceToAgent'),
55
57
  booleanField('allowAgentAudioGeneration'),
56
58
  textField('defaultModel'),
59
+ booleanField('autoSaveToLibrary'),
57
60
  ])
58
61
  this.channelsForm = new ChannelsForm(scope)
59
62
  }
@@ -68,6 +71,7 @@ export class AudioGenSettingsCardController {
68
71
  announceToAgent: this.form.field('announceToAgent'),
69
72
  allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
70
73
  defaultModel: this.form.field('defaultModel'),
74
+ autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
71
75
  }
72
76
  }
73
77
 
@@ -714,6 +718,11 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
714
718
  <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')}
715
719
  </label>
716
720
  </div>
721
+ <div className={css.field}>
722
+ <label className={css.label}>
723
+ <input type="checkbox" checked={state.autoSaveToLibrary.text === 'true'} disabled={!state.writable} onChange={event => props.edit('autoSaveToLibrary', String(event.target.checked))} /> {t('settings.autoSaveLibrary')}
724
+ </label>
725
+ </div>
717
726
  <div className={css.footer}>
718
727
  {state.failed ? <p className={css.failed}>保存失败</p> : null}
719
728
  <button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
package/src/client/api.ts CHANGED
@@ -1,14 +1,21 @@
1
1
  /**
2
- * Browser-side API client for the audio generation and history routes.
2
+ * Browser-side API client for the audio generation, history and
3
+ * resource-library routes.
3
4
  */
4
5
 
5
- import { GENERATE_API, HISTORY_API, type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry } from '../protocol.ts'
6
+ import {
7
+ GENERATE_API, HISTORY_API, LIBRARY_API,
8
+ type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
9
+ type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
10
+ } from '../protocol.ts'
6
11
 
7
12
  export interface GenerateResponse {
8
13
  ok: boolean
9
14
  outputs?: GeneratedAudio[]
10
15
  history?: HistoryEntry[]
11
16
  historyError?: string
17
+ /** Resource-library entries created by the generation (auto-save). */
18
+ resources?: Array<{ id: string; name: string; type: string }>
12
19
  code?: string
13
20
  message?: string
14
21
  }
@@ -33,4 +40,40 @@ export class AudiogenApi {
33
40
  async clearHistory(): Promise<void> {
34
41
  await fetch(HISTORY_API.clear, { method: 'POST' })
35
42
  }
43
+
44
+ async libraryList(): Promise<LibraryEntry[]> {
45
+ const response = await fetch(LIBRARY_API.list, { method: 'POST' })
46
+ const body = await response.json() as { ok?: boolean; entries?: LibraryEntry[] }
47
+ return body.ok === true ? (body.entries ?? []) : []
48
+ }
49
+
50
+ async librarySave(request: LibrarySaveRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
51
+ const response = await fetch(LIBRARY_API.save, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/json' },
54
+ body: JSON.stringify(request),
55
+ })
56
+ const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
57
+ return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
58
+ }
59
+
60
+ async libraryUpdate(request: LibraryUpdateRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
61
+ const response = await fetch(LIBRARY_API.update, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify(request),
65
+ })
66
+ const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
67
+ return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
68
+ }
69
+
70
+ async libraryRemove(ids: string[]): Promise<{ ok: boolean }> {
71
+ const response = await fetch(LIBRARY_API.remove, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify({ ids }),
75
+ })
76
+ const body = await response.json() as { ok?: boolean }
77
+ return { ok: body.ok === true }
78
+ }
36
79
  }