dsh-audiogen 0.3.4 → 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.
- package/README.md +7 -1
- package/lib/client.js +2368 -630
- package/lib/client.js.map +1 -1
- package/lib/index.js +1022 -68
- package/package.json +1 -1
- package/skills/design/SKILL.md +7 -1
- package/skills/music/SKILL.md +37 -0
- package/skills/sfx/SKILL.md +17 -3
- package/src/agent-audio-tools.ts +159 -14
- package/src/audio-engine.ts +284 -18
- package/src/audio-presets.ts +10 -4
- package/src/audio-store.ts +251 -3
- package/src/client/AudioGenPanel.tsx +59 -334
- package/src/client/SettingsCard.tsx +9 -0
- package/src/client/api.ts +45 -2
- package/src/client/audio-panel.module.css +652 -79
- package/src/client/audio-player.tsx +87 -0
- package/src/client/icons.tsx +170 -0
- package/src/client/library-save-dialog.tsx +173 -0
- package/src/client/library-view.tsx +511 -0
- package/src/client/library.module.css +686 -0
- package/src/client/locales.ts +6 -0
- package/src/client/settings-scope.ts +2 -0
- package/src/client/studio-view.tsx +560 -0
- package/src/index.ts +6 -0
- package/src/protocol.ts +130 -1
- package/src/routes.ts +247 -8
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library view: curated audio assets, organized by type (voice / music / sfx /
|
|
3
|
+
* tts) with categories (voice: male/female/custom; tts: the speaking voice).
|
|
4
|
+
* Search + chips + card grid + detail drawer (full provenance) + batch mode.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
8
|
+
import type { AudiogenApi } from './api.ts'
|
|
9
|
+
import type { AudioMode, LibraryEntry, LibraryType } from '../protocol.ts'
|
|
10
|
+
import { AudioPlayer } from './audio-player.tsx'
|
|
11
|
+
import { LIBRARY_TYPE_LABELS } from './library-save-dialog.tsx'
|
|
12
|
+
import { CheckIcon, CopyIcon, ListIcon, MicIcon, MusicNoteIcon, SearchIcon, TrashIcon, WaveIcon } from './icons.tsx'
|
|
13
|
+
import css from './library.module.css'
|
|
14
|
+
|
|
15
|
+
const TYPE_ORDER: LibraryType[] = ['voice', 'music', 'sfx', 'tts']
|
|
16
|
+
const VOICE_CATEGORIES = ['male', 'female', 'custom']
|
|
17
|
+
|
|
18
|
+
function timeAgo(ts: number): string {
|
|
19
|
+
const diff = Date.now() - ts
|
|
20
|
+
const minute = 60_000
|
|
21
|
+
const hour = 60 * minute
|
|
22
|
+
const day = 24 * hour
|
|
23
|
+
if (diff < minute) return '刚刚'
|
|
24
|
+
if (diff < hour) return `${Math.floor(diff / minute)} 分钟前`
|
|
25
|
+
if (diff < day) return `${Math.floor(diff / hour)} 小时前`
|
|
26
|
+
if (diff < 7 * day) return `${Math.floor(diff / day)} 天前`
|
|
27
|
+
const date = new Date(ts)
|
|
28
|
+
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function typeIcon(type: LibraryType): React.JSX.Element {
|
|
32
|
+
if (type === 'voice') return <MicIcon />
|
|
33
|
+
if (type === 'music') return <MusicNoteIcon />
|
|
34
|
+
if (type === 'sfx') return <WaveIcon />
|
|
35
|
+
return <ListIcon />
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LibraryReusePayload {
|
|
39
|
+
mode: AudioMode
|
|
40
|
+
voice?: string
|
|
41
|
+
voiceId?: string
|
|
42
|
+
model?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface DraftState {
|
|
46
|
+
name: string
|
|
47
|
+
tags: string
|
|
48
|
+
note: string
|
|
49
|
+
type: LibraryType
|
|
50
|
+
category: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function LibraryView(props: {
|
|
54
|
+
api: AudiogenApi
|
|
55
|
+
revision: number
|
|
56
|
+
showToast: (text: string) => void
|
|
57
|
+
onReuseVoice: (payload: LibraryReusePayload) => void
|
|
58
|
+
}): React.JSX.Element {
|
|
59
|
+
const { api, revision } = props
|
|
60
|
+
const [entries, setEntries] = useState<LibraryEntry[]>([])
|
|
61
|
+
const [loading, setLoading] = useState(true)
|
|
62
|
+
const [error, setError] = useState<string | null>(null)
|
|
63
|
+
const [keyword, setKeyword] = useState('')
|
|
64
|
+
const [typeFilter, setTypeFilter] = useState<'all' | LibraryType>('all')
|
|
65
|
+
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
|
66
|
+
const [tagFilter, setTagFilter] = useState<string | null>(null)
|
|
67
|
+
const [detailId, setDetailId] = useState<string | null>(null)
|
|
68
|
+
const [batchMode, setBatchMode] = useState(false)
|
|
69
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
70
|
+
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
|
71
|
+
const [busy, setBusy] = useState(false)
|
|
72
|
+
const [copied, setCopied] = useState<string | null>(null)
|
|
73
|
+
|
|
74
|
+
const load = (): void => {
|
|
75
|
+
setLoading(true)
|
|
76
|
+
setError(null)
|
|
77
|
+
api.libraryList()
|
|
78
|
+
.then(setEntries)
|
|
79
|
+
.catch(err => setError(err instanceof Error ? err.message : String(err)))
|
|
80
|
+
.finally(() => setLoading(false))
|
|
81
|
+
}
|
|
82
|
+
useEffect(() => { load() }, [revision])
|
|
83
|
+
useEffect(() => { setCategoryFilter('all') }, [typeFilter])
|
|
84
|
+
|
|
85
|
+
const categoriesOf = useMemo(() => {
|
|
86
|
+
const map = new Map<LibraryType, string[]>()
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.category === undefined || entry.category === '') continue
|
|
89
|
+
const list = map.get(entry.type) ?? []
|
|
90
|
+
if (!list.includes(entry.category)) list.push(entry.category)
|
|
91
|
+
map.set(entry.type, list)
|
|
92
|
+
}
|
|
93
|
+
return map
|
|
94
|
+
}, [entries])
|
|
95
|
+
|
|
96
|
+
const tags = useMemo(() => {
|
|
97
|
+
const counts = new Map<string, number>()
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
for (const tag of entry.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
|
100
|
+
}
|
|
101
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 14)
|
|
102
|
+
}, [entries])
|
|
103
|
+
|
|
104
|
+
const filtered = useMemo(() => {
|
|
105
|
+
const needle = keyword.trim().toLowerCase()
|
|
106
|
+
return entries.filter(entry => {
|
|
107
|
+
if (typeFilter !== 'all' && entry.type !== typeFilter) return false
|
|
108
|
+
if (categoryFilter !== 'all' && (entry.category ?? '') !== categoryFilter) return false
|
|
109
|
+
if (tagFilter !== null && !entry.tags.includes(tagFilter)) return false
|
|
110
|
+
if (needle !== '') {
|
|
111
|
+
const haystack = [entry.name, ...entry.tags, entry.provenance.prompt, entry.provenance.model ?? '', entry.provenance.channel ?? ''].join(' ').toLowerCase()
|
|
112
|
+
if (!haystack.includes(needle)) return false
|
|
113
|
+
}
|
|
114
|
+
return true
|
|
115
|
+
})
|
|
116
|
+
}, [entries, keyword, typeFilter, categoryFilter, tagFilter])
|
|
117
|
+
|
|
118
|
+
const typeCounts = useMemo(() => {
|
|
119
|
+
const counts: Record<'all' | LibraryType, number> = { all: entries.length, voice: 0, music: 0, sfx: 0, tts: 0 }
|
|
120
|
+
for (const entry of entries) counts[entry.type] += 1
|
|
121
|
+
return counts
|
|
122
|
+
}, [entries])
|
|
123
|
+
|
|
124
|
+
const detail = entries.find(entry => entry.id === detailId) ?? null
|
|
125
|
+
|
|
126
|
+
const currentCategoryOptions = useMemo(() => {
|
|
127
|
+
if (typeFilter === 'all') return []
|
|
128
|
+
return [...new Set([...(categoriesOf.get(typeFilter) ?? []), ...(typeFilter === 'voice' ? VOICE_CATEGORIES : [])])]
|
|
129
|
+
}, [typeFilter, categoriesOf])
|
|
130
|
+
|
|
131
|
+
const toggleSelect = (id: string): void => {
|
|
132
|
+
setSelected(current => {
|
|
133
|
+
const next = new Set(current)
|
|
134
|
+
if (!next.delete(id)) next.add(id)
|
|
135
|
+
return next
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const doDelete = async (ids: string[]): Promise<void> => {
|
|
140
|
+
setBusy(true)
|
|
141
|
+
try {
|
|
142
|
+
await api.libraryRemove(ids)
|
|
143
|
+
setEntries(current => current.filter(entry => !ids.includes(entry.id)))
|
|
144
|
+
setSelected(current => {
|
|
145
|
+
const next = new Set(current)
|
|
146
|
+
for (const id of ids) next.delete(id)
|
|
147
|
+
return next
|
|
148
|
+
})
|
|
149
|
+
if (detailId !== null && ids.includes(detailId)) setDetailId(null)
|
|
150
|
+
props.showToast(`已删除 ${ids.length} 个资源`)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
props.showToast(err instanceof Error ? err.message : '删除失败')
|
|
153
|
+
} finally {
|
|
154
|
+
setBusy(false)
|
|
155
|
+
setConfirmDelete(null)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const copyText = async (text: string, key: string): Promise<void> => {
|
|
160
|
+
try {
|
|
161
|
+
await navigator.clipboard.writeText(text)
|
|
162
|
+
setCopied(key)
|
|
163
|
+
window.setTimeout(() => setCopied(null), 1400)
|
|
164
|
+
} catch {
|
|
165
|
+
props.showToast('复制失败')
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- detail drawer state -------------------------------------------------
|
|
170
|
+
const [draft, setDraft] = useState<DraftState | null>(null)
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (detail !== null) {
|
|
173
|
+
setDraft({
|
|
174
|
+
name: detail.name,
|
|
175
|
+
tags: detail.tags.join(', '),
|
|
176
|
+
note: detail.note ?? '',
|
|
177
|
+
type: detail.type,
|
|
178
|
+
category: detail.category ?? '',
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
}, [detailId])
|
|
182
|
+
|
|
183
|
+
const saveDraft = async (): Promise<void> => {
|
|
184
|
+
if (detail === null || draft === null) return
|
|
185
|
+
setBusy(true)
|
|
186
|
+
try {
|
|
187
|
+
const result = await api.libraryUpdate({
|
|
188
|
+
id: detail.id,
|
|
189
|
+
name: draft.name,
|
|
190
|
+
tags: draft.tags.split(/[,,、\n]/).map(tag => tag.trim()).filter(tag => tag !== ''),
|
|
191
|
+
note: draft.note,
|
|
192
|
+
type: draft.type,
|
|
193
|
+
...((draft.type === 'voice' || draft.type === 'tts') && draft.category.trim() !== '' ? { category: draft.category.trim() } : {}),
|
|
194
|
+
})
|
|
195
|
+
if (!result.ok || result.entry === undefined) {
|
|
196
|
+
props.showToast(result.message ?? '保存失败')
|
|
197
|
+
return
|
|
198
|
+
}
|
|
199
|
+
setEntries(current => current.map(entry => entry.id === result.entry!.id ? result.entry! : entry))
|
|
200
|
+
props.showToast('资源已更新')
|
|
201
|
+
} catch (err) {
|
|
202
|
+
props.showToast(err instanceof Error ? err.message : '保存失败')
|
|
203
|
+
} finally {
|
|
204
|
+
setBusy(false)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const batchMoveCategory = async (category: string, type: LibraryType): Promise<void> => {
|
|
209
|
+
if (selected.size === 0) return
|
|
210
|
+
setBusy(true)
|
|
211
|
+
try {
|
|
212
|
+
for (const id of selected) {
|
|
213
|
+
await api.libraryUpdate({ id, type, ...(category !== '' ? { category } : {}) })
|
|
214
|
+
}
|
|
215
|
+
await load()
|
|
216
|
+
setSelected(new Set())
|
|
217
|
+
props.showToast(`已移动 ${selected.size} 个资源`)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
props.showToast(err instanceof Error ? err.message : '移动失败')
|
|
220
|
+
} finally {
|
|
221
|
+
setBusy(false)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const [batchTypeSelect, setBatchTypeSelect] = useState<LibraryType>('voice')
|
|
226
|
+
const [batchCategorySelect, setBatchCategorySelect] = useState('')
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div className={css.library}>
|
|
230
|
+
<div className={css.toolbar}>
|
|
231
|
+
<label className={css.searchBox}>
|
|
232
|
+
<SearchIcon />
|
|
233
|
+
<input
|
|
234
|
+
className={css.searchInput}
|
|
235
|
+
value={keyword}
|
|
236
|
+
onChange={event => setKeyword(event.target.value)}
|
|
237
|
+
placeholder="搜索名称、标签、提示词、模型…"
|
|
238
|
+
/>
|
|
239
|
+
</label>
|
|
240
|
+
<div className={css.typeChips}>
|
|
241
|
+
<button type="button" className={css.chip} data-active={typeFilter === 'all' ? 'true' : 'false'} onClick={() => setTypeFilter('all')}>
|
|
242
|
+
全部 <span className={css.chipCount}>{typeCounts.all}</span>
|
|
243
|
+
</button>
|
|
244
|
+
{TYPE_ORDER.map(type => (
|
|
245
|
+
<button key={type} type="button" className={css.chip} data-active={typeFilter === type ? 'true' : 'false'} onClick={() => setTypeFilter(type)}>
|
|
246
|
+
{typeIcon(type)} {LIBRARY_TYPE_LABELS[type]} <span className={css.chipCount}>{typeCounts[type]}</span>
|
|
247
|
+
</button>
|
|
248
|
+
))}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{typeFilter !== 'all' && currentCategoryOptions.length > 0 ? (
|
|
253
|
+
<div className={css.filterRow}>
|
|
254
|
+
<span className={css.filterLabel}>分类</span>
|
|
255
|
+
<button type="button" className={css.smallChip} data-active={categoryFilter === 'all' ? 'true' : 'false'} onClick={() => setCategoryFilter('all')}>全部</button>
|
|
256
|
+
{currentCategoryOptions.map(category => (
|
|
257
|
+
<button key={category} type="button" className={css.smallChip} data-active={categoryFilter === category ? 'true' : 'false'} onClick={() => setCategoryFilter(category)}>
|
|
258
|
+
{typeFilter === 'voice' ? ({ male: '男声', female: '女声', custom: '未分级' }[category] ?? category) : category}
|
|
259
|
+
</button>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
) : null}
|
|
263
|
+
|
|
264
|
+
{tags.length > 0 ? (
|
|
265
|
+
<div className={css.filterRow}>
|
|
266
|
+
<span className={css.filterLabel}>标签</span>
|
|
267
|
+
{tags.map(([tag, count]) => (
|
|
268
|
+
<button key={tag} type="button" className={css.smallChip} data-active={tagFilter === tag ? 'true' : 'false'} onClick={() => setTagFilter(tagFilter === tag ? null : tag)}>
|
|
269
|
+
{tag} <span className={css.chipCount}>{count}</span>
|
|
270
|
+
</button>
|
|
271
|
+
))}
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
274
|
+
|
|
275
|
+
<div className={css.listHead}>
|
|
276
|
+
<span className={css.listCount}>{filtered.length} 个资源</span>
|
|
277
|
+
<div className={css.listActions}>
|
|
278
|
+
{batchMode ? (
|
|
279
|
+
<>
|
|
280
|
+
<span className={css.selCount}>已选 {selected.size}</span>
|
|
281
|
+
<select
|
|
282
|
+
className={css.smallSelect}
|
|
283
|
+
value={batchTypeSelect}
|
|
284
|
+
onChange={event => {
|
|
285
|
+
setBatchTypeSelect(event.target.value as LibraryType)
|
|
286
|
+
setBatchCategorySelect('')
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
{TYPE_ORDER.map(type => <option key={type} value={type}>{LIBRARY_TYPE_LABELS[type]}</option>)}
|
|
290
|
+
</select>
|
|
291
|
+
<input className={css.smallSelect} list="library-category-options" placeholder="分类(可选)" value={batchCategorySelect} onChange={event => setBatchCategorySelect(event.target.value)} />
|
|
292
|
+
<datalist id="library-category-options">
|
|
293
|
+
{(categoriesOf.get(batchTypeSelect) ?? []).map(category => <option key={category} value={category} />)}
|
|
294
|
+
</datalist>
|
|
295
|
+
<button type="button" className={css.ghostBtn} disabled={busy || selected.size === 0} onClick={() => void batchMoveCategory(batchCategorySelect, batchTypeSelect)}>
|
|
296
|
+
<CheckIcon /> 移动
|
|
297
|
+
</button>
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
className={css.dangerBtn}
|
|
301
|
+
disabled={busy || selected.size === 0}
|
|
302
|
+
onClick={() => {
|
|
303
|
+
if (confirmDelete === 'batch') void doDelete([...selected])
|
|
304
|
+
else setConfirmDelete('batch')
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<TrashIcon /> {confirmDelete === 'batch' ? '确认删除' : '批量删除'}
|
|
308
|
+
</button>
|
|
309
|
+
<button type="button" className={css.ghostBtn} onClick={() => { setBatchMode(false); setSelected(new Set()); setConfirmDelete(null) }}>
|
|
310
|
+
退出
|
|
311
|
+
</button>
|
|
312
|
+
</>
|
|
313
|
+
) : (
|
|
314
|
+
<button type="button" className={css.ghostBtn} onClick={() => setBatchMode(true)}>
|
|
315
|
+
多选管理
|
|
316
|
+
</button>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
|
|
321
|
+
{loading ? <p className={css.stateNote}>加载中…</p> : null}
|
|
322
|
+
{error !== null ? <p className={css.stateNote} data-error>{error}</p> : null}
|
|
323
|
+
{!loading && error === null && filtered.length === 0 ? (
|
|
324
|
+
<div className={css.empty}>
|
|
325
|
+
<span className={css.emptyIcon}>🎧</span>
|
|
326
|
+
<p>资源库还是空的</p>
|
|
327
|
+
<p className={css.emptyHint}>在生成页点击「加入资源库」,把满意的音频沉淀为可管理的资源</p>
|
|
328
|
+
</div>
|
|
329
|
+
) : null}
|
|
330
|
+
|
|
331
|
+
<div className={css.grid}>
|
|
332
|
+
{filtered.map(entry => {
|
|
333
|
+
const fileCount = entry.files.length
|
|
334
|
+
const first = entry.files[0]
|
|
335
|
+
return (
|
|
336
|
+
<article
|
|
337
|
+
key={entry.id}
|
|
338
|
+
className={css.card}
|
|
339
|
+
data-selected={selected.has(entry.id) ? 'true' : 'false'}
|
|
340
|
+
onClick={() => {
|
|
341
|
+
if (batchMode) toggleSelect(entry.id)
|
|
342
|
+
else setDetailId(entry.id)
|
|
343
|
+
}}
|
|
344
|
+
role="button"
|
|
345
|
+
tabIndex={0}
|
|
346
|
+
onKeyDown={event => {
|
|
347
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); if (batchMode) toggleSelect(entry.id); else setDetailId(entry.id) }
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
{batchMode ? (
|
|
351
|
+
<span className={css.cardCheck} onClick={event => event.stopPropagation()}>
|
|
352
|
+
<input type="checkbox" checked={selected.has(entry.id)} onChange={() => toggleSelect(entry.id)} />
|
|
353
|
+
</span>
|
|
354
|
+
) : null}
|
|
355
|
+
<div className={css.cardHead}>
|
|
356
|
+
<span className={css.typeBadge} data-type={entry.type}>{typeIcon(entry.type)}{LIBRARY_TYPE_LABELS[entry.type]}</span>
|
|
357
|
+
<span className={css.cardTime}>{timeAgo(entry.createdAt)}</span>
|
|
358
|
+
</div>
|
|
359
|
+
<strong className={css.cardName} title={entry.name}>{entry.name}</strong>
|
|
360
|
+
{first !== undefined ? (
|
|
361
|
+
<div onClick={event => event.stopPropagation()}>
|
|
362
|
+
<AudioPlayer src={first.url} compact itemKey={entry.id} />
|
|
363
|
+
</div>
|
|
364
|
+
) : null}
|
|
365
|
+
<div className={css.cardFoot}>
|
|
366
|
+
{entry.category !== undefined && entry.category !== '' ? (
|
|
367
|
+
<span className={css.metaChip}>{entry.type === 'voice' ? ({ male: '男声', female: '女声', custom: '未分级' }[entry.category] ?? entry.category) : entry.category}</span>
|
|
368
|
+
) : null}
|
|
369
|
+
{entry.provenance.model !== undefined ? <span className={css.metaChip} title={`模型:${entry.provenance.model}`}>{entry.provenance.model}</span> : null}
|
|
370
|
+
{fileCount > 1 ? <span className={css.metaChip}>×{fileCount}</span> : null}
|
|
371
|
+
{entry.tags.length > 0 ? <span className={css.metaChip} data-tag>{entry.tags[0]}{entry.tags.length > 1 ? ` +${entry.tags.length - 1}` : ''}</span> : null}
|
|
372
|
+
</div>
|
|
373
|
+
</article>
|
|
374
|
+
)
|
|
375
|
+
})}
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
{detail !== null ? (
|
|
379
|
+
<div className={css.drawerMask} onClick={() => setDetailId(null)}>
|
|
380
|
+
<aside className={css.drawer} onClick={event => event.stopPropagation()}>
|
|
381
|
+
<header className={css.drawerHead}>
|
|
382
|
+
<strong className={css.drawerTitle}>{detail.name}</strong>
|
|
383
|
+
<button type="button" className={css.iconBtn} aria-label="关闭" onClick={() => setDetailId(null)}>×</button>
|
|
384
|
+
</header>
|
|
385
|
+
|
|
386
|
+
<div className={css.drawerBody}>
|
|
387
|
+
<div className={css.drawerSection}>
|
|
388
|
+
<div className={css.drawerPlayerList}>
|
|
389
|
+
{detail.files.map(file => (
|
|
390
|
+
<div key={file.rel} className={css.drawerPlayer}>
|
|
391
|
+
<span className={css.drawerPlayerName}>{file.rel.split('/').pop()}</span>
|
|
392
|
+
<AudioPlayer src={file.url} itemKey={`${file.rel}-${detail.createdAt}`} />
|
|
393
|
+
</div>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
|
|
398
|
+
{draft !== null ? (
|
|
399
|
+
<div className={css.drawerSection}>
|
|
400
|
+
<div className={css.drawerLabel}>编辑</div>
|
|
401
|
+
<label className={css.drawerField}>
|
|
402
|
+
<span>名称</span>
|
|
403
|
+
<input className={css.input} value={draft.name} onChange={event => setDraft({ ...draft, name: event.target.value })} />
|
|
404
|
+
</label>
|
|
405
|
+
<label className={css.drawerField}>
|
|
406
|
+
<span>标签(逗号分隔)</span>
|
|
407
|
+
<input className={css.input} value={draft.tags} onChange={event => setDraft({ ...draft, tags: event.target.value })} />
|
|
408
|
+
</label>
|
|
409
|
+
<label className={css.drawerField}>
|
|
410
|
+
<span>备注</span>
|
|
411
|
+
<textarea className={css.textarea} rows={2} value={draft.note} onChange={event => setDraft({ ...draft, note: event.target.value })} />
|
|
412
|
+
</label>
|
|
413
|
+
<div className={css.drawerSplit}>
|
|
414
|
+
<label className={css.drawerField}>
|
|
415
|
+
<span>类型</span>
|
|
416
|
+
<select className={css.input} value={draft.type} onChange={event => setDraft({ ...draft, type: event.target.value as LibraryType, category: '' })}>
|
|
417
|
+
{TYPE_ORDER.map(type => <option key={type} value={type}>{LIBRARY_TYPE_LABELS[type]}</option>)}
|
|
418
|
+
</select>
|
|
419
|
+
</label>
|
|
420
|
+
{(draft.type === 'voice' || draft.type === 'tts') ? (
|
|
421
|
+
<label className={css.drawerField}>
|
|
422
|
+
<span>{draft.type === 'voice' ? '分级(男/女)' : '音色键'}</span>
|
|
423
|
+
<input className={css.input} list="library-category-options" value={draft.category} onChange={event => setDraft({ ...draft, category: event.target.value })} placeholder={draft.type === 'voice' ? 'male / female / custom' : 'voice 键'} />
|
|
424
|
+
</label>
|
|
425
|
+
) : null}
|
|
426
|
+
</div>
|
|
427
|
+
<button type="button" className={css.primaryBtn} disabled={busy} onClick={() => void saveDraft()}><CheckIcon /> 保存修改</button>
|
|
428
|
+
</div>
|
|
429
|
+
) : null}
|
|
430
|
+
|
|
431
|
+
<div className={css.drawerSection}>
|
|
432
|
+
<div className={css.drawerLabel}>来源(可追溯)</div>
|
|
433
|
+
<div className={css.provenance}>
|
|
434
|
+
<div className={css.provRow}><span className={css.provKey}>类型</span><span className={css.provValue}>{LIBRARY_TYPE_LABELS[detail.type]} · {detail.provenance.mode}</span></div>
|
|
435
|
+
{detail.provenance.channel !== undefined ? <div className={css.provRow}><span className={css.provKey}>渠道</span><span className={css.provValue}>{detail.provenance.channel}</span></div> : null}
|
|
436
|
+
{detail.provenance.apiUrl !== undefined ? <div className={css.provRow}><span className={css.provKey}>API 地址</span><span className={`${css.provValue} ${css.mono}`}>{detail.provenance.apiUrl}</span></div> : null}
|
|
437
|
+
{detail.provenance.model !== undefined ? (
|
|
438
|
+
<div className={css.provRow}>
|
|
439
|
+
<span className={css.provKey}>模型</span>
|
|
440
|
+
<span className={css.provValue}>{detail.provenance.model}{detail.provenance.upstream !== undefined && detail.provenance.upstream !== detail.provenance.model ? ` → ${detail.provenance.upstream}` : ''}</span>
|
|
441
|
+
</div>
|
|
442
|
+
) : null}
|
|
443
|
+
{detail.provenance.voice !== undefined ? <div className={css.provRow}><span className={css.provKey}>音色</span><span className={css.provValue}>{detail.provenance.voice}</span></div> : null}
|
|
444
|
+
{detail.provenance.voiceId !== undefined ? (
|
|
445
|
+
<div className={css.provRow}>
|
|
446
|
+
<span className={css.provKey}>Voice ID</span>
|
|
447
|
+
<span className={css.provValue}>
|
|
448
|
+
<code className={css.code}>{detail.provenance.voiceId}</code>
|
|
449
|
+
<button type="button" className={css.iconBtn} title="复制 Voice ID" onClick={() => void copyText(detail.provenance.voiceId!, `vid-${detail.id}`)}>
|
|
450
|
+
{copied === `vid-${detail.id}` ? <CheckIcon /> : <CopyIcon />}
|
|
451
|
+
</button>
|
|
452
|
+
</span>
|
|
453
|
+
</div>
|
|
454
|
+
) : null}
|
|
455
|
+
<div className={css.provRow}><span className={css.provKey}>提示词</span><span className={`${css.provValue} ${css.promptText}`}>{detail.provenance.prompt || '—'}</span></div>
|
|
456
|
+
<div className={css.provRow}><span className={css.provKey}>创建时间</span><span className={css.provValue}>{new Date(detail.createdAt).toLocaleString('zh-CN', { hour12: false })}</span></div>
|
|
457
|
+
{detail.provenance.params !== undefined ? (
|
|
458
|
+
<div className={css.provRow}>
|
|
459
|
+
<span className={css.provKey}>生成参数</span>
|
|
460
|
+
<span className={css.provValue}>
|
|
461
|
+
<details className={css.paramsDetails}>
|
|
462
|
+
<summary>查看参数快照</summary>
|
|
463
|
+
<pre className={css.code}>{JSON.stringify(detail.provenance.params, null, 2)}</pre>
|
|
464
|
+
</details>
|
|
465
|
+
<button type="button" className={css.iconBtn} title="复制参数 JSON" onClick={() => void copyText(JSON.stringify(detail.provenance.params, null, 2), `params-${detail.id}`)}>
|
|
466
|
+
{copied === `params-${detail.id}` ? <CheckIcon /> : <CopyIcon />}
|
|
467
|
+
</button>
|
|
468
|
+
</span>
|
|
469
|
+
</div>
|
|
470
|
+
) : null}
|
|
471
|
+
<div className={css.provRow}><span className={css.provKey}>文件</span><span className={`${css.provValue} ${css.mono}`}>{detail.files.length} 段 · {detail.files.map(file => file.rel).join(',')}</span></div>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<div className={css.drawerActions}>
|
|
476
|
+
{detail.provenance.voiceId !== undefined || detail.provenance.voice !== undefined ? (
|
|
477
|
+
<button
|
|
478
|
+
type="button"
|
|
479
|
+
className={css.primaryBtn}
|
|
480
|
+
onClick={() => {
|
|
481
|
+
props.onReuseVoice({
|
|
482
|
+
mode: 'tts',
|
|
483
|
+
...(detail.provenance.voiceId !== undefined ? { voiceId: detail.provenance.voiceId } : {}),
|
|
484
|
+
...(detail.provenance.voice !== undefined ? { voice: detail.provenance.voice } : {}),
|
|
485
|
+
...(detail.provenance.model !== undefined ? { model: detail.provenance.model } : {}),
|
|
486
|
+
})
|
|
487
|
+
setDetailId(null)
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
<MicIcon /> 用此音色去 TTS
|
|
491
|
+
</button>
|
|
492
|
+
) : null}
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
className={css.dangerBtn}
|
|
496
|
+
onClick={() => {
|
|
497
|
+
if (confirmDelete === detail.id) void doDelete([detail.id])
|
|
498
|
+
else setConfirmDelete(detail.id)
|
|
499
|
+
}}
|
|
500
|
+
disabled={busy}
|
|
501
|
+
>
|
|
502
|
+
<TrashIcon /> {confirmDelete === detail.id ? '确认删除' : '删除资源'}
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
</aside>
|
|
507
|
+
</div>
|
|
508
|
+
) : null}
|
|
509
|
+
</div>
|
|
510
|
+
)
|
|
511
|
+
}
|