dsh-audiogen 0.4.20 → 0.4.22

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/src/client/api.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
9
9
  type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
10
10
  type VoiceEntry, type VoicesListRequest, type VoicesDeleteRequest,
11
+ type VoicesRecommendRequest, type VoiceRecommendation, type VoiceRecommendRecord,
11
12
  } from '../protocol.ts'
12
13
 
13
14
  export interface GenerateResponse {
@@ -127,10 +128,64 @@ export class AudiogenApi {
127
128
  }
128
129
  }
129
130
 
131
+ /** 读取最近 N 条 AI 音色推荐记录(面板与 Agent 共用)。 */
132
+ async voiceRecommendHistory(limit = 20): Promise<{ ok: boolean; entries?: VoiceRecommendRecord[]; message?: string }> {
133
+ const response = await postJson(VOICES_API.recommendHistory.list, { limit })
134
+ const body = await response.json() as { ok?: boolean; entries?: VoiceRecommendRecord[]; message?: string }
135
+ return { ok: body.ok === true, ...(body.entries === undefined ? {} : { entries: body.entries }), ...(body.message === undefined ? {} : { message: body.message }) }
136
+ }
137
+
138
+ /** 删除一条 AI 音色推荐记录。 */
139
+ async voiceRecommendHistoryRemove(id: string): Promise<{ ok: boolean; message?: string }> {
140
+ const response = await postJson(VOICES_API.recommendHistory.remove, { id })
141
+ const body = await response.json() as { ok?: boolean; message?: string }
142
+ return { ok: body.ok === true, ...(body.message === undefined ? {} : { message: body.message }) }
143
+ }
144
+
130
145
  /** 删除厂商音色(不可逆;confirm=true 必须)。 */
131
146
  async voiceDelete(request: VoicesDeleteRequest): Promise<{ ok: boolean; message?: string; code?: string }> {
132
147
  const response = await postJson(VOICES_API.delete, request)
133
148
  const body = await response.json() as { ok?: boolean; message?: string; code?: string }
134
149
  return { ok: body.ok === true, ...(body.message === undefined ? {} : { message: body.message }), ...(body.code === undefined ? {} : { code: body.code }) }
135
150
  }
151
+
152
+ /** 按需求描述(prompt)用 Agent 默认模型推荐音色(MiniMax / ElevenLabs)。 */
153
+ async voiceRecommend(request: VoicesRecommendRequest): Promise<{
154
+ ok: boolean
155
+ vendor?: string
156
+ channel?: string
157
+ requirement?: string
158
+ candidate_count?: number
159
+ top_k?: number
160
+ recommendations?: VoiceRecommendation[]
161
+ note?: string
162
+ code?: string
163
+ message?: string
164
+ }> {
165
+ const response = await postJson(VOICES_API.recommend, request)
166
+ const body = await response.json() as {
167
+ ok?: boolean
168
+ vendor?: string
169
+ channel?: string
170
+ requirement?: string
171
+ candidate_count?: number
172
+ top_k?: number
173
+ recommendations?: VoiceRecommendation[]
174
+ note?: string
175
+ code?: string
176
+ message?: string
177
+ }
178
+ return {
179
+ ok: body.ok === true,
180
+ ...(body.vendor === undefined ? {} : { vendor: body.vendor }),
181
+ ...(body.channel === undefined ? {} : { channel: body.channel }),
182
+ ...(body.requirement === undefined ? {} : { requirement: body.requirement }),
183
+ ...(body.candidate_count === undefined ? {} : { candidate_count: body.candidate_count }),
184
+ ...(body.top_k === undefined ? {} : { top_k: body.top_k }),
185
+ ...(body.recommendations === undefined ? {} : { recommendations: body.recommendations }),
186
+ ...(body.note === undefined ? {} : { note: body.note }),
187
+ ...(body.code === undefined ? {} : { code: body.code }),
188
+ ...(body.message === undefined ? {} : { message: body.message }),
189
+ }
190
+ }
136
191
  }
@@ -1360,3 +1360,70 @@
1360
1360
  justify-content: flex-end;
1361
1361
  margin-top: -4px;
1362
1362
  }
