dsh-audiogen 0.4.1 → 0.4.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/README.md +2 -0
- package/lib/client.js +1669 -987
- package/lib/client.js.map +1 -1
- package/lib/index.js +124 -6
- package/package.json +1 -1
- package/src/agent-audio-tools.ts +11 -1
- package/src/audio-scheduler.ts +73 -0
- package/src/client/SettingsCard.tsx +18 -0
- package/src/client/api.ts +24 -25
- package/src/client/audio-panel.module.css +241 -0
- package/src/client/field-specs.ts +186 -0
- package/src/client/library-view.tsx +6 -1
- package/src/client/locales.ts +2 -0
- package/src/client/settings-scope.ts +18 -3
- package/src/client/studio-view.tsx +729 -390
- package/src/index.ts +14 -0
- package/src/protocol.ts +10 -1
- package/src/routes.ts +50 -2
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Studio view: the generation form (left), result cards (center) and the
|
|
3
3
|
* compact generation history (right). Owns the «加入资源库» interactions —
|
|
4
|
-
* a pre-generation checkbox, a per-card save dialog, and a history star
|
|
4
|
+
* a pre-generation checkbox, a per-card save dialog, and a history star —
|
|
5
|
+
* plus a model-comparison mode that runs the same prompt across several
|
|
6
|
+
* models and shows one result group per model.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
9
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
8
10
|
import type { AudiogenApi } from './api.ts'
|
|
9
11
|
import type { AudiogenConfig, AudiogenScope } from './settings-scope.ts'
|
|
10
12
|
import { audioModelOptions } from './settings-scope.ts'
|
|
13
|
+
import { globalFieldSpecs, overrideRowSpecs, presetLabel, type FieldSpec } from './field-specs.ts'
|
|
11
14
|
import { tt } from './helpers.ts'
|
|
12
15
|
import {
|
|
13
16
|
HISTORY_API,
|
|
14
|
-
type AudioMode, type
|
|
17
|
+
type AudioMode, type GeneratedAudio, type GenerateAudioRequest, type HistoryEntry, type LibraryEntry,
|
|
15
18
|
} from '../protocol.ts'
|
|
16
19
|
import { AudioPlayer } from './audio-player.tsx'
|
|
17
20
|
import { LibrarySaveDialog, type SaveDialogContext } from './library-save-dialog.tsx'
|
|
@@ -26,25 +29,33 @@ export interface StudioReuse {
|
|
|
26
29
|
voiceId?: string
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
32
|
+
/** One row of a model-comparison run. */
|
|
33
|
+
interface CompareResult {
|
|
34
|
+
model: string
|
|
35
|
+
state: 'waiting' | 'running' | 'done' | 'error' | 'cancelled'
|
|
36
|
+
outputs: GeneratedAudio[]
|
|
37
|
+
error?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 一个生成任务(单模型或模型对比):提交即建任务,非阻塞;可取消、可并行。 */
|
|
41
|
+
interface StudioTask {
|
|
42
|
+
id: string
|
|
43
|
+
mode: AudioMode
|
|
44
|
+
kind: 'single' | 'compare'
|
|
45
|
+
prompt?: string
|
|
46
|
+
label: string
|
|
47
|
+
status: 'running' | 'done' | 'failed' | 'cancelled'
|
|
48
|
+
progress: { done: number; total: number; current: string }
|
|
49
|
+
startedAt: number
|
|
50
|
+
finishedAt?: number
|
|
51
|
+
groups: CompareResult[]
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
43
54
|
|
|
44
55
|
/** 每模型覆盖值 → 请求字段的数值/类型转换(空值跳过)。 */
|
|
45
56
|
function overrideSpread(override: Record<string, string>): Partial<GenerateAudioRequest> {
|
|
46
57
|
const out: Partial<GenerateAudioRequest> = {}
|
|
47
|
-
const val = override.format
|
|
58
|
+
const val = (override.format ?? '').trim()
|
|
48
59
|
if (val !== '') out.format = val
|
|
49
60
|
const num = (key: string): number | undefined => {
|
|
50
61
|
const raw = (override[key] ?? '').trim()
|
|
@@ -54,17 +65,17 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
|
|
|
54
65
|
}
|
|
55
66
|
const duration = num('duration')
|
|
56
67
|
if (duration !== undefined) out.duration = duration
|
|
57
|
-
const voice = override.voice
|
|
68
|
+
const voice = (override.voice ?? '').trim()
|
|
58
69
|
if (voice !== '') out.voice = voice
|
|
59
70
|
const speed = num('speed')
|
|
60
71
|
if (speed !== undefined) out.speed = speed
|
|
61
|
-
const emotion = override.emotion
|
|
72
|
+
const emotion = (override.emotion ?? '').trim()
|
|
62
73
|
if (emotion !== '') out.emotion = emotion
|
|
63
74
|
const sampleRate = num('sample_rate')
|
|
64
75
|
if (sampleRate !== undefined) out.sampleRate = sampleRate
|
|
65
76
|
const bitrate = num('bitrate')
|
|
66
77
|
if (bitrate !== undefined) out.bitrate = bitrate
|
|
67
|
-
const lyrics = override.lyrics
|
|
78
|
+
const lyrics = (override.lyrics ?? '').trim()
|
|
68
79
|
if (lyrics !== '') out.lyrics = lyrics
|
|
69
80
|
const seed = num('seed')
|
|
70
81
|
if (seed !== undefined) out.seed = seed
|
|
@@ -75,6 +86,21 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
|
|
|
75
86
|
return out
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/** 历史记录分组 tab(全部 + 按模式)。 */
|
|
90
|
+
type HistoryTab = 'all' | AudioMode
|
|
91
|
+
|
|
92
|
+
function taskIdOf(entry: HistoryEntry): string {
|
|
93
|
+
const params = entry.params as Record<string, unknown> | undefined
|
|
94
|
+
return typeof params?.taskId === 'string' && params.taskId !== '' ? params.taskId : ''
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function modeLabelOf(mode: AudioMode): string {
|
|
98
|
+
if (mode === 'tts') return tt('mode.tts')
|
|
99
|
+
if (mode === 'music') return tt('mode.music')
|
|
100
|
+
if (mode === 'sfx') return tt('mode.sfx')
|
|
101
|
+
return tt('mode.voiceDesign')
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
function useConfig(scope: AudiogenScope) {
|
|
79
105
|
const [value, setValue] = useState(scope.getSnapshot().value)
|
|
80
106
|
useEffect(() => scope.subscribe(() => { setValue(scope.getSnapshot().value) }), [scope])
|
|
@@ -84,7 +110,11 @@ function useConfig(scope: AudiogenScope) {
|
|
|
84
110
|
function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: () => void } {
|
|
85
111
|
const [entries, setEntries] = useState<HistoryEntry[]>([])
|
|
86
112
|
const reload = (): void => {
|
|
87
|
-
void fetch(HISTORY_API.list, {
|
|
113
|
+
void fetch(HISTORY_API.list, {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: { 'content-type': 'application/json' },
|
|
116
|
+
body: '{}',
|
|
117
|
+
})
|
|
88
118
|
.then(async response => {
|
|
89
119
|
const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
|
|
90
120
|
if (body.ok === true) setEntries(body.history ?? [])
|
|
@@ -93,7 +123,11 @@ function useHistory(): { entries: HistoryEntry[]; reload: () => void; clear: ()
|
|
|
93
123
|
}
|
|
94
124
|
useEffect(() => { reload() }, [])
|
|
95
125
|
const clear = (): void => {
|
|
96
|
-
void fetch(HISTORY_API.clear, {
|
|
126
|
+
void fetch(HISTORY_API.clear, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'content-type': 'application/json' },
|
|
129
|
+
body: '{}',
|
|
130
|
+
})
|
|
97
131
|
.then(() => reload())
|
|
98
132
|
.catch(() => { /* best-effort */ })
|
|
99
133
|
}
|
|
@@ -170,10 +204,6 @@ export function StudioView(props: {
|
|
|
170
204
|
const [prompt, setPrompt] = useState('')
|
|
171
205
|
const [previewText, setPreviewText] = useState('')
|
|
172
206
|
const [model, setModel] = useState('')
|
|
173
|
-
// 多模型对比:同一提示词逐模型生成
|
|
174
|
-
const [selectedModels, setSelectedModels] = useState<string[]>([])
|
|
175
|
-
// 每模型参数覆盖(留空 = 自动沿用全局配置)
|
|
176
|
-
const [overrides, setOverrides] = useState<Record<string, Record<string, string>>>({})
|
|
177
207
|
const [voice, setVoice] = useState('')
|
|
178
208
|
const [speed, setSpeed] = useState('')
|
|
179
209
|
const [duration, setDuration] = useState('')
|
|
@@ -192,14 +222,27 @@ export function StudioView(props: {
|
|
|
192
222
|
const [bitrate, setBitrate] = useState('')
|
|
193
223
|
const [audioChannel, setAudioChannel] = useState('')
|
|
194
224
|
const [subtitle, setSubtitle] = useState(false)
|
|
195
|
-
|
|
196
|
-
const [
|
|
225
|
+
// Stable Audio 参数(仅 Stability 渠道显示)
|
|
226
|
+
const [seed, setSeed] = useState('')
|
|
227
|
+
const [steps, setSteps] = useState('')
|
|
228
|
+
const [cfgScale, setCfgScale] = useState('')
|
|
197
229
|
const [error, setError] = useState<string | null>(null)
|
|
198
|
-
|
|
230
|
+
// 任务列表:提交即建任务,非阻塞;可同时存在多个进行中的任务。
|
|
231
|
+
const [tasks, setTasks] = useState<StudioTask[]>([])
|
|
232
|
+
const tasksRef = useRef<StudioTask[]>([])
|
|
233
|
+
useEffect(() => { tasksRef.current = tasks }, [tasks])
|
|
234
|
+
const taskControllers = useRef(new Map<string, AbortController[]>())
|
|
199
235
|
// 资源库
|
|
200
236
|
const [saveToLibrary, setSaveToLibrary] = useState(cfg?.autoSaveToLibrary === true)
|
|
201
237
|
const [savedIds, setSavedIds] = useState<Set<string>>(new Set())
|
|
202
238
|
const [saveDialog, setSaveDialog] = useState<SaveDialogState | null>(null)
|
|
239
|
+
// 历史记录分类 tab
|
|
240
|
+
const [historyTab, setHistoryTab] = useState<HistoryTab>('all')
|
|
241
|
+
// 模型对比
|
|
242
|
+
const [compareMode, setCompareMode] = useState(false)
|
|
243
|
+
const [compareModels, setCompareModels] = useState<string[]>([])
|
|
244
|
+
// 每模型参数覆盖(留空 = 自动沿用全局)
|
|
245
|
+
const [overrides, setOverrides] = useState<Record<string, Record<string, string>>>({})
|
|
203
246
|
const { entries, reload, clear } = useHistory()
|
|
204
247
|
// 音色设计模式的厂商/渠道选择(默认渠道)
|
|
205
248
|
const [designChannelId, setDesignChannelId] = useState('')
|
|
@@ -223,32 +266,43 @@ export function StudioView(props: {
|
|
|
223
266
|
.map(entry => entry.alias)
|
|
224
267
|
}, [modelOptions.models, mode])
|
|
225
268
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
})
|
|
233
|
-
}, [visibleModels])
|
|
269
|
+
// 当前模型所属渠道 preset(单模型模式);对比模式为所选模型渠道集合。
|
|
270
|
+
const currentPreset = useMemo(() => {
|
|
271
|
+
if (mode === 'voice_design') return channels.find(candidate => candidate.id === designChannelId)?.preset ?? ''
|
|
272
|
+
const picked = modelOptions.models.find(entry => entry.alias === model) ?? modelOptions.models.find(entry => entry.alias === (visibleModels[0] ?? ''))
|
|
273
|
+
return picked?.preset ?? ''
|
|
274
|
+
}, [mode, model, visibleModels, modelOptions.models, channels, designChannelId])
|
|
234
275
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
for (const alias of
|
|
240
|
-
|
|
276
|
+
const fieldPresets = useMemo(() => {
|
|
277
|
+
if (mode === 'voice_design') return []
|
|
278
|
+
if (compareMode) {
|
|
279
|
+
const presets: string[] = []
|
|
280
|
+
for (const alias of compareModels) {
|
|
281
|
+
const entry = modelOptions.models.find(candidate => candidate.alias === alias)
|
|
282
|
+
if (entry !== undefined && !presets.includes(entry.preset)) presets.push(entry.preset)
|
|
241
283
|
}
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
|
|
284
|
+
if (presets.length === 0) return [currentPreset].filter(value => value !== '')
|
|
285
|
+
return presets
|
|
286
|
+
}
|
|
287
|
+
return [currentPreset].filter(value => value !== '')
|
|
288
|
+
}, [mode, compareMode, compareModels, modelOptions.models, currentPreset])
|
|
245
289
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
290
|
+
// 按(模式 × 渠道集合)计算全局字段:对比模式只留共有字段,独有字段在覆盖矩阵中。
|
|
291
|
+
const globalSpecs = useMemo(() => globalFieldSpecs(mode, fieldPresets), [mode, fieldPresets])
|
|
292
|
+
|
|
293
|
+
// 模型下拉按渠道分组。
|
|
294
|
+
const groupedModels = useMemo(() => {
|
|
295
|
+
const groups: Array<{ channelId: string; channelName: string; models: Array<{ alias: string }> }> = []
|
|
296
|
+
for (const entry of modelOptions.models) {
|
|
297
|
+
let group = groups.find(candidate => candidate.channelId === entry.channelId)
|
|
298
|
+
if (group === undefined) {
|
|
299
|
+
group = { channelId: entry.channelId, channelName: entry.channelName, models: [] }
|
|
300
|
+
groups.push(group)
|
|
301
|
+
}
|
|
302
|
+
group.models.push({ alias: entry.alias })
|
|
303
|
+
}
|
|
304
|
+
return groups.map(group => ({ ...group, models: group.models.filter(entry => visibleModels.includes(entry.alias)) })).filter(group => group.models.length > 0)
|
|
305
|
+
}, [modelOptions.models, visibleModels])
|
|
252
306
|
|
|
253
307
|
useEffect(() => {
|
|
254
308
|
if (visibleModels.length > 0 && !visibleModels.includes(model)) {
|
|
@@ -256,103 +310,345 @@ export function StudioView(props: {
|
|
|
256
310
|
}
|
|
257
311
|
}, [visibleModels, model])
|
|
258
312
|
|
|
313
|
+
// 切模式时清空对比结果(参数含义变了)
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
setCompareModels(current => current.filter(item => visibleModels.includes(item)))
|
|
316
|
+
}, [mode, visibleModels])
|
|
317
|
+
|
|
318
|
+
// 进入对比模式时确保至少选中 2 个有效模型
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (!compareMode) return
|
|
321
|
+
setCompareModels(current => {
|
|
322
|
+
const valid = current.filter(item => visibleModels.includes(item))
|
|
323
|
+
const rest = visibleModels.filter(item => !valid.includes(item))
|
|
324
|
+
while (valid.length < 2 && rest.length > 0) valid.push(rest.shift()!)
|
|
325
|
+
return valid
|
|
326
|
+
})
|
|
327
|
+
}, [compareMode, visibleModels])
|
|
328
|
+
|
|
259
329
|
// 资源库「用此音色」回填
|
|
260
330
|
useEffect(() => {
|
|
261
331
|
if (reuse === undefined || reuse === null) return
|
|
262
332
|
setMode(reuse.mode)
|
|
263
333
|
if (reuse.voiceId !== undefined || reuse.voice !== undefined) setVoice(reuse.voiceId ?? reuse.voice ?? '')
|
|
264
|
-
if (reuse.model !== undefined && reuse.model !== '')
|
|
265
|
-
setModel(reuse.model)
|
|
266
|
-
setSelectedModels([reuse.model])
|
|
267
|
-
}
|
|
334
|
+
if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
|
|
268
335
|
}, [reuse?.nonce])
|
|
269
336
|
|
|
270
|
-
|
|
337
|
+
/** Build the shared generation request for one model. */
|
|
338
|
+
const requestOf = (modelName: string): GenerateAudioRequest => ({
|
|
339
|
+
mode,
|
|
340
|
+
model: modelName,
|
|
341
|
+
prompt: prompt.trim(),
|
|
342
|
+
saveToLibrary,
|
|
343
|
+
...(mode === 'voice_design' && designChannelId !== '' ? { channelId: designChannelId } : {}),
|
|
344
|
+
...(previewText.trim() !== '' ? { previewText: previewText.trim() } : {}),
|
|
345
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
346
|
+
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
347
|
+
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
348
|
+
...(lyrics.trim() !== '' ? { lyrics: lyrics.trim() } : {}),
|
|
349
|
+
...(instrumental ? { isInstrumental: true } : {}),
|
|
350
|
+
...(loop ? { loop: true } : {}),
|
|
351
|
+
...(promptInfluence.trim() !== '' ? { promptInfluence: Number(promptInfluence) } : {}),
|
|
352
|
+
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
353
|
+
...(emotion.trim() !== '' ? { emotion: emotion.trim() } : {}),
|
|
354
|
+
...(vol.trim() !== '' ? { vol: Number(vol) } : {}),
|
|
355
|
+
...(pitch.trim() !== '' ? { pitch: Number(pitch) } : {}),
|
|
356
|
+
...(toneText.trim() !== '' ? { pronunciationTone: toneText.split('\n').map(item => item.trim()).filter(item => item !== '') } : {}),
|
|
357
|
+
...(sampleRate.trim() !== '' ? { sampleRate: Number(sampleRate) } : {}),
|
|
358
|
+
...(bitrate.trim() !== '' ? { bitrate: Number(bitrate) } : {}),
|
|
359
|
+
...(audioChannel.trim() !== '' ? { audioChannel: Number(audioChannel) } : {}),
|
|
360
|
+
...(subtitle ? { subtitleEnable: true } : {}),
|
|
361
|
+
...(seed.trim() !== '' ? { seed: Number(seed) } : {}),
|
|
362
|
+
...(steps.trim() !== '' ? { steps: Number(steps) } : {}),
|
|
363
|
+
...(cfgScale.trim() !== '' ? { cfgScale: Number(cfgScale) } : {}),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const applyResponse = (response: Awaited<ReturnType<AudiogenApi['generate']>>): GeneratedAudio[] => {
|
|
367
|
+
const generated = response.outputs ?? []
|
|
368
|
+
if ((response.resources?.length ?? 0) > 0 && saveToLibrary) {
|
|
369
|
+
setSavedIds(current => new Set([...current, ...generated.map(item => item.id)]))
|
|
370
|
+
props.showToast('已保存到资源库')
|
|
371
|
+
props.onLibraryChanged()
|
|
372
|
+
}
|
|
373
|
+
reload()
|
|
374
|
+
return generated
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const patchTask = (taskId: string, fn: (task: StudioTask) => StudioTask): void => {
|
|
378
|
+
setTasks(current => current.map(task => task.id === taskId ? fn(task) : task))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 提交即建任务:非阻塞,可继续发起其他生成;并发由宿主「最大并发生成数」闸门控制。 */
|
|
382
|
+
const submit = (): void => {
|
|
271
383
|
if (prompt.trim() === '') {
|
|
272
384
|
setError(tt('prompt.required'))
|
|
273
385
|
return
|
|
274
386
|
}
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
387
|
+
const isCompare = compareMode && needModel
|
|
388
|
+
const models = isCompare ? (compareModels.length >= 2 ? compareModels : visibleModels.slice(0, 2)) : []
|
|
389
|
+
if (isCompare && models.length < 2) {
|
|
390
|
+
setError('请至少选择 2 个模型进行对比')
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
const singleModel = isCompare ? '' : (model || visibleModels[0] || '')
|
|
394
|
+
if (!isCompare && singleModel === '') {
|
|
282
395
|
setError('当前模式暂无可用模型')
|
|
283
396
|
return
|
|
284
397
|
}
|
|
285
|
-
setLoading(true)
|
|
286
398
|
setError(null)
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
399
|
+
const taskId = `t-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
400
|
+
const planModels = isCompare ? models : [singleModel]
|
|
401
|
+
// 参数快照:任务启动后表单再变化不影响本次生成;每模型再叠加各自覆盖。
|
|
402
|
+
const plan = planModels.map(modelName => ({
|
|
403
|
+
model: modelName,
|
|
404
|
+
request: { ...requestOf(modelName), taskId, ...overrideSpread(overrides[modelName] ?? {}) },
|
|
405
|
+
}))
|
|
406
|
+
const task: StudioTask = {
|
|
407
|
+
id: taskId,
|
|
408
|
+
mode,
|
|
409
|
+
prompt: prompt.trim(),
|
|
410
|
+
kind: isCompare ? 'compare' : 'single',
|
|
411
|
+
label: isCompare ? `对比 ${models.join(' / ')}` : singleModel,
|
|
412
|
+
status: 'running',
|
|
413
|
+
progress: { done: 0, total: plan.length, current: '' },
|
|
414
|
+
startedAt: Date.now(),
|
|
415
|
+
groups: planModels.map(modelName => ({ model: modelName, state: 'waiting' as const, outputs: [] as GeneratedAudio[] })),
|
|
416
|
+
}
|
|
417
|
+
setTasks(current => [task, ...current])
|
|
418
|
+
void runTask(taskId, plan)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** 执行一个任务:并行发起(宿主闸门限流),进度回写;支持取消。 */
|
|
422
|
+
const runTask = async (taskId: string, plan: Array<{ model: string; request: GenerateAudioRequest }>): Promise<void> => {
|
|
423
|
+
const controllers: AbortController[] = []
|
|
424
|
+
taskControllers.current.set(taskId, controllers)
|
|
425
|
+
const taskIsFinished = (): 'running' | 'cancelled' | 'pending' => {
|
|
426
|
+
const task = tasksRef.current.find(candidate => candidate.id === taskId)
|
|
427
|
+
if (task === undefined) return 'pending'
|
|
428
|
+
if (task.status === 'cancelled') return 'cancelled'
|
|
429
|
+
return 'running'
|
|
430
|
+
}
|
|
431
|
+
await Promise.allSettled(plan.map(async (step) => {
|
|
432
|
+
const controller = new AbortController()
|
|
433
|
+
controllers.push(controller)
|
|
434
|
+
patchTask(taskId, task => ({
|
|
435
|
+
...task,
|
|
436
|
+
progress: { ...task.progress, current: step.model },
|
|
437
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'running' as const, error: undefined } : group),
|
|
438
|
+
}))
|
|
439
|
+
try {
|
|
440
|
+
const response = await api.generate(step.request, controller.signal)
|
|
441
|
+
if (!response.ok) throw new Error(response.message ?? '生成失败')
|
|
442
|
+
const generated = applyResponse(response)
|
|
443
|
+
patchTask(taskId, task => ({
|
|
444
|
+
...task,
|
|
445
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'done' as const, outputs: generated } : group),
|
|
446
|
+
}))
|
|
447
|
+
} catch (err) {
|
|
448
|
+
if (controller.signal.aborted === true || taskIsFinished() === 'cancelled') {
|
|
449
|
+
patchTask(taskId, task => ({
|
|
450
|
+
...task,
|
|
451
|
+
groups: task.groups.map(group => group.model === step.model ? { ...group, state: 'cancelled' as const, error: undefined } : group),
|
|
452
|
+
}))
|
|
453
|
+
} else {
|
|
454
|
+
patchTask(taskId, task => ({
|
|
455
|
+
...task,
|
|
456
|
+
groups: task.groups.map(group => group.model === step.model
|
|
457
|
+
? { ...group, state: 'error' as const, error: err instanceof Error ? err.message : String(err) }
|
|
458
|
+
: group),
|
|
459
|
+
}))
|
|
328
460
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if ((response.resources?.length ?? 0) > 0 && saveToLibrary) anySaved = true
|
|
332
|
-
}
|
|
333
|
-
setResults(gathered)
|
|
334
|
-
if (anySaved) {
|
|
335
|
-
setSavedIds(current => new Set([...current, ...gathered.flatMap(group => group.outputs.map(item => item.id))]))
|
|
336
|
-
props.showToast('已保存到资源库')
|
|
337
|
-
props.onLibraryChanged()
|
|
461
|
+
} finally {
|
|
462
|
+
patchTask(taskId, task => ({ ...task, progress: { ...task.progress, done: task.progress.done + 1, current: '' } }))
|
|
338
463
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
464
|
+
}))
|
|
465
|
+
taskControllers.current.delete(taskId)
|
|
466
|
+
setTasks(current => current.map(task => {
|
|
467
|
+
if (task.id !== taskId) return task
|
|
468
|
+
if (task.status === 'cancelled') return { ...task, finishedAt: task.finishedAt ?? Date.now() }
|
|
469
|
+
const done = task.groups.filter(group => group.state === 'done').length
|
|
470
|
+
const failed = task.groups.filter(group => group.state === 'error').length
|
|
471
|
+
const cancelled = task.groups.filter(group => group.state === 'cancelled').length
|
|
472
|
+
if (done > 0) return { ...task, status: 'done', finishedAt: Date.now() }
|
|
473
|
+
if (cancelled === task.groups.length) return { ...task, status: 'cancelled', finishedAt: Date.now() }
|
|
474
|
+
return {
|
|
475
|
+
...task,
|
|
476
|
+
status: 'failed',
|
|
477
|
+
finishedAt: Date.now(),
|
|
478
|
+
error: failed > 0 ? task.groups.filter(group => group.state === 'error').map(group => `「${group.model}」${group.error ?? ''}`).join(';') : '生成失败',
|
|
342
479
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
480
|
+
}))
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** 取消任务:本地中止在途 fetch + 宿主中断上游请求,剩余模型跳过。 */
|
|
484
|
+
const cancelTask = (taskId: string): void => {
|
|
485
|
+
for (const controller of taskControllers.current.get(taskId) ?? []) controller.abort()
|
|
486
|
+
void api.cancelTask(taskId)
|
|
487
|
+
patchTask(taskId, task => ({
|
|
488
|
+
...task,
|
|
489
|
+
status: 'cancelled',
|
|
490
|
+
finishedAt: Date.now(),
|
|
491
|
+
progress: { ...task.progress, current: '' },
|
|
492
|
+
groups: task.groups.map(group => group.state === 'waiting' || group.state === 'running'
|
|
493
|
+
? { ...group, state: 'cancelled' as const, error: undefined }
|
|
494
|
+
: group),
|
|
495
|
+
}))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const removeTask = (taskId: string): void => {
|
|
499
|
+
setTasks(current => current.filter(task => task.id !== taskId))
|
|
350
500
|
}
|
|
351
501
|
|
|
352
502
|
const openSaveDialog = (files: GeneratedAudio[], context: SaveDialogContext): void => {
|
|
353
503
|
setSaveDialog({ files, context })
|
|
354
504
|
}
|
|
355
505
|
|
|
506
|
+
/** 按字段规格渲染一个表单控件(渠道/模式感知:字段集由 globalSpecs 决定)。 */
|
|
507
|
+
const renderField = (spec: FieldSpec): React.JSX.Element => {
|
|
508
|
+
const common = { className: css.input, disabled: false }
|
|
509
|
+
switch (spec.key) {
|
|
510
|
+
case 'voice':
|
|
511
|
+
return (
|
|
512
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
513
|
+
<span>{spec.label}</span>
|
|
514
|
+
<input className={css.input} value={voice} onChange={event => setVoice(event.target.value)} placeholder={currentPreset === 'minimax' ? 'male-qn-qingse / female-shaonv' : 'alloy / 自定义音色'} />
|
|
515
|
+
</label>
|
|
516
|
+
)
|
|
517
|
+
case 'speed':
|
|
518
|
+
return (
|
|
519
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
520
|
+
<span>{spec.label}</span>
|
|
521
|
+
<input className={css.input} type="number" step={spec.step ?? 0.1} min={spec.min} max={spec.max} value={speed} onChange={event => setSpeed(event.target.value)} placeholder={spec.placeholder} />
|
|
522
|
+
</label>
|
|
523
|
+
)
|
|
524
|
+
case 'duration':
|
|
525
|
+
return (
|
|
526
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
527
|
+
<span>{spec.label}</span>
|
|
528
|
+
<input className={css.input} type="number" step={spec.step ?? 1} min={spec.min} max={spec.max} value={duration} onChange={event => setDuration(event.target.value)} placeholder={spec.placeholder} />
|
|
529
|
+
</label>
|
|
530
|
+
)
|
|
531
|
+
case 'format':
|
|
532
|
+
return (
|
|
533
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
534
|
+
<span>{spec.label}</span>
|
|
535
|
+
<select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
|
|
536
|
+
{(spec.options ?? ['mp3', 'wav', 'pcm']).map(option => <option key={option} value={option}>{option}</option>)}
|
|
537
|
+
</select>
|
|
538
|
+
</label>
|
|
539
|
+
)
|
|
540
|
+
case 'lyrics':
|
|
541
|
+
return (
|
|
542
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
543
|
+
<span>{spec.label}</span>
|
|
544
|
+
<textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
|
|
545
|
+
</label>
|
|
546
|
+
)
|
|
547
|
+
case 'instrumental':
|
|
548
|
+
return (
|
|
549
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
550
|
+
<input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
|
|
551
|
+
<span>{spec.label}(是:{currentPreset === 'elevenlabs' ? 'force_instrumental' : 'is_instrumental'})</span>
|
|
552
|
+
</label>
|
|
553
|
+
)
|
|
554
|
+
case 'sampleRate':
|
|
555
|
+
return (
|
|
556
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
557
|
+
<span>{spec.label}</span>
|
|
558
|
+
<select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
|
|
559
|
+
<option value="">默认(44100)</option>
|
|
560
|
+
{(spec.options ?? []).map(option => <option key={option} value={option}>{option}</option>)}
|
|
561
|
+
</select>
|
|
562
|
+
</label>
|
|
563
|
+
)
|
|
564
|
+
case 'bitrate':
|
|
565
|
+
return (
|
|
566
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
567
|
+
<span>{spec.label}</span>
|
|
568
|
+
<select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
|
|
569
|
+
<option value="">默认(256000)</option>
|
|
570
|
+
{(spec.options ?? []).map(option => <option key={option} value={option}>{option}</option>)}
|
|
571
|
+
</select>
|
|
572
|
+
</label>
|
|
573
|
+
)
|
|
574
|
+
case 'audioChannel':
|
|
575
|
+
return (
|
|
576
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
577
|
+
<span>{spec.label}</span>
|
|
578
|
+
<select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
|
|
579
|
+
<option value="">默认(1)</option>
|
|
580
|
+
<option value="1">1</option>
|
|
581
|
+
<option value="2">2</option>
|
|
582
|
+
</select>
|
|
583
|
+
</label>
|
|
584
|
+
)
|
|
585
|
+
case 'emotion':
|
|
586
|
+
return (
|
|
587
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
588
|
+
<span>{spec.label}</span>
|
|
589
|
+
<input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder={spec.placeholder} />
|
|
590
|
+
</label>
|
|
591
|
+
)
|
|
592
|
+
case 'vol':
|
|
593
|
+
return (
|
|
594
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
595
|
+
<span>{spec.label}</span>
|
|
596
|
+
<input className={css.input} type="number" min={spec.min} max={spec.max} step={spec.step ?? 0.5} value={vol} onChange={event => setVol(event.target.value)} placeholder={spec.placeholder} />
|
|
597
|
+
</label>
|
|
598
|
+
)
|
|
599
|
+
case 'pitch':
|
|
600
|
+
return (
|
|
601
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
602
|
+
<span>{spec.label}</span>
|
|
603
|
+
<input className={css.input} type="number" min={spec.min} max={spec.max} value={pitch} onChange={event => setPitch(event.target.value)} placeholder={spec.placeholder} />
|
|
604
|
+
</label>
|
|
605
|
+
)
|
|
606
|
+
case 'toneText':
|
|
607
|
+
return (
|
|
608
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
609
|
+
<span>{spec.label}</span>
|
|
610
|
+
<textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={spec.placeholder} />
|
|
611
|
+
</label>
|
|
612
|
+
)
|
|
613
|
+
case 'subtitle':
|
|
614
|
+
return (
|
|
615
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
616
|
+
<input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
|
|
617
|
+
<span>{spec.label}</span>
|
|
618
|
+
</label>
|
|
619
|
+
)
|
|
620
|
+
case 'loop':
|
|
621
|
+
return (
|
|
622
|
+
<label className={css.checkbox} key={spec.key} title={spec.hint}>
|
|
623
|
+
<input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
|
|
624
|
+
<span>{spec.label}</span>
|
|
625
|
+
</label>
|
|
626
|
+
)
|
|
627
|
+
case 'promptInfluence':
|
|
628
|
+
return (
|
|
629
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
630
|
+
<span>{spec.label}</span>
|
|
631
|
+
<input className={css.input} type="number" step={spec.step ?? 0.1} min={spec.min} max={spec.max} value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder={spec.placeholder} />
|
|
632
|
+
</label>
|
|
633
|
+
)
|
|
634
|
+
case 'seed':
|
|
635
|
+
case 'steps':
|
|
636
|
+
case 'cfgScale': {
|
|
637
|
+
const value = spec.key === 'seed' ? seed : spec.key === 'steps' ? steps : cfgScale
|
|
638
|
+
const setter = spec.key === 'seed' ? setSeed : spec.key === 'steps' ? setSteps : setCfgScale
|
|
639
|
+
// 覆盖矩阵中的数字输入用统一 state(cfgScale 入参键 cfg_scale)。
|
|
640
|
+
return (
|
|
641
|
+
<label className={css.label} key={spec.key} title={spec.hint}>
|
|
642
|
+
<span>{spec.label}</span>
|
|
643
|
+
<input {...common} type="number" step={spec.step ?? 1} min={spec.min} max={spec.max} value={String(value)} placeholder={spec.placeholder} onChange={event => setter(event.target.value)} />
|
|
644
|
+
</label>
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
default:
|
|
648
|
+
return <span key={spec.key}>{spec.label}</span>
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
356
652
|
const onDialogSaved = (entry: LibraryEntry): void => {
|
|
357
653
|
if (saveDialog !== null) {
|
|
358
654
|
setSavedIds(current => new Set([...current, ...saveDialog.files.map(file => file.id)]))
|
|
@@ -362,6 +658,49 @@ export function StudioView(props: {
|
|
|
362
658
|
props.onLibraryChanged()
|
|
363
659
|
}
|
|
364
660
|
|
|
661
|
+
/** One result card (single mode shares it with the compare groups). */
|
|
662
|
+
const renderAudioCard = (audio: GeneratedAudio, index: number, label: string, contextModel: string): React.JSX.Element => {
|
|
663
|
+
const saved = savedIds.has(audio.id)
|
|
664
|
+
return (
|
|
665
|
+
<div className={css.audioCard} key={`${label}-${audio.id}`} data-saved={saved ? 'true' : 'false'}>
|
|
666
|
+
<div className={css.audioCardHead}>
|
|
667
|
+
{audio.voiceId !== undefined ? <span className={css.voiceIdChip} title="新音色 ID">新音色 {audio.voiceId}</span> : null}
|
|
668
|
+
{saved ? (
|
|
669
|
+
<span className={css.savedChip}><CheckIcon /> 已入库</span>
|
|
670
|
+
) : null}
|
|
671
|
+
<span className={css.audioCardIndex}>#{index + 1}</span>
|
|
672
|
+
</div>
|
|
673
|
+
<AudioPlayer src={dataUrlOf(audio)} itemKey={`${label}-${audio.id}`} />
|
|
674
|
+
<div className={css.audioCardActions}>
|
|
675
|
+
<a className={css.ghostButton} href={dataUrlOf(audio)} download={`generated-${index + 1}.${audio.mime.split('/')[1]?.replace('mpeg', 'mp3') ?? 'mp3'}`}>
|
|
676
|
+
<DownloadIcon /> 下载
|
|
677
|
+
</a>
|
|
678
|
+
{saved ? (
|
|
679
|
+
<button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
|
|
680
|
+
<CheckIcon /> 已入库
|
|
681
|
+
</button>
|
|
682
|
+
) : (
|
|
683
|
+
<button
|
|
684
|
+
type="button"
|
|
685
|
+
className={css.ghostButton}
|
|
686
|
+
onClick={() => openSaveDialog([audio], {
|
|
687
|
+
mode,
|
|
688
|
+
prompt: prompt.trim(),
|
|
689
|
+
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
690
|
+
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
691
|
+
...(contextModel !== '' ? { model: contextModel } : {}),
|
|
692
|
+
...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
|
|
693
|
+
params: requestOf(contextModel) as unknown as Record<string, unknown>,
|
|
694
|
+
})}
|
|
695
|
+
>
|
|
696
|
+
<StarIcon /> 加入资源库
|
|
697
|
+
</button>
|
|
698
|
+
)}
|
|
699
|
+
</div>
|
|
700
|
+
</div>
|
|
701
|
+
)
|
|
702
|
+
}
|
|
703
|
+
|
|
365
704
|
const modeLabel = useMemo(() => {
|
|
366
705
|
if (mode === 'tts') return tt('mode.tts')
|
|
367
706
|
if (mode === 'music') return tt('mode.music')
|
|
@@ -369,7 +708,60 @@ export function StudioView(props: {
|
|
|
369
708
|
return tt('mode.voiceDesign')
|
|
370
709
|
}, [mode])
|
|
371
710
|
|
|
711
|
+
/** 历史记录:按 taskId 聚合出「单条 / 对比任务卡」两种条目。 */
|
|
712
|
+
const historyItems = useMemo((): Array<{
|
|
713
|
+
key: string
|
|
714
|
+
kind: 'single' | 'compare'
|
|
715
|
+
mode: AudioMode
|
|
716
|
+
prompt: string
|
|
717
|
+
createdAt: number
|
|
718
|
+
entry: HistoryEntry
|
|
719
|
+
models: Array<{ model: string; channel?: string; entry: HistoryEntry }>
|
|
720
|
+
}> => {
|
|
721
|
+
const taskCounts = new Map<string, number>()
|
|
722
|
+
for (const entry of entries) {
|
|
723
|
+
const taskId = taskIdOf(entry)
|
|
724
|
+
if (taskId !== '') taskCounts.set(taskId, (taskCounts.get(taskId) ?? 0) + 1)
|
|
725
|
+
}
|
|
726
|
+
const merged: Array<{
|
|
727
|
+
key: string
|
|
728
|
+
kind: 'single' | 'compare'
|
|
729
|
+
mode: AudioMode
|
|
730
|
+
prompt: string
|
|
731
|
+
createdAt: number
|
|
732
|
+
entry: HistoryEntry
|
|
733
|
+
models: Array<{ model: string; channel?: string; entry: HistoryEntry }>
|
|
734
|
+
}> = []
|
|
735
|
+
const byTask = new Map<string, typeof merged[number]>()
|
|
736
|
+
for (const entry of entries) {
|
|
737
|
+
const taskId = taskIdOf(entry)
|
|
738
|
+
if (taskId !== '' && (taskCounts.get(taskId) ?? 0) > 1) {
|
|
739
|
+
const existing = byTask.get(taskId)
|
|
740
|
+
if (existing !== undefined) {
|
|
741
|
+
existing.models.push({ model: entry.model, ...(entry.channel === undefined ? {} : { channel: entry.channel }), entry })
|
|
742
|
+
if (entry.createdAt > existing.createdAt) existing.createdAt = entry.createdAt
|
|
743
|
+
continue
|
|
744
|
+
}
|
|
745
|
+
const item: typeof merged[number] = {
|
|
746
|
+
key: taskId,
|
|
747
|
+
kind: 'compare',
|
|
748
|
+
mode: entry.mode,
|
|
749
|
+
prompt: entry.prompt,
|
|
750
|
+
createdAt: entry.createdAt,
|
|
751
|
+
entry,
|
|
752
|
+
models: [{ model: entry.model, ...(entry.channel === undefined ? {} : { channel: entry.channel }), entry }],
|
|
753
|
+
}
|
|
754
|
+
byTask.set(taskId, item)
|
|
755
|
+
merged.push(item)
|
|
756
|
+
continue
|
|
757
|
+
}
|
|
758
|
+
merged.push({ key: entry.id, kind: 'single', mode: entry.mode, prompt: entry.prompt, createdAt: entry.createdAt, entry, models: [] })
|
|
759
|
+
}
|
|
760
|
+
return merged.sort((left, right) => right.createdAt - left.createdAt)
|
|
761
|
+
}, [entries])
|
|
762
|
+
|
|
372
763
|
const needModel = mode !== 'voice_design'
|
|
764
|
+
const runningCount = tasks.filter(task => task.status === 'running').length
|
|
373
765
|
|
|
374
766
|
return (
|
|
375
767
|
<div className={css.studio}>
|
|
@@ -415,288 +807,188 @@ export function StudioView(props: {
|
|
|
415
807
|
) : null}
|
|
416
808
|
|
|
417
809
|
{needModel ? (
|
|
418
|
-
<label className={css.
|
|
419
|
-
<
|
|
420
|
-
<
|
|
421
|
-
{visibleModels.length === 0 ? <p className={css.hint}>(当前模式暂无可用模型)</p> : null}
|
|
422
|
-
{visibleModels.map(item => (
|
|
423
|
-
<label key={item} className={css.checkbox}>
|
|
424
|
-
<input
|
|
425
|
-
type="checkbox"
|
|
426
|
-
checked={selectedModels.includes(item)}
|
|
427
|
-
onChange={event => {
|
|
428
|
-
const checked = event.target.checked
|
|
429
|
-
setSelectedModels(current => checked ? [...new Set([...current, item])] : current.filter(alias => alias !== item))
|
|
430
|
-
if (checked && (model === '' || !visibleModels.includes(model))) setModel(item)
|
|
431
|
-
}}
|
|
432
|
-
/>
|
|
433
|
-
<span title="点击生成对比">{item}</span>
|
|
434
|
-
</label>
|
|
435
|
-
))}
|
|
436
|
-
</div>
|
|
810
|
+
<label className={css.checkbox} title="选择多个模型,用相同参数逐个生成,便于对比效果">
|
|
811
|
+
<input type="checkbox" checked={compareMode} onChange={event => setCompareMode(event.target.checked)} />
|
|
812
|
+
<span>模型对比(多模型同参数生成)</span>
|
|
437
813
|
</label>
|
|
438
814
|
) : null}
|
|
439
815
|
|
|
440
|
-
{needModel
|
|
441
|
-
|
|
442
|
-
<
|
|
443
|
-
|
|
444
|
-
<div className={css.
|
|
445
|
-
|
|
446
|
-
|
|
816
|
+
{needModel ? (
|
|
817
|
+
compareMode ? (
|
|
818
|
+
<div className={css.compareBox}>
|
|
819
|
+
<span className={css.label}>对比模型(至少 2 个,最多 4 个)</span>
|
|
820
|
+
<div className={css.compareChips}>
|
|
821
|
+
{visibleModels.map(item => (
|
|
822
|
+
<button
|
|
823
|
+
key={item}
|
|
824
|
+
type="button"
|
|
825
|
+
className={css.compareChip}
|
|
826
|
+
data-active={compareModels.includes(item) ? 'true' : 'false'}
|
|
827
|
+
onClick={() => setCompareModels(current => current.includes(item)
|
|
828
|
+
? current.filter(candidate => candidate !== item)
|
|
829
|
+
: current.length < 4 ? [...current, item] : current)}
|
|
830
|
+
>
|
|
831
|
+
{item}
|
|
832
|
+
</button>
|
|
833
|
+
))}
|
|
834
|
+
{visibleModels.length === 0 ? <p className={css.hint}>当前模式暂无可用模型</p> : null}
|
|
447
835
|
</div>
|
|
448
|
-
{
|
|
449
|
-
<
|
|
450
|
-
|
|
451
|
-
{
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
) : (
|
|
461
|
-
<input
|
|
462
|
-
className={css.input}
|
|
463
|
-
type={row.type === 'number' ? 'number' : 'text'}
|
|
464
|
-
value={value}
|
|
465
|
-
placeholder={row.placeholder ?? '自动'}
|
|
466
|
-
onChange={event => setOverrideValue(item, row.key, event.target.value)}
|
|
467
|
-
/>
|
|
468
|
-
)}
|
|
836
|
+
<details className={css.advanced}>
|
|
837
|
+
<summary>每模型参数覆盖(默认自动:沿用上方相同配置)</summary>
|
|
838
|
+
<div className={css.overrideTable}>
|
|
839
|
+
<div className={css.overrideRow}>
|
|
840
|
+
<span className={`${css.overrideCell} ${css.overrideCellHead}`} />
|
|
841
|
+
{compareModels.map(item => <span key={item} className={`${css.overrideCell} ${css.overrideCellHead}`}>{item}</span>)}
|
|
842
|
+
</div>
|
|
843
|
+
{overrideRowSpecs(mode).map(row => (
|
|
844
|
+
<div key={row.key} className={css.overrideRow}>
|
|
845
|
+
<span className={css.overrideCell} title={`${row.hint ?? ''}${row.presets.length < 3 ? `(适用:${row.presets.map(presetLabel).join('/')})` : ''}`}>
|
|
846
|
+
{row.label}
|
|
847
|
+
{row.presets.length < 3 ? <span className={css.overrideOnly}> 仅{row.presets.map(presetLabel).join('/')}</span> : null}
|
|
469
848
|
</span>
|
|
470
|
-
|
|
471
|
-
|
|
849
|
+
{compareModels.map(item => {
|
|
850
|
+
const entry = modelOptions.models.find(candidate => candidate.alias === item)
|
|
851
|
+
const applicable = entry !== undefined && row.presets.includes(entry.preset)
|
|
852
|
+
if (!applicable) {
|
|
853
|
+
return <span key={item} className={css.overrideCell}><span className={css.overrideDash}>—</span></span>
|
|
854
|
+
}
|
|
855
|
+
const value = overrides[item]?.[row.key] ?? ''
|
|
856
|
+
return (
|
|
857
|
+
<span key={item} className={css.overrideCell}>
|
|
858
|
+
{row.type === 'select' ? (
|
|
859
|
+
<select className={css.input} value={value} onChange={event => {
|
|
860
|
+
setOverrides(current => ({
|
|
861
|
+
...current,
|
|
862
|
+
[item]: { ...(current[item] ?? {}), [row.key]: event.target.value },
|
|
863
|
+
}))
|
|
864
|
+
}}>
|
|
865
|
+
<option value="">自动</option>
|
|
866
|
+
{row.options!.map(option => <option key={option} value={option}>{option}</option>)}
|
|
867
|
+
</select>
|
|
868
|
+
) : (
|
|
869
|
+
<input
|
|
870
|
+
className={css.input}
|
|
871
|
+
type={row.type === 'number' ? 'number' : 'text'}
|
|
872
|
+
value={value}
|
|
873
|
+
placeholder={row.placeholder ?? '自动'}
|
|
874
|
+
onChange={event => {
|
|
875
|
+
setOverrides(current => ({
|
|
876
|
+
...current,
|
|
877
|
+
[item]: { ...(current[item] ?? {}), [row.key]: event.target.value },
|
|
878
|
+
}))
|
|
879
|
+
}}
|
|
880
|
+
/>
|
|
881
|
+
)}
|
|
882
|
+
</span>
|
|
883
|
+
)
|
|
884
|
+
})}
|
|
885
|
+
</div>
|
|
886
|
+
))}
|
|
472
887
|
</div>
|
|
473
|
-
|
|
888
|
+
</details>
|
|
474
889
|
</div>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
890
|
+
) : (
|
|
891
|
+
<label className={css.label}>
|
|
892
|
+
<span>{tt('model.label')}</span>
|
|
893
|
+
<select className={css.input} value={model} onChange={event => setModel(event.target.value)}>
|
|
894
|
+
{visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
|
|
895
|
+
{groupedModels.map(group => (
|
|
896
|
+
<optgroup key={group.channelId} label={group.channelName}>
|
|
897
|
+
{group.models.map(item => <option key={item.alias} value={item.alias}>{item.alias}</option>)}
|
|
898
|
+
</optgroup>
|
|
899
|
+
))}
|
|
900
|
+
</select>
|
|
901
|
+
</label>
|
|
902
|
+
)
|
|
483
903
|
) : null}
|
|
484
904
|
|
|
485
|
-
{
|
|
486
|
-
<label className={css.label}>
|
|
487
|
-
<span>{tt('speed.label')}</span>
|
|
488
|
-
<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" />
|
|
489
|
-
</label>
|
|
490
|
-
) : null}
|
|
905
|
+
{globalSpecs.filter(spec => spec.advanced !== true).map(spec => renderField(spec))}
|
|
491
906
|
|
|
492
|
-
{mode === 'tts' &&
|
|
907
|
+
{mode === 'tts' && globalSpecs.some(spec => spec.advanced === true) ? (
|
|
493
908
|
<details className={css.advanced}>
|
|
494
909
|
<summary>MiniMax 高级参数</summary>
|
|
495
|
-
|
|
496
|
-
<span>情绪 emotion</span>
|
|
497
|
-
<input className={css.input} value={emotion} onChange={event => setEmotion(event.target.value)} placeholder="happy / sad / angry / nervous…" />
|
|
498
|
-
</label>
|
|
499
|
-
<div className={css.row}>
|
|
500
|
-
<label className={css.label}>
|
|
501
|
-
<span>音量 vol (0-10)</span>
|
|
502
|
-
<input className={css.input} type="number" min="0" max="10" step="0.5" value={vol} onChange={event => setVol(event.target.value)} placeholder="1" />
|
|
503
|
-
</label>
|
|
504
|
-
<label className={css.label}>
|
|
505
|
-
<span>音调 pitch (-12~12)</span>
|
|
506
|
-
<input className={css.input} type="number" min="-12" max="12" value={pitch} onChange={event => setPitch(event.target.value)} placeholder="0" />
|
|
507
|
-
</label>
|
|
508
|
-
</div>
|
|
509
|
-
<div className={css.row}>
|
|
510
|
-
<label className={css.label}>
|
|
511
|
-
<span>采样率</span>
|
|
512
|
-
<input className={css.input} type="number" min="16000" max="48000" step="8000" value={sampleRate} onChange={event => setSampleRate(event.target.value)} placeholder="32000" />
|
|
513
|
-
</label>
|
|
514
|
-
<label className={css.label}>
|
|
515
|
-
<span>码率 bps</span>
|
|
516
|
-
<input className={css.input} type="number" min="64000" max="320000" step="8000" value={bitrate} onChange={event => setBitrate(event.target.value)} placeholder="128000" />
|
|
517
|
-
</label>
|
|
518
|
-
<label className={css.label}>
|
|
519
|
-
<span>声道</span>
|
|
520
|
-
<select className={css.input} value={audioChannel} onChange={event => setAudioChannel(event.target.value)}>
|
|
521
|
-
<option value="">默认(1)</option>
|
|
522
|
-
<option value="1">1</option>
|
|
523
|
-
<option value="2">2</option>
|
|
524
|
-
</select>
|
|
525
|
-
</label>
|
|
526
|
-
</div>
|
|
527
|
-
<label className={css.label}>
|
|
528
|
-
<span>发音词典(每行一条:"文字/读音")</span>
|
|
529
|
-
<textarea className={css.textarea} value={toneText} onChange={event => setToneText(event.target.value)} placeholder={'处理/(chu3)(li3)\n危险/dangerous'} />
|
|
530
|
-
</label>
|
|
531
|
-
<label className={css.checkbox}>
|
|
532
|
-
<input type="checkbox" checked={subtitle} onChange={event => setSubtitle(event.target.checked)} />
|
|
533
|
-
<span>生成字幕 subtitle_enable</span>
|
|
534
|
-
</label>
|
|
910
|
+
{globalSpecs.filter(spec => spec.advanced === true).map(spec => renderField(spec))}
|
|
535
911
|
</details>
|
|
536
912
|
) : null}
|
|
537
913
|
|
|
538
|
-
{mode === 'music' || mode === 'sfx' ? (
|
|
539
|
-
<label className={css.label}>
|
|
540
|
-
<span>{tt('duration.label')}</span>
|
|
541
|
-
<input className={css.input} type="number" step="1" min="1" max="120" value={duration} onChange={event => setDuration(event.target.value)} placeholder="30" />
|
|
542
|
-
</label>
|
|
543
|
-
) : null}
|
|
544
|
-
|
|
545
|
-
{mode === 'sfx' ? (
|
|
546
|
-
<>
|
|
547
|
-
<label className={css.checkbox}>
|
|
548
|
-
<input type="checkbox" checked={loop} onChange={event => setLoop(event.target.checked)} />
|
|
549
|
-
<span>循环音效 loop(无缝循环,需 eleven_text_to_sound_v2)</span>
|
|
550
|
-
</label>
|
|
551
|
-
<label className={css.label}>
|
|
552
|
-
<span>提示词影响度 prompt_influence (0-1)</span>
|
|
553
|
-
<input className={css.input} type="number" step="0.1" min="0" max="1" value={promptInfluence} onChange={event => setPromptInfluence(event.target.value)} placeholder="0.3" />
|
|
554
|
-
</label>
|
|
555
|
-
</>
|
|
556
|
-
) : null}
|
|
557
|
-
|
|
558
|
-
{mode === 'music' ? (
|
|
559
|
-
<>
|
|
560
|
-
<label className={css.label}>
|
|
561
|
-
<span>歌词(纯音乐模式可留空;多段用空行分隔)</span>
|
|
562
|
-
<textarea className={css.textarea} value={lyrics} onChange={event => setLyrics(event.target.value)} placeholder={'第一段歌词…\n\n第二段歌词…'} />
|
|
563
|
-
</label>
|
|
564
|
-
<label className={css.checkbox}>
|
|
565
|
-
<input type="checkbox" checked={instrumental} onChange={event => setInstrumental(event.target.checked)} />
|
|
566
|
-
<span>纯音乐(无歌词/人声)is_instrumental</span>
|
|
567
|
-
</label>
|
|
568
|
-
<div className={css.row}>
|
|
569
|
-
<label className={css.label}>
|
|
570
|
-
<span>采样率</span>
|
|
571
|
-
<select className={css.input} value={sampleRate} onChange={event => setSampleRate(event.target.value)}>
|
|
572
|
-
<option value="">默认(44100)</option>
|
|
573
|
-
<option value="16000">16000</option>
|
|
574
|
-
<option value="24000">24000</option>
|
|
575
|
-
<option value="32000">32000</option>
|
|
576
|
-
<option value="44100">44100</option>
|
|
577
|
-
</select>
|
|
578
|
-
</label>
|
|
579
|
-
<label className={css.label}>
|
|
580
|
-
<span>码率 bps</span>
|
|
581
|
-
<select className={css.input} value={bitrate} onChange={event => setBitrate(event.target.value)}>
|
|
582
|
-
<option value="">默认(256000)</option>
|
|
583
|
-
<option value="32000">32000</option>
|
|
584
|
-
<option value="64000">64000</option>
|
|
585
|
-
<option value="128000">128000</option>
|
|
586
|
-
<option value="256000">256000</option>
|
|
587
|
-
</select>
|
|
588
|
-
</label>
|
|
589
|
-
</div>
|
|
590
|
-
</>
|
|
591
|
-
) : null}
|
|
592
|
-
|
|
593
|
-
{needModel ? (
|
|
594
|
-
<label className={css.label}>
|
|
595
|
-
<span>{tt('format.label')}</span>
|
|
596
|
-
<select className={css.input} value={format} onChange={event => setFormat(event.target.value)}>
|
|
597
|
-
<option value="mp3">mp3</option>
|
|
598
|
-
<option value="wav">wav</option>
|
|
599
|
-
{mode === 'tts' ? (
|
|
600
|
-
<>
|
|
601
|
-
<option value="flac">flac</option>
|
|
602
|
-
<option value="ogg">ogg</option>
|
|
603
|
-
</>
|
|
604
|
-
) : null}
|
|
605
|
-
<option value="pcm">pcm</option>
|
|
606
|
-
</select>
|
|
607
|
-
</label>
|
|
608
|
-
) : null}
|
|
609
|
-
|
|
610
914
|
<label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
|
|
611
915
|
<input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
|
|
612
916
|
<span>生成后保存到资源库</span>
|
|
613
917
|
</label>
|
|
614
918
|
|
|
615
919
|
{!connected && <p className={css.hint}>{tt('config.missing')}</p>}
|
|
616
|
-
<button
|
|
617
|
-
|
|
920
|
+
<button
|
|
921
|
+
type="button"
|
|
922
|
+
className={css.generate}
|
|
923
|
+
disabled={!connected || (compareMode && needModel ? compareModels.length < 2 : needModel && visibleModels.length === 0)}
|
|
924
|
+
onClick={submit}
|
|
925
|
+
>
|
|
926
|
+
{(compareMode && needModel ? '对比生成' : tt('generate'))}
|
|
618
927
|
</button>
|
|
928
|
+
{runningCount > 0 ? <p className={css.hint}>进行中任务:{runningCount} 个(并发上限在「设置 → 插件 → AI 音频」调整)</p> : null}
|
|
619
929
|
</div>
|
|
620
930
|
|
|
621
931
|
<div className={css.resultCol}>
|
|
622
932
|
{error !== null ? <p className={css.error}>{error}</p> : null}
|
|
623
|
-
{
|
|
933
|
+
{tasks.length === 0 ? (
|
|
624
934
|
<div className={css.resultEmpty}>
|
|
625
935
|
<span className={css.resultEmptyIcon}>🎵</span>
|
|
626
936
|
<p>{tt('result.empty')}</p>
|
|
627
|
-
<p className={css.resultEmptyHint}
|
|
937
|
+
<p className={css.resultEmptyHint}>点击「开始生成」即创建一个任务,可同时进行多个;勾选「模型对比」用多个模型同参数生成对比</p>
|
|
628
938
|
</div>
|
|
629
939
|
) : (
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
940
|
+
<div className={css.taskList}>
|
|
941
|
+
{tasks.map(task => {
|
|
942
|
+
const elapsed = task.finishedAt !== undefined
|
|
943
|
+
? Math.round((task.finishedAt - task.startedAt) / 1000)
|
|
944
|
+
: Math.round((Date.now() - task.startedAt) / 1000)
|
|
945
|
+
const statusText = task.status === 'running'
|
|
946
|
+
? `生成中 ${task.progress.done}/${task.progress.total}${task.progress.current !== '' ? ` · ${task.progress.current}` : ''} · ${elapsed}s`
|
|
947
|
+
: task.status === 'done'
|
|
948
|
+
? `完成 · ${task.groups.reduce((sum, group) => sum + group.outputs.length, 0)} 段 · ${elapsed}s`
|
|
949
|
+
: task.status === 'cancelled'
|
|
950
|
+
? '已取消'
|
|
951
|
+
: '失败'
|
|
952
|
+
return (
|
|
953
|
+
<div className={css.taskCard} key={task.id} data-state={task.status}>
|
|
954
|
+
<div className={css.taskHead}>
|
|
955
|
+
<span className={css.resultModeChip}>{task.mode}</span>
|
|
956
|
+
<span className={css.taskLabel} title={task.prompt}>{task.label}</span>
|
|
957
|
+
<span className={css.taskStatus} data-state={task.status}>{statusText}</span>
|
|
958
|
+
<span className={css.taskActions}>
|
|
959
|
+
{task.status === 'running' ? (
|
|
960
|
+
<button type="button" className={css.ghostButton} onClick={() => cancelTask(task.id)}>取消</button>
|
|
961
|
+
) : null}
|
|
962
|
+
<button type="button" className={css.ghostButton} onClick={() => removeTask(task.id)}>移除</button>
|
|
963
|
+
</span>
|
|
642
964
|
</div>
|
|
643
|
-
{
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
<
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
{saved ? (
|
|
662
|
-
<button type="button" className={css.ghostButton} onClick={() => props.showToast('该音频已加入资源库')}>
|
|
663
|
-
<CheckIcon /> 已入库
|
|
664
|
-
</button>
|
|
665
|
-
) : (
|
|
666
|
-
<button
|
|
667
|
-
type="button"
|
|
668
|
-
className={css.ghostButton}
|
|
669
|
-
onClick={() => openSaveDialog([audio], {
|
|
670
|
-
mode,
|
|
671
|
-
prompt: prompt.trim(),
|
|
672
|
-
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
673
|
-
...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
|
|
674
|
-
...(group.model !== '' ? { model: group.model } : {}),
|
|
675
|
-
...(channels.length > 0 ? { channel: channels.find(candidate => candidate.id === (mode === 'voice_design' ? designChannelId : modelOptions.defaultChannelId))?.name ?? channels[0]?.name ?? '' } : {}),
|
|
676
|
-
params: {
|
|
677
|
-
mode,
|
|
678
|
-
model: mode === 'voice_design' ? '' : group.model,
|
|
679
|
-
prompt: prompt.trim(),
|
|
680
|
-
...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
|
|
681
|
-
...(speed.trim() !== '' ? { speed: Number(speed) } : {}),
|
|
682
|
-
...(duration.trim() !== '' ? { duration: Number(duration) } : {}),
|
|
683
|
-
...(format.trim() !== '' ? { format: format.trim() } : {}),
|
|
684
|
-
},
|
|
685
|
-
})}
|
|
686
|
-
>
|
|
687
|
-
<StarIcon /> 加入资源库
|
|
688
|
-
</button>
|
|
689
|
-
)}
|
|
690
|
-
</div>
|
|
965
|
+
{task.error !== undefined ? <p className={css.hint} data-error>{task.error}</p> : null}
|
|
966
|
+
<div className={css.compareBoard}>
|
|
967
|
+
{task.groups.map(group => (
|
|
968
|
+
<div className={css.compareGroup} key={group.model} data-state={group.state}>
|
|
969
|
+
<div className={css.compareGroupHead}>
|
|
970
|
+
<span className={css.compareModelName}>{group.model}</span>
|
|
971
|
+
<span className={css.compareState}>
|
|
972
|
+
{group.state === 'waiting' ? '等待中…'
|
|
973
|
+
: group.state === 'running' ? '生成中…'
|
|
974
|
+
: group.state === 'done' ? <><CheckIcon /> 完成</>
|
|
975
|
+
: group.state === 'cancelled' ? '已取消'
|
|
976
|
+
: '失败'}
|
|
977
|
+
</span>
|
|
978
|
+
</div>
|
|
979
|
+
{group.state === 'error' ? <p className={css.hint} data-error>{group.error}</p> : null}
|
|
980
|
+
{group.outputs.length > 0 ? (
|
|
981
|
+
<div className={css.audioList}>
|
|
982
|
+
{group.outputs.map((audio, index) => renderAudioCard(audio, index, group.model, group.model))}
|
|
691
983
|
</div>
|
|
692
|
-
)
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
984
|
+
) : null}
|
|
985
|
+
</div>
|
|
986
|
+
))}
|
|
987
|
+
</div>
|
|
696
988
|
</div>
|
|
697
|
-
)
|
|
698
|
-
|
|
699
|
-
|
|
989
|
+
)
|
|
990
|
+
})}
|
|
991
|
+
</div>
|
|
700
992
|
)}
|
|
701
993
|
</div>
|
|
702
994
|
|
|
@@ -706,22 +998,69 @@ export function StudioView(props: {
|
|
|
706
998
|
<button type="button" className={css.historyClear} onClick={clear}>清空</button>
|
|
707
999
|
</div>
|
|
708
1000
|
{entries.length === 0 ? <p className={css.historyEmpty}>{tt('history.empty')}</p> : (
|
|
709
|
-
|
|
710
|
-
{
|
|
711
|
-
|
|
712
|
-
<
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1001
|
+
<>
|
|
1002
|
+
<div className={css.historyTabs}>
|
|
1003
|
+
{(['all', 'tts', 'music', 'sfx', 'voice_design'] as const).map(tab => (
|
|
1004
|
+
<button
|
|
1005
|
+
key={tab}
|
|
1006
|
+
type="button"
|
|
1007
|
+
className={css.historyTab}
|
|
1008
|
+
data-active={historyTab === tab ? 'true' : 'false'}
|
|
1009
|
+
onClick={() => setHistoryTab(tab)}
|
|
1010
|
+
>
|
|
1011
|
+
{tab === 'all' ? '全部' : modeLabelOf(tab)}
|
|
1012
|
+
<span className={css.historyTabCount}>
|
|
1013
|
+
{tab === 'all' ? entries.length : historyItems.filter(item => item.mode === tab).length}
|
|
1014
|
+
</span>
|
|
1015
|
+
</button>
|
|
1016
|
+
))}
|
|
1017
|
+
</div>
|
|
1018
|
+
<div className={css.historyList}>
|
|
1019
|
+
{(historyTab === 'all' ? historyItems : historyItems.filter(item => item.mode === historyTab)).map(item => {
|
|
1020
|
+
if (item.kind === 'compare') {
|
|
1021
|
+
return (
|
|
1022
|
+
<details className={css.historyItem} key={item.key} open>
|
|
1023
|
+
<summary className={css.historyCompareSummary}>
|
|
1024
|
+
<span className={css.historyPrompt}>{item.prompt}</span>
|
|
1025
|
+
<span className={css.historyCompareBadge}>对比 · {item.models.length} 个模型</span>
|
|
1026
|
+
</summary>
|
|
1027
|
+
<div className={css.historyMeta}>{modeLabelOf(item.mode)} · {item.models.map(model => model.model).join(' / ')}</div>
|
|
1028
|
+
{item.models.map(model => (
|
|
1029
|
+
<div key={model.entry.id} className={css.historyModelRow}>
|
|
1030
|
+
<div className={css.historyMeta}>
|
|
1031
|
+
<strong>{model.model}</strong>{model.channel !== undefined ? ` · ${model.channel}` : ''}
|
|
1032
|
+
</div>
|
|
1033
|
+
{model.entry.audio.map((audio, index) => (
|
|
1034
|
+
<AudioPlayer key={index} src={audio.url} compact itemKey={`${item.key}-${model.entry.id}-${index}`} />
|
|
1035
|
+
))}
|
|
1036
|
+
<div className={css.historyActions}>
|
|
1037
|
+
<button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(model.entry), contextOfEntry(model.entry))}>
|
|
1038
|
+
<StarIcon /> 入库
|
|
1039
|
+
</button>
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
))}
|
|
1043
|
+
</details>
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
const entry = item.entry
|
|
1047
|
+
return (
|
|
1048
|
+
<div className={css.historyItem} key={item.key}>
|
|
1049
|
+
<div className={css.historyPrompt}>{entry.prompt}</div>
|
|
1050
|
+
<div className={css.historyMeta}>{modeLabelOf(entry.mode)} · {entry.model}{entry.channel ? ` · ${entry.channel}` : ''}</div>
|
|
1051
|
+
{entry.audio.map((audio, index) => (
|
|
1052
|
+
<AudioPlayer key={index} src={audio.url} compact itemKey={`${entry.id}-${index}`} />
|
|
1053
|
+
))}
|
|
1054
|
+
<div className={css.historyActions}>
|
|
1055
|
+
<button type="button" className={css.historyAction} onClick={() => openSaveDialog(audioRefsOfEntry(entry), contextOfEntry(entry))}>
|
|
1056
|
+
<StarIcon /> 入库
|
|
1057
|
+
</button>
|
|
1058
|
+
</div>
|
|
1059
|
+
</div>
|
|
1060
|
+
)
|
|
1061
|
+
})}
|
|
1062
|
+
</div>
|
|
1063
|
+
</>
|
|
725
1064
|
)}
|
|
726
1065
|
</aside>
|
|
727
1066
|
|