1363
+
1364
+ /* ------------------------------------------------ 音色推荐(studio 模式) */
1365
+ .recVoiceRow {
1366
+ display: flex;
1367
+ align-items: flex-start;
1368
+ gap: 8px;
1369
+ padding: 8px 0;
1370
+ border-top: 1px solid var(--dsw-alias-border-l2, #f3f4f6);
1371
+ }
1372
+
1373
+ .recVoiceRow:first-of-type {
1374
+ border-top: none;
1375
+ }
1376
+
1377
+ .recRank {
1378
+ display: inline-flex;
1379
+ align-items: center;
1380
+ justify-content: center;
1381
+ width: 20px;
1382
+ height: 20px;
1383
+ border-radius: 999px;
1384
+ background: var(--dsw-accent, #2563eb);
1385
+ color: #fff;
1386
+ font-size: 11px;
1387
+ font-weight: 600;
1388
+ flex: none;
1389
+ margin-top: 2px;
1390
+ }
1391
+
1392
+ .recVoiceBody {
1393
+ flex: 1;
1394
+ min-width: 0;
1395
+ display: flex;
1396
+ flex-direction: column;
1397
+ gap: 4px;
1398
+ }
1399
+
1400
+ .recVoiceHead {
1401
+ display: flex;
1402
+ align-items: center;
1403
+ gap: 6px;
1404
+ }
1405
+
1406
+ .recVoiceName {
1407
+ font-size: 13px;
1408
+ color: var(--dsw-alias-fg, #111827);
1409
+ overflow: hidden;
1410
+ text-overflow: ellipsis;
1411
+ white-space: nowrap;
1412
+ max-width: 260px;
1413
+ }
1414
+
1415
+ .recReason {
1416
+ margin: 0;
1417
+ font-size: 12.5px;
1418
+ line-height: 1.5;
1419
+ color: var(--dsw-accent, #2563eb);
1420
+ }
1421
+
1422
+ .recDescription {
1423
+ margin: 0;
1424
+ font-size: 12px;
1425
+ line-height: 1.5;
1426
+ color: var(--dsw-alias-fg-l2, #4b5563);
1427
+ white-space: pre-wrap;
1428
+ word-break: break-word;
1429
+ }
@@ -117,7 +117,8 @@ function specOf(key: FieldKey, presets?: string[]): FieldSpec {
117
117
  }
118
118
 
119
119
  /** 当前模式 + 所选模型集合(渠道集合)对应的「全局字段」清单。 */
120
- export function globalFieldSpecs(mode: AudioMode, presets: string[]): FieldSpec[] {
120
+ export function globalFieldSpecs(mode: AudioMode | 'voice_recommend', presets: string[]): FieldSpec[] {
121
+ if (mode === 'voice_recommend') return []
121
122
  const list: FieldSpec[] = []
122
123
  const all = (key: FieldKey, keys: FieldKey[]): boolean => keys.includes(key) && presets.every(preset => presetSupports(preset, key, mode))
123
124
  if (mode === 'tts') {
@@ -146,7 +147,8 @@ export function globalFieldSpecs(mode: AudioMode, presets: string[]): FieldSpec[
146
147
  }
147
148
 
148
149
  /** 「每模型参数覆盖」矩阵的字段全集(含适用渠道标注)。 */
149
- export function overrideRowSpecs(mode: AudioMode): Array<FieldSpec & { presets: string[] }> {
150
+ export function overrideRowSpecs(mode: AudioMode | 'voice_recommend'): Array<FieldSpec & { presets: string[] }> {
151
+ if (mode === 'voice_recommend') return []
150
152
  const rows: Array<FieldSpec & { presets: string[] }> = []
151
153
  const presets: Array<[FieldKey, string[]]> = [
152
154
  ['format', [PRESET_MINIMAX, PRESET_ELEVENLABS, PRESET_STABILITY]],
@@ -168,3 +168,16 @@ export function ListIcon(): JSX.Element {
168
168
  </Svg>
169
169
  )
170
170
  }
171
+
172
+ export function SparkIcon(): JSX.Element {
173
+ return (
174
+ <Svg viewBox="0 0 16 16">
175
+ <path
176
+ d="M6.5 2.4l1 3 3 1-3 1-1 3-1-3-3-1 3-1 1-3Z"
177
+ fill="currentColor"
178
+ stroke="none"
179
+ />
180
+ <path d="M11.6 9.2l.6 1.8 1.8.6-1.8.6-.6 1.8-.6-1.8-1.8-.6 1.8-.6.6-1.8Z" fill="currentColor" stroke="none" />
181
+ </Svg>
182
+ )
183
+ }
@@ -15,6 +15,7 @@ import { tt } from './helpers.ts'
15
15
  import {
16
16
  HISTORY_API,
17
17
  type AudioMode, type GeneratedAudio, type GenerateAudioRequest, type HistoryAudioRef, type HistoryEntry, type LibraryEntry,
18
+ type VoiceRecommendation, type VoiceRecommendRecord,
18
19
  } from '../protocol.ts'
19
20
  import type { ModelOption } from './settings-scope.ts'
20
21
  import { AudioPlayer } from './audio-player.tsx'
@@ -104,15 +105,19 @@ function overrideSpread(override: Record<string, string>): Partial<GenerateAudio
104
105
  /** 历史记录分组 tab(全部 + 按模式)。 */
105
106
  type HistoryTab = 'all' | AudioMode
106
107
 
108
+ /** 生成模式 + 音色推荐(prompt → 音色,结果与记录在右侧)。 */
109
+ type StudioMode = AudioMode | 'voice_recommend'
110
+
107
111
  function taskIdOf(entry: HistoryEntry): string {
108
112
  const params = entry.params as Record<string, unknown> | undefined
109
113
  return typeof params?.taskId === 'string' && params.taskId !== '' ? params.taskId : ''
110
114
  }
111
115
 
112
- function modeLabelOf(mode: AudioMode): string {
116
+ function modeLabelOf(mode: StudioMode): string {
113
117
  if (mode === 'tts') return tt('mode.tts')
114
118
  if (mode === 'music') return tt('mode.music')
115
119
  if (mode === 'sfx') return tt('mode.sfx')
120
+ if (mode === 'voice_recommend') return '音色推荐'
116
121
  return tt('mode.voiceDesign')
117
122
  }
118
123
 
@@ -245,9 +250,9 @@ export function StudioView(props: {
245
250
  return channel.apiUrl.trim() !== '' && keyHeld && (channel.models.length > 0 || channel.preset === 'minimax')
246
251
  })
247
252
 
248
- const [mode, setMode] = useState<AudioMode>('tts')
249
- /** 每个模式独立的输入内容(TTS 文本 / 音乐·音效提示词 / 音色描述),切模式互不干扰。 */
250
- const [promptByMode, setPromptByMode] = useState<Record<AudioMode, string>>({ tts: '', music: '', sfx: '', voice_design: '' })
253
+ const [mode, setMode] = useState<StudioMode>('tts')
254
+ /** 每个模式独立的输入内容(TTS 文本 / 音乐·音效提示词 / 音色描述 / 音色需求),切模式互不干扰。 */
255
+ const [promptByMode, setPromptByMode] = useState<Record<StudioMode, string>>({ tts: '', music: '', sfx: '', voice_design: '', voice_recommend: '' })
251
256
  const prompt = promptByMode[mode] ?? ''
252
257
  const setPrompt = (next: string): void => {
253
258
  const target = mode
@@ -296,13 +301,13 @@ export function StudioView(props: {
296
301
  const [historyTab, setHistoryTab] = useState<HistoryTab>('all')
297
302
  // 模型对比
298
303
  /** 对比模式与对比模型选择按模式独立保存:恢复对比任务或切换模式时不会串到其他模块。 */
299
- const [compareModeByMode, setCompareModeByMode] = useState<Record<AudioMode, boolean>>({ tts: false, music: false, sfx: false, voice_design: false })
304
+ const [compareModeByMode, setCompareModeByMode] = useState<Record<StudioMode, boolean>>({ tts: false, music: false, sfx: false, voice_design: false, voice_recommend: false })
300
305
  const compareMode = compareModeByMode[mode] ?? false
301
306
  const setCompareMode = (next: boolean): void => {
302
307
  const target = mode
303
308
  setCompareModeByMode(current => (current[target] === next ? current : { ...current, [target]: next }))
304
309
  }
305
- const [compareModelsByMode, setCompareModelsByMode] = useState<Record<AudioMode, string[]>>({ tts: [], music: [], sfx: [], voice_design: [] })
310
+ const [compareModelsByMode, setCompareModelsByMode] = useState<Record<StudioMode, string[]>>({ tts: [], music: [], sfx: [], voice_design: [], voice_recommend: [] })
306
311
  const compareModels = compareModelsByMode[mode] ?? []
307
312
  const setCompareModels = (next: string[] | ((current: string[]) => string[])): void => {
308
313
  const target = mode
@@ -330,8 +335,84 @@ export function StudioView(props: {
330
335
  }
331
336
  }, [channels, modelOptions.defaultChannelId, designChannelId])
332
337
 
338
+ // ---- 音色推荐(左侧模式「音色推荐」:prompt → 候选音色,结果与记录在右侧) ----
339
+ const [recommendChannelId, setRecommendChannelId] = useState('')
340
+ useEffect(() => {
341
+ if (channels.length === 0) return
342
+ if (recommendChannelId === '' || !channels.some(candidate => candidate.id === recommendChannelId)) {
343
+ setRecommendChannelId(modelOptions.defaultChannelId ?? channels[0]!.id)
344
+ }
345
+ }, [channels, modelOptions.defaultChannelId, recommendChannelId])
346
+ const [recommendTopK, setRecommendTopK] = useState(5)
347
+ const [recommending, setRecommending] = useState(false)
348
+ const [recommendError, setRecommendError] = useState<string | null>(null)
349
+ const [recommendResult, setRecommendResult] = useState<{
350
+ channelId: string
351
+ channel: string
352
+ requirement: string
353
+ candidateCount: number
354
+ voices: VoiceRecommendation[]
355
+ } | null>(null)
356
+ /** 右侧推荐记录(与音色管理页、Agent action=recommend 共用同一宿主存储)。 */
357
+ const [recommendRecords, setRecommendRecords] = useState<VoiceRecommendRecord[]>([])
358
+ const loadRecommendRecords = (): void => {
359
+ void api.voiceRecommendHistory(30)
360
+ .then(response => setRecommendRecords(response.entries ?? []))
361
+ .catch(() => { /* best-effort */ })
362
+ }
363
+ useEffect(() => { loadRecommendRecords() }, [api]) // eslint-disable-line react-hooks/exhaustive-deps
364
+
365
+ const recommendVoice = async (): Promise<void> => {
366
+ const text = prompt.trim()
367
+ if (text === '') {
368
+ setRecommendError('请先描述你想要的音色,例如「17岁清亮甜美的少女音,适合活泼女主角」。')
369
+ return
370
+ }
371
+ if (recommendChannelId === '') {
372
+ setRecommendError('请先选择渠道(需要在设置中添加且填写 API 地址与密钥)。')
373
+ return
374
+ }
375
+ setRecommending(true)
376
+ setRecommendError(null)
377
+ setRecommendResult(null)
378
+ try {
379
+ const response = await api.voiceRecommend({ channel: recommendChannelId, requirement: text, top_k: recommendTopK })
380
+ if (response.ok) {
381
+ setRecommendResult({
382
+ channelId: recommendChannelId,
383
+ channel: response.channel ?? '',
384
+ requirement: text,
385
+ candidateCount: response.candidate_count ?? 0,
386
+ voices: response.recommendations ?? [],
387
+ })
388
+ void loadRecommendRecords()
389
+ props.showToast('推荐完成,已记录到右侧「推荐记录」')
390
+ } else {
391
+ setRecommendError(response.message ?? '音色推荐失败')
392
+ }
393
+ } catch (err) {
394
+ setRecommendError(err instanceof Error ? err.message : String(err))
395
+ } finally {
396
+ setRecommending(false)
397
+ }
398
+ }
399
+
400
+ /** 从推荐结果/记录复用音色:切回 TTS 并回填音色与模型。 */
401
+ const reuseRecommendVoice = (voiceId: string, channelId: string): void => {
402
+ const channel = channels.find(item => item.id === channelId)
403
+ const ttsModel = channel?.models.find(candidate => candidate.category === 'tts' || /tts|speech|voice|t2a/i.test(candidate.alias))
404
+ setMode('tts')
405
+ setVoice(voiceId)
406
+ if (ttsModel !== undefined && ttsModel.alias !== '') setModel(ttsModel.alias)
407
+ }
408
+
409
+ const removeRecommendRecord = async (id: string): Promise<void> => {
410
+ await api.voiceRecommendHistoryRemove(id).catch(() => { /* best-effort */ })
411
+ loadRecommendRecords()
412
+ }
413
+
333
414
  const visibleModels = useMemo(() => {
334
- if (mode === 'voice_design') return []
415
+ if (mode === 'voice_design' || mode === 'voice_recommend') return []
335
416
  return modelOptions.models
336
417
  .filter(entry => modelSuitableForMode(entry, mode))
337
418
  .map(entry => entry.alias)
@@ -340,9 +421,10 @@ export function StudioView(props: {
340
421
  // 当前模型所属渠道 preset(单模型模式);对比模式为所选模型渠道集合。
341
422
  const currentPreset = useMemo(() => {
342
423
  if (mode === 'voice_design') return channels.find(candidate => candidate.id === designChannelId)?.preset ?? ''
424
+ if (mode === 'voice_recommend') return channels.find(candidate => candidate.id === recommendChannelId)?.preset ?? ''
343
425
  const picked = modelOptions.models.find(entry => entry.alias === model) ?? modelOptions.models.find(entry => entry.alias === (visibleModels[0] ?? ''))
344
426
  return picked?.preset ?? ''
345
- }, [mode, model, visibleModels, modelOptions.models, channels, designChannelId])
427
+ }, [mode, model, visibleModels, modelOptions.models, channels, designChannelId, recommendChannelId])
346
428
 
347
429
  const fieldPresets = useMemo(() => {
348
430
  if (mode === 'voice_design') return []
@@ -405,9 +487,12 @@ export function StudioView(props: {
405
487
  if (reuse.model !== undefined && reuse.model !== '') setModel(reuse.model)
406
488
  }, [reuse?.nonce])
407
489
 
490
+ /** 音色推荐模式不参与生成:生成请求一律收敛为音频模式(运行时不会触发)。 */
491
+ const genMode: AudioMode = mode === 'voice_recommend' ? 'tts' : mode
492
+
408
493
  /** Build the shared generation request for one model. */
409
494
  const requestOf = (modelName: string): GenerateAudioRequest => ({
410
- mode,
495
+ mode: genMode,
411
496
  model: modelName,
412
497
  prompt: prompt.trim(),
413
498
  saveToLibrary,
@@ -478,7 +563,7 @@ export function StudioView(props: {
478
563
  }))
479
564
  const task: StudioTask = {
480
565
  id: taskId,
481
- mode,
566
+ mode: genMode,
482
567
  prompt: prompt.trim(),
483
568
  kind: isCompare ? 'compare' : 'single',
484
569
  label: isCompare ? `对比 ${models.join(' / ')}` : singleModel,
@@ -934,7 +1019,7 @@ export function StudioView(props: {
934
1019
  type="button"
935
1020
  className={css.ghostButton}
936
1021
  onClick={() => openSaveDialog([audio], {
937
- mode,
1022
+ mode: genMode,
938
1023
  prompt: prompt.trim(),
939
1024
  ...(voice.trim() !== '' ? { voice: voice.trim() } : {}),
940
1025
  ...(audio.voiceId === undefined ? {} : { voiceId: audio.voiceId }),
@@ -1010,7 +1095,7 @@ export function StudioView(props: {
1010
1095
  return merged.sort((left, right) => right.createdAt - left.createdAt)
1011
1096
  }, [entries])
1012
1097
 
1013
- const needModel = mode !== 'voice_design'
1098
+ const needModel = mode !== 'voice_design' && mode !== 'voice_recommend'
1014
1099
  const runningCount = tasks.filter(task => task.status === 'running').length
1015
1100
  /** 结果列只显示当前模式的任务(历史面板已按模式分组)。 */
1016
1101
  const visibleTasks = useMemo(() => tasks.filter(task => task.mode === mode), [tasks, mode])
@@ -1019,7 +1104,7 @@ export function StudioView(props: {
1019
1104
  <div className={css.studio}>
1020
1105
  <div className={css.formCol}>
1021
1106
  <div className={css.modeRow}>
1022
- {([['tts', '🎙️'], ['music', '🎵'], ['sfx', '🔊'], ['voice_design', '🎨']] as Array<[AudioMode, string]>).map(([item, icon]) => (
1107
+ {([['tts', '🎙️'], ['music', '🎵'], ['sfx', '🔊'], ['voice_design', '🎨'], ['voice_recommend', '🎤']] as Array<[StudioMode, string]>).map(([item, icon]) => (
1023
1108
  <button
1024
1109
  key={item}
1025
1110
  type="button"
@@ -1035,15 +1120,22 @@ export function StudioView(props: {
1035
1120
 
1036
1121
  <p className={css.formSection}>输入</p>
1037
1122
  <label className={css.label}>
1038
- <span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
1039
- <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
1123
+ <span>{mode === 'voice_design' ? '音色描述' : mode === 'voice_recommend' ? '音色需求' : mode === 'tts' ? '文本' : '提示词'}</span>
1124
+ <textarea
1125
+ className={css.textarea}
1126
+ value={prompt}
1127
+ onChange={event => setPrompt(event.target.value)}
1128
+ placeholder={mode === 'voice_recommend' ? '描述你想要的音色,例如:17岁清亮甜美的少女音,适合活泼女主角,英式口音…' : tt('prompt.placeholder')}
1129
+ />
1040
1130
  </label>
1041
- <div className={css.enhanceActionsRow}>
1042
- <button type="button" className={css.ghostButton} disabled={enhancing} onClick={() => void runEnhance()}>
1043
- {enhancing ? '增强中…' : '✨ 增强提示词'}
1044
- </button>
1045
- </div>
1046
- {enhancePreview !== null ? (
1131
+ {mode !== 'voice_recommend' ? (
1132
+ <div className={css.enhanceActionsRow}>
1133
+ <button type="button" className={css.ghostButton} disabled={enhancing} onClick={() => void runEnhance()}>
1134
+ {enhancing ? '增强中…' : '✨ 增强提示词'}
1135
+ </button>
1136
+ </div>
1137
+ ) : null}
1138
+ {enhancePreview !== null && mode !== 'voice_recommend' ? (
1047
1139
  <div className={css.enhanceCard}>
1048
1140
  <div className={css.enhanceCardHead}>
1049
1141
  <strong>增强结果({modeLabelOf(mode)})</strong>
@@ -1076,6 +1168,27 @@ export function StudioView(props: {
1076
1168
  <input className={css.input} value={previewText} onChange={event => setPreviewText(event.target.value)} placeholder="你好,这是新设计的音色试听。" />
1077
1169
  </label>
1078
1170
  </>
1171
+ ) : mode === 'voice_recommend' ? (
1172
+ <>
1173
+ <label className={css.label}>
1174
+ <span>厂商 / 渠道</span>
1175
+ <select className={css.input} value={recommendChannelId} onChange={event => setRecommendChannelId(event.target.value)}>
1176
+ {channels.map(channel => (
1177
+ <option key={channel.id} value={channel.id}>{channel.name}</option>
1178
+ ))}
1179
+ </select>
1180
+ </label>
1181
+ <label className={css.label}>
1182
+ <span>推荐数量</span>
1183
+ <select className={css.input} value={String(recommendTopK)} onChange={event => setRecommendTopK(Number(event.target.value))}>
1184
+ <option value="3">3 条</option>
1185
+ <option value="5">5 条</option>
1186
+ <option value="8">8 条</option>
1187
+ <option value="10">10 条</option>
1188
+ </select>
1189
+ </label>
1190
+ <p className={css.hint}>按需求描述挑选:候选范围为该渠道全部可用音色(语言/口音等由描述自然约束),结果会记录到右侧「推荐记录」</p>
1191
+ </>
1079
1192
  ) : null}
1080
1193
 
1081
1194
  <p className={css.formSection}>模型</p>
@@ -1195,25 +1308,102 @@ export function StudioView(props: {
1195
1308
  </details>
1196
1309
  ) : null}
1197
1310
 
1198
- <label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
1199
- <input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
1200
- <span>生成后保存到资源库</span>
1201
- </label>
1311
+ {mode !== 'voice_recommend' ? (
1312
+ <label className={css.checkbox} title="生成完成后自动保存到资源库;也可在设置中开启全部自动保存">
1313
+ <input type="checkbox" checked={saveToLibrary} onChange={event => setSaveToLibrary(event.target.checked)} />
1314
+ <span>生成后保存到资源库</span>
1315
+ </label>
1316
+ ) : null}
1202
1317
 
1203
1318
  {!connected && <p className={css.hint}>{tt('config.missing')}</p>}
1204
- <button
1205
- type="button"
1206
- className={css.generate}
1207
- disabled={!connected || (compareMode && needModel ? compareModels.length < 2 : needModel && visibleModels.length === 0)}
1208
- onClick={submit}
1209
- >
1210
- {(compareMode && needModel ? '对比生成' : tt('generate'))}
1211
- </button>
1319
+ {mode === 'voice_recommend' ? (
1320
+ <button
1321
+ type="button"
1322
+ className={css.generate}
1323
+ disabled={!connected || recommending}
1324
+ onClick={() => void recommendVoice()}
1325
+ >
1326
+ {recommending ? 'AI 推荐中…' : '✨ AI 推荐音色'}
1327
+ </button>
1328
+ ) : (
1329
+ <button
1330
+ type="button"
1331
+ className={css.generate}
1332
+ disabled={!connected || (compareMode && needModel ? compareModels.length < 2 : needModel && visibleModels.length === 0)}
1333
+ onClick={submit}
1334
+ >
1335
+ {(compareMode && needModel ? '对比生成' : tt('generate'))}
1336
+ </button>
1337
+ )}
1212
1338
  {runningCount > 0 ? <p className={css.hint}>进行中任务:{runningCount} 个(并发上限在「设置 → 插件 → AI 音频」调整)</p> : null}
1213
1339
  </div>
1214
1340
 
1215
1341
  <div className={css.resultCol}>
1216
- {error !== null ? <p className={css.error}>{error}</p> : null}
1342
+ {mode === 'voice_recommend' ? (
1343
+ <>
1344
+ {recommendError !== null ? <p className={css.error}>{recommendError}</p> : null}
1345
+ {recommending ? (
1346
+ <div className={css.resultEmpty}>
1347
+ <span className={css.resultEmptyIcon}>🎤</span>
1348
+ <p>正在让模型从候选音色中挑选…(约 10-45 秒,结果会记录到右侧)</p>
1349
+ </div>
1350
+ ) : null}
1351
+ {!recommending && recommendResult !== null ? (
1352
+ <div className={css.taskList}>
1353
+ <div className={css.taskCard}>
1354
+ <div className={css.taskHead}>
1355
+ <span className={css.resultModeChip}>音色推荐</span>
1356
+ <span className={css.taskLabel} title={recommendResult.requirement}>{recommendResult.requirement}</span>
1357
+ </div>
1358
+ <div className={css.historyMeta}>
1359
+ {recommendResult.channel} · 候选池 {recommendResult.candidateCount} 个音色 · 推荐 {recommendResult.voices.length} 条
1360
+ </div>
1361
+ {recommendResult.voices.map((voice, index) => (
1362
+ <div className={css.recVoiceRow} key={`${voice.source}:${voice.voice_id}`}>
1363
+ <span className={css.recRank}>{index + 1}</span>
1364
+ <div className={css.recVoiceBody}>
1365
+ <div className={css.recVoiceHead}>
1366
+ <strong className={css.recVoiceName} title={voice.name}>{voice.name}</strong>
1367
+ </div>
1368
+ {voice.reason === '' ? null : <p className={css.recReason}>{voice.reason}</p>}
1369
+ <div className={css.historyMeta}>
1370
+ ID: {voice.voice_id}{voice.language === undefined ? '' : ` · ${voice.language}`}{voice.locale === undefined ? '' : ` · ${voice.locale}`}{voice.accent === undefined ? '' : ` · ${voice.accent}`}{voice.gender === undefined ? '' : ` · ${voice.gender}`}{voice.age === undefined ? '' : ` · ${voice.age}`}{voice.use_case === undefined ? '' : ` · ${voice.use_case}`}
1371
+ </div>
1372
+ {voice.descriptive === undefined && (voice.labels === undefined || Object.keys(voice.labels).length === 0) ? null : (
1373
+ <div className={css.historyMeta}>
1374
+ {voice.descriptive !== undefined ? `标签 descriptive: ${voice.descriptive}` : ''}
1375
+ {voice.labels !== undefined ? Object.entries(voice.labels).filter(([key]) => key !== 'description' && key !== 'descriptive').map(([key, value]) => `${key === 'use_case' ? '用途' : key === 'accent' ? '口音' : key === 'gender' ? '性别' : key === 'age' ? '年龄' : key}: ${value}`).join(' · ') : ''}
1376
+ </div>
1377
+ )}
1378
+ {voice.description === undefined || voice.description === '' ? null : <p className={css.recDescription}>{voice.description}</p>}
1379
+ {voice.preview_url === undefined || voice.preview_url === '' ? null : <AudioPlayer src={voice.preview_url} compact />}
1380
+ </div>
1381
+ <button type="button" className={css.ghostButton} onClick={() => reuseRecommendVoice(voice.voice_id, recommendResult.channelId)}>
1382
+ 用此音色生成
1383
+ </button>
1384
+ </div>
1385
+ ))}
1386
+ </div>
1387
+ </div>
1388
+ ) : null}
1389
+ {!recommending && recommendResult === null ? (
1390
+ <div className={css.resultEmpty}>
1391
+ <span className={css.resultEmptyIcon}>🎤</span>
1392
+ <p>在左侧描述想要的音色,点「✨ AI 推荐音色」</p>
1393
+ <p className={css.resultEmptyHint}>模型会从该渠道候选池里挑最匹配的 top-k 音色;每次推荐自动记录到右侧「推荐记录」</p>
1394
+ <button
1395
+ type="button"
1396
+ className={css.ghostButton}
1397
+ onClick={() => setPrompt('17岁清亮甜美的少女音,适合活泼女主角,英式口音')}
1398
+ >
1399
+ 填入示例需求
1400
+ </button>
1401
+ </div>
1402
+ ) : null}
1403
+ </>
1404
+ ) : (
1405
+ <>
1406
+ {error !== null ? <p className={css.error}>{error}</p> : null}
1217
1407
  {visibleTasks.length === 0 ? (
1218
1408
  <div className={css.resultEmpty}>
1219
1409
  <span className={css.resultEmptyIcon}>🎵</span>
@@ -1286,9 +1476,58 @@ export function StudioView(props: {
1286
1476
  })}
1287
1477
  </div>
1288
1478
  )}
1479
+ </>
1480
+ )}
1289
1481
  </div>
1290
1482
 
1291
1483
  <aside className={css.historyCol}>
1484
+ {mode === 'voice_recommend' ? (
1485
+ <>
1486
+ <div className={css.historyHeader}>
1487
+ <strong className={css.historyTitle}>推荐记录</strong>
1488
+ </div>
1489
+ {recommendRecords.length === 0 ? (
1490
+ <p className={css.historyEmpty}>还没有推荐记录——在左侧输入音色需求,点「✨ AI 推荐音色」后自动记录在这里</p>
1491
+ ) : (
1492
+ <div className={css.historyList}>
1493
+ {recommendRecords.map(record => (
1494
+ <details className={css.historyItem} key={record.id}>
1495
+ <summary className={css.historyCompareSummary}>
1496
+ <span className={css.historyPrompt}>{record.requirement}</span>
1497
+ <span className={css.historyCompareBadge}>{record.channel} · {record.recommendations.length} 条</span>
1498
+ <span className={css.historyTime}>{formatClock(record.createdAt)}</span>
1499
+ </summary>
1500
+ <div className={css.historyMeta}>候选池 {record.candidate_count} 个音色</div>
1501
+ {record.recommendations.map((voice, index) => (
1502
+ <div className={css.recVoiceRow} key={`${record.id}:${voice.voice_id}`}>
1503
+ <span className={css.recRank}>{index + 1}</span>
1504
+ <div className={css.recVoiceBody}>
1505
+ <div className={css.recVoiceHead}>
1506
+ <strong className={css.recVoiceName} title={voice.name}>{voice.name}</strong>
1507
+ </div>
1508
+ {voice.reason === '' ? null : <p className={css.recReason}>{voice.reason}</p>}
1509
+ <div className={css.historyMeta}>ID: {voice.voice_id}{voice.descriptive === undefined ? '' : ` · ${voice.descriptive}`}{voice.accent === undefined ? '' : ` · ${voice.accent}`}{voice.gender === undefined ? '' : ` · ${voice.gender}`}{voice.age === undefined ? '' : ` · ${voice.age}`}</div>
1510
+ {voice.description === undefined || voice.description === '' ? null : <p className={css.recDescription}>{voice.description.length > 120 ? `${voice.description.slice(0, 119)}…` : voice.description}</p>}
1511
+ </div>
1512
+ <button
1513
+ type="button"
1514
+ className={css.ghostButton}
1515
+ onClick={() => reuseRecommendVoice(voice.voice_id, record.channel_id ?? recommendChannelId)}
1516
+ >
1517
+ 用此音色生成
1518
+ </button>
1519
+ </div>
1520
+ ))}
1521
+ <div className={css.historyActions}>
1522
+ <button type="button" className={css.historyIcon} title="删除这条推荐记录" onClick={() => void removeRecommendRecord(record.id)}>✕</button>
1523
+ </div>
1524
+ </details>
1525
+ ))}
1526
+ </div>
1527
+ )}
1528
+ </>
1529
+ ) : (
1530
+ <>
1292
1531
  <div className={css.historyHeader}>
1293
1532
  <strong className={css.historyTitle}>{tt('history.title')}</strong>
1294
1533
  <button type="button" className={css.historyClear} onClick={clear}>清空</button>
@@ -1383,6 +1622,8 @@ export function StudioView(props: {
1383
1622
  </div>
1384
1623
  </>
1385
1624
  )}
1625
+ </>
1626
+ )}
1386
1627
  </aside>
1387
1628
 
1388
1629
  {saveDialog !== null ? (