dsh-audiogen 0.4.6 → 0.4.8

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/lib/index.js CHANGED
@@ -28,6 +28,8 @@ const TASK_API = { cancel: "/api/dsh-audiogen/task/cancel" };
28
28
  const ENHANCE_API = "/api/dsh-audiogen/prompt/enhance";
29
29
  /** Host-mediated built-in provider catalog (channels the user can instantiate). */
30
30
  const PRESETS_API = "/api/dsh-audiogen/presets";
31
+ /** LLM 模型目录:提示词增强模型的候选(来自「设置 → 模型」各提供方)。 */
32
+ const LLM_MODELS_API = "/api/dsh-audiogen/llm/models";
31
33
  /** Host-mediated model/voice discovery endpoint. */
32
34
  const MODEL_API = { discover: "/api/dsh-audiogen/models/discover" };
33
35
  /** Loopback-only audio file reader for panel/tool-result previews. */
@@ -882,11 +884,17 @@ function instructionsFor(mode) {
882
884
  voice_design: "这是音色设计任务:扩写人声/音色特征——性别年龄、音域、音质(低沉/清亮/沙哑)、语速、情绪性格、适用场景,用可感知的描述,方便语音模型合成。"
883
885
  }[mode];
884
886
  }
885
- /** 读取 Agent 默认模型并调用 LLM 增强,返回增强后的文本。 */
886
- async function enhancePromptText(deps, prompt, mode) {
887
+ /** 读取 Agent 默认模型并调用 LLM 增强,返回增强后的文本。
888
+ * @param override - 用户设置的增强模型;缺省/为空时回退到 agent-default-model。 */
889
+ async function enhancePromptText(deps, prompt, mode, override) {
887
890
  const text = prompt.trim();
888
891
  if (text === "") throw new AudioGenError("提示词为空,无法增强", "enhance-empty-prompt");
889
- const value = (deps.settings.describe({ redactSecrets: true }) ?? []).find((candidate) => String(candidate.ns) === "agent-default-model")?.value ?? {};
892
+ const selected = override !== void 0 && override.provider.trim() !== "" && override.model.trim() !== "" ? {
893
+ provider: override.provider.trim(),
894
+ model: override.model.trim()
895
+ } : void 0;
896
+ const descriptor = selected === void 0 ? (deps.settings.describe({ redactSecrets: true }) ?? []).find((candidate) => String(candidate.ns) === "agent-default-model") : void 0;
897
+ const value = selected ?? descriptor?.value ?? {};
890
898
  const provider = typeof value.provider === "string" && value.provider.trim() !== "" ? value.provider.trim() : "";
891
899
  const model = typeof value.model === "string" && value.model.trim() !== "" ? value.model.trim() : "";
892
900
  if (provider === "" || model === "") throw new AudioGenError("未找到 Agent 默认模型(agent-default-model):请先在「设置 → 模型」中配置默认模型", "no-default-model");
@@ -896,13 +904,17 @@ async function enhancePromptText(deps, prompt, mode) {
896
904
  const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), 3e4);
897
905
  timer.unref?.();
898
906
  let output = "";
907
+ let terminalFailure = "";
899
908
  try {
900
909
  for await (const chunk of runtime.stream({
901
910
  provider,
902
911
  model,
903
912
  messages: [{
904
913
  role: "user",
905
- content: text
914
+ content: [{
915
+ type: "text",
916
+ text
917
+ }]
906
918
  }],
907
919
  system: instructionsFor(mode),
908
920
  temperature: .7,
@@ -912,12 +924,19 @@ async function enhancePromptText(deps, prompt, mode) {
912
924
  const record = chunk;
913
925
  if (record.type === "text-delta" && typeof record.text === "string") output += record.text;
914
926
  else if (record.type === "block-end" && record.block !== void 0 && record.block.type === "text" && typeof record.block.text === "string") output += record.block.text;
927
+ else if (record.type === "finish" && record.reason !== void 0 && record.reason.kind !== "stop" && record.reason.kind !== void 0 && terminalFailure === "") {
928
+ const failure = record.reason.failure;
929
+ terminalFailure = typeof failure?.message === "string" && failure.message.trim() !== "" ? `${failure.message}${typeof failure.code === "string" ? `(${failure.code})` : ""}` : `stream ${record.reason.kind}`;
930
+ }
915
931
  }
916
932
  } finally {
917
933
  clearTimeout(timer);
918
934
  }
919
935
  const result = stripFences(output.trim());
920
- if (result === "") throw new AudioGenError("模型未返回增强内容:请检查「设置 → 模型」的默认模型是否可用(或稍后重试)", "enhance-empty-result");
936
+ if (result === "") {
937
+ if (terminalFailure !== "") throw new AudioGenError(`增强失败:LLM 调用出错(${terminalFailure})。请检查「设置 → 模型」的默认模型是否可用`, "enhance-llm-error");
938
+ throw new AudioGenError("模型未返回增强内容:请检查「设置 → 模型」的默认模型是否可用(或稍后重试)", "enhance-empty-result");
939
+ }
921
940
  return result;
922
941
  }
923
942
  /** 去掉模型可能包裹的 ``` 代码围栏。 */
@@ -1669,6 +1688,7 @@ function parseGenerateRequest(body) {
1669
1688
  ...num(body.cfgScale) !== void 0 ? { cfgScale: num(body.cfgScale) } : {},
1670
1689
  ...typeof body.format === "string" && body.format.trim() !== "" ? { format: body.format.trim() } : {},
1671
1690
  ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {},
1691
+ ...typeof body.taskId === "string" && body.taskId.trim() !== "" ? { taskId: body.taskId.trim() } : {},
1672
1692
  ...str(body.emotion) !== void 0 ? { emotion: str(body.emotion) } : {},
1673
1693
  ...num(body.vol) !== void 0 ? { vol: num(body.vol) } : {},
1674
1694
  ...num(body.pitch) !== void 0 ? { pitch: num(body.pitch) } : {},
@@ -1871,6 +1891,25 @@ function makeRoutes(deps) {
1871
1891
  });
1872
1892
  }
1873
1893
  },
1894
+ {
1895
+ kind: "exact",
1896
+ path: LLM_MODELS_API,
1897
+ handler: async (req, res) => {
1898
+ if (!guard(req, res, "POST")) return;
1899
+ try {
1900
+ writeJson(res, 200, {
1901
+ ok: true,
1902
+ providers: await deps.llmModelOptions()
1903
+ });
1904
+ } catch (error) {
1905
+ writeJson(res, 200, {
1906
+ ok: false,
1907
+ code: "llm-models-failed",
1908
+ message: messageOf(error)
1909
+ });
1910
+ }
1911
+ }
1912
+ },
1874
1913
  {
1875
1914
  kind: "exact",
1876
1915
  path: MODEL_API.discover,
@@ -3134,7 +3173,8 @@ const Config = z.object({
3134
3173
  defaultChannelId: z.string().default(""),
3135
3174
  defaultModel: z.string().default(""),
3136
3175
  autoSaveToLibrary: z.boolean().default(false),
3137
- maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT)
3176
+ maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT),
3177
+ enhanceModel: z.string().default("")
3138
3178
  });
3139
3179
  const DEFAULT_ENABLED = true;
3140
3180
  const DEFAULT_ANNOUNCE = true;
@@ -3227,6 +3267,7 @@ function apply(ctx, config) {
3227
3267
  })),
3228
3268
  defaultChannelId,
3229
3269
  defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
3270
+ enhanceModel: typeof value.enhanceModel === "string" ? value.enhanceModel.trim() : "",
3230
3271
  autoSaveToLibrary: value.autoSaveToLibrary === true,
3231
3272
  maxConcurrentGenerations: (() => {
3232
3273
  const rawMax = value.maxConcurrentGenerations;
@@ -3242,7 +3283,51 @@ function apply(ctx, config) {
3242
3283
  return enhancePromptText({
3243
3284
  settings: seam,
3244
3285
  llm: () => ctx.get("llm")
3245
- }, prompt, mode);
3286
+ }, prompt, mode, enhanceSelectionOf(resolve()));
3287
+ };
3288
+ /** 解析设置的增强模型("provider|model");空/非法值返回 undefined = 跟随默认。 */
3289
+ const enhanceSelectionOf = (config) => {
3290
+ const raw = config.enhanceModel ?? "";
3291
+ const sep = raw.indexOf("|");
3292
+ if (sep <= 0 || sep >= raw.length - 1) return void 0;
3293
+ const provider = raw.slice(0, sep).trim();
3294
+ const model = raw.slice(sep + 1).trim();
3295
+ return provider !== "" && model !== "" ? {
3296
+ provider,
3297
+ model
3298
+ } : void 0;
3299
+ };
3300
+ /** 读取「设置 → 模型」目录:各提供方 + 可广播的模型列表(增强模型下拉候选)。 */
3301
+ const llmModelOptions = async () => {
3302
+ const llm = ctx.get("llm");
3303
+ if (llm === void 0 || llm.listProviders === void 0 || llm.listModels === void 0) return [];
3304
+ const options = [];
3305
+ const directory = /* @__PURE__ */ new Map();
3306
+ if (llm.listConfigurableProviders !== void 0) {
3307
+ for (const entry of llm.listConfigurableProviders() ?? []) if (typeof entry.provider === "string" && entry.provider !== "" && typeof entry.displayName === "string") directory.set(entry.provider, entry.displayName);
3308
+ }
3309
+ for (const info of llm.listProviders() ?? []) {
3310
+ const provider = typeof info.id === "string" ? info.id : "";
3311
+ if (provider === "") continue;
3312
+ let models = [];
3313
+ try {
3314
+ models = await llm.listModels(provider) ?? [];
3315
+ } catch {
3316
+ models = [];
3317
+ }
3318
+ for (const model of models) {
3319
+ const id = typeof model.id === "string" ? model.id.trim() : "";
3320
+ if (id === "") continue;
3321
+ const name = typeof model.name === "string" && model.name.trim() !== "" ? model.name.trim() : id;
3322
+ options.push({
3323
+ provider,
3324
+ providerName: directory.get(provider) ?? provider,
3325
+ id,
3326
+ name
3327
+ });
3328
+ }
3329
+ }
3330
+ return options;
3246
3331
  };
3247
3332
  const channelsView = () => {
3248
3333
  const value = resolve();
@@ -3259,7 +3344,8 @@ function apply(ctx, config) {
3259
3344
  resolveChannels: channelsView,
3260
3345
  autoSave: () => resolve().autoSaveToLibrary,
3261
3346
  budget,
3262
- enhance
3347
+ enhance,
3348
+ llmModelOptions
3263
3349
  }).map((route) => ctx.webServer.register(route));
3264
3350
  return () => {
3265
3351
  for (const dispose of disposers) dispose();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-audiogen",
3
3
  "description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
4
- "version": "0.4.6",
4
+ "version": "0.4.8",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -18,7 +18,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client
18
18
  import { CardForm, booleanField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
19
19
  import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
20
20
  import type { AudiogenScope } from './settings-scope.ts'
21
- import { MODEL_API, PRESETS_API, type AudioModelCategory, type DiscoveredAudioModel, type ModelMapping, type PresetProviderView } from '../protocol.ts'
21
+ import { MODEL_API, PRESETS_API, LLM_MODELS_API, type AudioModelCategory, type DiscoveredAudioModel, type LlmModelOption, type ModelMapping, type PresetProviderView } from '../protocol.ts'
22
22
  import type { AudioGenKey } from './locales.ts'
23
23
  import css from './settings-card.module.css'
24
24
 
@@ -29,6 +29,7 @@ export interface AudioGenSettings {
29
29
  defaultModel?: string
30
30
  autoSaveToLibrary?: boolean
31
31
  maxConcurrentGenerations?: number
32
+ enhanceModel?: string
32
33
  }
33
34
 
34
35
  export interface AudioGenSettingsCardState extends CardShell {
@@ -39,6 +40,7 @@ export interface AudioGenSettingsCardState extends CardShell {
39
40
  defaultModel: CardFieldState
40
41
  autoSaveToLibrary: CardFieldState
41
42
  maxConcurrentGenerations: CardFieldState
43
+ enhanceModel: CardFieldState
42
44
  }
43
45
 
44
46
  export interface AudioGenSettingsCardFace extends CardActions {
@@ -60,6 +62,7 @@ export class AudioGenSettingsCardController {
60
62
  textField('defaultModel'),
61
63
  booleanField('autoSaveToLibrary'),
62
64
  textField('maxConcurrentGenerations'),
65
+ textField('enhanceModel'),
63
66
  ])
64
67
  this.channelsForm = new ChannelsForm(scope)
65
68
  }
@@ -76,6 +79,7 @@ export class AudioGenSettingsCardController {
76
79
  defaultModel: this.form.field('defaultModel'),
77
80
  autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
78
81
  maxConcurrentGenerations: this.form.field('maxConcurrentGenerations'),
82
+ enhanceModel: this.form.field('enhanceModel'),
79
83
  }
80
84
  }
81
85
 
@@ -387,6 +391,9 @@ function ChannelEditor(props: ChannelEditorProps): React.JSX.Element {
387
391
  onAdopt={() => adoptCandidates()}
388
392
  onCloseCandidates={closeCandidates}
389
393
  />
394
+ {/stability/i.test(`${props.channel?.preset ?? invited?.id ?? presetId}|${url}`) ? (
395
+ <p className={css.sectionHint}>{t('channel.stabilityHint')}</p>
396
+ ) : null}
390
397
  <label className={css.field}>
391
398
  <span className={css.label}>
392
399
  <input type="checkbox" checked={isDefault} disabled={!writable} onChange={event => setIsDefault(event.target.checked)} /> {t('channel.default')}
@@ -469,20 +476,32 @@ function ModelCatalog(props: ModelCatalogProps): React.JSX.Element {
469
476
  disabled={!writable}
470
477
  onChange={event => props.onPatchModel(index, { id: event.target.value })}
471
478
  />
472
- <select
473
- className={`${css.select} ${css.modelCategorySelect}`}
474
- value={model.category ?? ''}
475
- aria-label={`${t('channel.modelCategory')} ${index + 1}`}
476
- disabled={!writable}
477
- onChange={event => {
478
- const value = event.target.value as AudioModelCategory | ''
479
- props.onPatchModel(index, value === '' ? { category: undefined } : { category: value })
480
- }}
481
- >
482
- {MODEL_CATEGORIES.map(category => (
483
- <option key={category ?? 'auto'} value={category ?? ''}>{category === undefined ? t('channel.category.auto') : t(`channel.category.${category}`)}</option>
484
- ))}
485
- </select>
479
+ {/^stable-audio-/i.test(model.id) ? (
480
+ <select
481
+ className={`${css.select} ${css.modelCategorySelect}`}
482
+ value="__stable-dual__"
483
+ aria-label={`${t('channel.modelCategory')} ${index + 1}`}
484
+ disabled
485
+ title={t('channel.category.stableDualHint')}
486
+ >
487
+ <option value="__stable-dual__">{t('channel.category.stableDual')}</option>
488
+ </select>
489
+ ) : (
490
+ <select
491
+ className={`${css.select} ${css.modelCategorySelect}`}
492
+ value={model.category ?? ''}
493
+ aria-label={`${t('channel.modelCategory')} ${index + 1}`}
494
+ disabled={!writable}
495
+ onChange={event => {
496
+ const value = event.target.value as AudioModelCategory | ''
497
+ props.onPatchModel(index, value === '' ? { category: undefined } : { category: value })
498
+ }}
499
+ >
500
+ {MODEL_CATEGORIES.map(category => (
501
+ <option key={category ?? 'auto'} value={category ?? ''}>{category === undefined ? t('channel.category.auto') : t(`channel.category.${category}`)}</option>
502
+ ))}
503
+ </select>
504
+ )}
486
505
  <button
487
506
  type="button"
488
507
  className={css.modelRowRemove}
@@ -554,6 +573,8 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
554
573
  const [presetLoading, setPresetLoading] = useState(false)
555
574
  const [presetError, setPresetError] = useState<string | null>(null)
556
575
  const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
576
+ const [llmModels, setLlmModels] = useState<LlmModelOption[] | null>(null)
577
+ const [llmModelsError, setLlmModelsError] = useState<string | null>(null)
557
578
 
558
579
  const channels = state.channels.channels
559
580
  const editingChannel = editor !== null && editor.kind === 'edit'
@@ -575,6 +596,18 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
575
596
  }
576
597
  }, [editor, presets.length, presetError, presetLoading])
577
598
 
599
+ // 展开卡片时加载「设置 → 模型」的 LLM 提供方/模型列表(提示词增强模型下拉)。
600
+ useEffect(() => {
601
+ if (!open || llmModels !== null || llmModelsError !== null) return
602
+ void fetch(LLM_MODELS_API, { method: 'POST' })
603
+ .then(async response => {
604
+ const body = await response.json() as { ok?: boolean; providers?: LlmModelOption[]; message?: string }
605
+ if (!response.ok || body.ok !== true || body.providers === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
606
+ setLlmModels(body.providers)
607
+ })
608
+ .catch(error => setLlmModelsError(error instanceof Error ? error.message : String(error)))
609
+ }, [open, llmModels, llmModelsError])
610
+
578
611
  if (!state.available) return null
579
612
 
580
613
  const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
@@ -707,6 +740,31 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
707
740
  ) : null}
708
741
  </section>
709
742
 
743
+ <div className={css.field}>
744
+ <label className={css.label}>
745
+ <span>{t('settings.enhanceModel')}</span>
746
+ <select
747
+ className={css.select}
748
+ value={state.enhanceModel.text}
749
+ disabled={!state.writable}
750
+ onChange={event => props.edit('enhanceModel', event.target.value)}
751
+ >
752
+ <option value="">{t('settings.enhanceModelDefault')}</option>
753
+ {llmModels !== null ? llmModels.map(option => (
754
+ <option key={`${option.provider}|${option.id}`} value={`${option.provider}|${option.id}`}>
755
+ {`${option.providerName} — ${option.name}${option.name !== option.id ? `(${option.id})` : ''}`}
756
+ </option>
757
+ )) : null}
758
+ {llmModels !== null && state.enhanceModel.text !== ''
759
+ && !llmModels.some(option => `${option.provider}|${option.id}` === state.enhanceModel.text) ? (
760
+ <option value={state.enhanceModel.text}>{state.enhanceModel.text}</option>
761
+ ) : null}
762
+ </select>
763
+ <span className={css.sectionHint}>{t('settings.enhanceModelHint')}</span>
764
+ {llmModelsError !== null ? <span className={css.failed}>{t('settings.enhanceModelFailed')}:{llmModelsError}</span> : null}
765
+ </label>
766
+ </div>
767
+
710
768
  <div className={css.field}>
711
769
  <label className={css.label}>
712
770
  <input type="checkbox" checked={state.enabled.text === 'true' || state.enabled.text === ''} disabled={!state.writable} onChange={event => props.edit('enabled', String(event.target.checked))} /> {t('settings.enabled')}
@@ -37,6 +37,10 @@ export const zh = {
37
37
  'settings.allowAgentAudio': '允许 Agent 调用音频生成',
38
38
  'settings.autoSaveLibrary': '生成后自动保存到资源库',
39
39
  'settings.maxConcurrent': '最大并发生成数(同时打到上游的请求数,默认 5)',
40
+ 'settings.enhanceModel': '提示词增强模型(LLM)',
41
+ 'settings.enhanceModelDefault': '跟随 Agent 默认模型(设置 → 模型)',
42
+ 'settings.enhanceModelHint': '用于「✨ 增强提示词」与 generate_audio 的 enhance_prompt 参数;留空则使用「设置 → 模型」中的默认模型。',
43
+ 'settings.enhanceModelFailed': '模型列表加载失败',
40
44
  'settings.save': '保存',
41
45
  'settings.saving': '保存中…',
42
46
  'settings.discard': '放弃修改',
@@ -89,6 +93,8 @@ export const zh = {
89
93
  'channel.category.sfx': '音效',
90
94
  'channel.category.voice_design': '音色设计',
91
95
  'channel.category.voice_clone': '音色克隆',
96
+ 'channel.category.stableDual': '音乐 + 音效(自动)',
97
+ 'channel.category.stableDualHint': 'Stable Audio 模型按模型名自动识别为音乐+音效,无需设置分类。',
92
98
  'channel.addModel': '添加模型',
93
99
  'channel.removeModel': '移除',
94
100
  'channel.fetchModels': '获取可用模型',
@@ -97,6 +103,7 @@ export const zh = {
97
103
  'channel.fetchEmpty': '未发现音频相关模型。',
98
104
  'channel.discoverSource': '来源:{source}',
99
105
  'channel.candidates': '发现 {n} 个可用模型/音色',
106
+ 'channel.stabilityHint': 'Stable Audio 模型(stable-audio-*)同时适用于「音乐生成」与「音效生成」,面板按模型名自动识别;分类仅影响默认展示。官方不支持 TTS。',
100
107
  'channel.selectAll': '全选',
101
108
  'channel.clearSelection': '清空',
102
109
  'channel.adoptSelected': '添加所选({n})',
@@ -142,6 +149,10 @@ export const en: Record<AudioGenKey, string> = {
142
149
  'settings.allowAgentAudio': 'Allow agents to generate audio',
143
150
  'settings.autoSaveLibrary': 'Auto-save generated audio to the library',
144
151
  'settings.maxConcurrent': 'Max concurrent generations (in-flight upstream calls, default 5)',
152
+ 'settings.enhanceModel': 'Prompt enhance model (LLM)',
153
+ 'settings.enhanceModelDefault': 'Follow the agent default model (Settings → Models)',
154
+ 'settings.enhanceModelHint': 'Used by "✨ Enhance prompt" and the generate_audio enhance_prompt parameter; empty follows the default model in Settings → Models.',
155
+ 'settings.enhanceModelFailed': 'Failed to load the model list',
145
156
  'settings.save': 'Save',
146
157
  'settings.saving': 'Saving…',
147
158
  'settings.discard': 'Discard',
@@ -194,6 +205,8 @@ export const en: Record<AudioGenKey, string> = {
194
205
  'channel.category.sfx': 'SFX',
195
206
  'channel.category.voice_design': 'Voice design',
196
207
  'channel.category.voice_clone': 'Voice clone',
208
+ 'channel.category.stableDual': 'Music + SFX (auto)',
209
+ 'channel.category.stableDualHint': 'Stable Audio models are auto-detected as Music + SFX by model name; no category needed.',
197
210
  'channel.addModel': 'Add model',
198
211
  'channel.removeModel': 'Remove',
199
212
  'channel.fetchModels': 'Fetch available models',
@@ -202,6 +215,7 @@ export const en: Record<AudioGenKey, string> = {
202
215
  'channel.fetchEmpty': 'No audio-related models found.',
203
216
  'channel.discoverSource': 'Source: {source}',
204
217
  'channel.candidates': '{n} available model(s)/voice(s) found',
218
+ 'channel.stabilityHint': 'Stable Audio models (stable-audio-*) apply to both Music and SFX; the panel detects them by model name. The category only affects the default display. Official API does not support TTS.',
205
219
  'channel.selectAll': 'Select all',
206
220
  'channel.clearSelection': 'Clear',
207
221
  'channel.adoptSelected': 'Add selected ({n})',
@@ -31,6 +31,8 @@ export interface AudiogenConfig {
31
31
  defaultModel?: string
32
32
  /** 生成完成后自动保存到资源库(面板与 Agent 生成均生效)。 */
33
33
  autoSaveToLibrary?: boolean
34
+ /** 提示词增强模型("provider|model";空串 = 跟随 Agent 默认模型)。 */
35
+ enhanceModel?: string
34
36
  }
35
37
 
36
38
  /** One settings path-op as the bridge consumes it. */
@@ -16,11 +16,26 @@ import {
16
16
  HISTORY_API,
17
17
  type AudioMode, type GeneratedAudio, type GenerateAudioRequest, type HistoryEntry, type LibraryEntry,
18
18
  } from '../protocol.ts'
19
+ import type { ModelOption } from './settings-scope.ts'
19
20
  import { AudioPlayer } from './audio-player.tsx'
20
21
  import { LibrarySaveDialog, type SaveDialogContext } from './library-save-dialog.tsx'
21
22
  import { CheckIcon, DownloadIcon, StarIcon } from './icons.tsx'
22
23
  import css from './audio-panel.module.css'
23
24
 
25
+ /**
26
+ * 模型是否适用于当前模式。
27
+ * - stable-audio-*(Stable Audio 系列):官方 text-to-audio 协议对音乐与音效是同一接口,
28
+ * 因此同时适用于「音乐生成」与「音效生成」;官方不支持 TTS(语音合成),故不出现在 TTS。
29
+ * - 其余模型:按设置中的分类(category)匹配,auto(未分类)适用于全部模式。
30
+ */
31
+ function modelSuitableForMode(entry: ModelOption, mode: AudioMode): boolean {
32
+ if (mode === 'voice_design') return false
33
+ if (/^stable-audio-/i.test(entry.alias)) return mode === 'music' || mode === 'sfx'
34
+ if (entry.category === undefined) return true
35
+ if (entry.category === mode) return true
36
+ return entry.category === 'tts' && mode === 'tts'
37
+ }
38
+
24
39
  export interface StudioReuse {
25
40
  nonce: number
26
41
  mode: AudioMode
@@ -209,7 +224,13 @@ export function StudioView(props: {
209
224
  })
210
225
 
211
226
  const [mode, setMode] = useState<AudioMode>('tts')
212
- const [prompt, setPrompt] = useState('')
227
+ /** 每个模式独立的输入内容(TTS 文本 / 音乐·音效提示词 / 音色描述),切模式互不干扰。 */
228
+ const [promptByMode, setPromptByMode] = useState<Record<AudioMode, string>>({ tts: '', music: '', sfx: '', voice_design: '' })
229
+ const prompt = promptByMode[mode] ?? ''
230
+ const setPrompt = (next: string): void => {
231
+ const target = mode
232
+ setPromptByMode(current => (current[target] === next ? current : { ...current, [target]: next }))
233
+ }
213
234
  const [previewText, setPreviewText] = useState('')
214
235
  const [model, setModel] = useState('')
215
236
  const [voice, setVoice] = useState('')
@@ -233,6 +254,8 @@ export function StudioView(props: {
233
254
  // 提示词增强
234
255
  const [enhancing, setEnhancing] = useState(false)
235
256
  const [enhancePreview, setEnhancePreview] = useState<string | null>(null)
257
+ // 切换模式后增强预览属于旧模式,清除以免误用(prompt 本身按模式独立保留)。
258
+ useEffect(() => { setEnhancePreview(null) }, [mode])
236
259
  // Stable Audio 参数(仅 Stability 渠道显示)
237
260
  const [seed, setSeed] = useState('')
238
261
  const [steps, setSteps] = useState('')
@@ -273,7 +296,7 @@ export function StudioView(props: {
273
296
  const visibleModels = useMemo(() => {
274
297
  if (mode === 'voice_design') return []
275
298
  return modelOptions.models
276
- .filter(entry => entry.category === undefined || entry.category === 'tts' && mode === 'tts' || entry.category === mode)
299
+ .filter(entry => modelSuitableForMode(entry, mode))
277
300
  .map(entry => entry.alias)
278
301
  }, [modelOptions.models, mode])
279
302
 
@@ -510,8 +533,9 @@ export function StudioView(props: {
510
533
  setTasks(current => current.filter(task => task.id !== taskId))
511
534
  }
512
535
 
513
- /** 从历史参数回填表单(参考 AI 生图「恢复」):配置 + prompt 一键复用。 */
514
- const restoreFromParams = (params: Record<string, unknown>, modeValue: AudioMode, singleModel: string, compareModelsRestore?: string[]): void => {
536
+ /** 从历史参数回填表单(参考 AI 生图「恢复」):配置 + prompt 一键复用。
537
+ * compareModelsRestore:对比任务恢复为对比模式;overridesRestore 由各模型 params 差异重建。 */
538
+ const restoreFromParams = (params: Record<string, unknown>, modeValue: AudioMode, singleModel: string, compareModelsRestore?: string[], overridesRestore?: Record<string, Record<string, string>>): void => {
515
539
  const str = (key: string): string | undefined => {
516
540
  const v = params[key]
517
541
  return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
@@ -528,7 +552,10 @@ export function StudioView(props: {
528
552
  const bool = (key: string): boolean | undefined => typeof params[key] === 'boolean' ? params[key] as boolean : undefined
529
553
  setMode(modeValue)
530
554
  const promptValue = str('prompt')
531
- if (promptValue !== undefined) setPrompt(promptValue)
555
+ if (promptValue !== undefined) {
556
+ // 直接写入被恢复模式自己的槽位(此刻闭包 mode 仍是旧值)。
557
+ setPromptByMode(current => (current[modeValue] === promptValue ? current : { ...current, [modeValue]: promptValue }))
558
+ }
532
559
  const modelValue = str('model') ?? singleModel
533
560
  if (modelValue !== '') setModel(modelValue)
534
561
  if (compareModelsRestore !== undefined && compareModelsRestore.length > 0) {
@@ -580,9 +607,29 @@ export function StudioView(props: {
580
607
  if (previewValue !== undefined) setPreviewText(previewValue)
581
608
  const channelIdValue = str('channelId')
582
609
  if (channelIdValue !== undefined && modeValue === 'voice_design') setDesignChannelId(channelIdValue)
610
+ // 恢复对比任务时重建每模型参数覆盖(各模型 params 与第一项的差异);单条恢复清空覆盖。
611
+ setOverrides(overridesRestore ?? {})
583
612
  props.showToast('已恢复该次生成的配置,可直接再次生成')
584
613
  }
585
614
 
615
+ /** 由对比历史条目重建每模型参数覆盖:以第一项为全局基准,其余条目与基准不同的字段即覆盖。 */
616
+ const overridesOfCompare = (models: Array<{ model: string; entry: HistoryEntry }>): Record<string, Record<string, string>> => {
617
+ const base = (models[0]?.entry.params ?? {}) as Record<string, unknown>
618
+ const out: Record<string, Record<string, string>> = {}
619
+ for (const item of models.slice(1)) {
620
+ const params = (item.entry.params ?? {}) as Record<string, unknown>
621
+ const diff: Record<string, string> = {}
622
+ for (const [key, value] of Object.entries(params)) {
623
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') continue
624
+ if (key === 'taskId' || key === 'upstream') continue
625
+ if (typeof base[key] === typeof value && String(base[key]) === String(value)) continue
626
+ diff[key] = String(value)
627
+ }
628
+ if (Object.keys(diff).length > 0) out[item.model] = diff
629
+ }
630
+ return out
631
+ }
632
+
586
633
  /** 调用宿主增强(Agent 默认模型),结果先预览再应用。 */
587
634
  const runEnhance = async (): Promise<void> => {
588
635
  if (prompt.trim() === '') {
@@ -878,6 +925,8 @@ export function StudioView(props: {
878
925
 
879
926
  const needModel = mode !== 'voice_design'
880
927
  const runningCount = tasks.filter(task => task.status === 'running').length
928
+ /** 结果列只显示当前模式的任务(历史面板已按模式分组)。 */
929
+ const visibleTasks = useMemo(() => tasks.filter(task => task.mode === mode), [tasks, mode])
881
930
 
882
931
  return (
883
932
  <div className={css.studio}>
@@ -1031,7 +1080,11 @@ export function StudioView(props: {
1031
1080
  {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
1032
1081
  {groupedModels.map(group => (
1033
1082
  <optgroup key={group.channelId} label={group.channelName}>
1034
- {group.models.map(item => <option key={item.alias} value={item.alias}>{item.alias}</option>)}
1083
+ {group.models.map(item => (
1084
+ <option key={item.alias} value={item.alias}>
1085
+ {/^stable-audio-/i.test(item.alias) ? `${item.alias} · 音乐/音效` : item.alias}
1086
+ </option>
1087
+ ))}
1035
1088
  </optgroup>
1036
1089
  ))}
1037
1090
  </select>
@@ -1074,7 +1127,7 @@ export function StudioView(props: {
1074
1127
 
1075
1128
  <div className={css.resultCol}>
1076
1129
  {error !== null ? <p className={css.error}>{error}</p> : null}
1077
- {tasks.length === 0 ? (
1130
+ {visibleTasks.length === 0 ? (
1078
1131
  <div className={css.resultEmpty}>
1079
1132
  <span className={css.resultEmptyIcon}>🎵</span>
1080
1133
  <p>{tt('result.empty')}</p>
@@ -1091,7 +1144,7 @@ export function StudioView(props: {
1091
1144
  </div>
1092
1145
  ) : (
1093
1146
  <div className={css.taskList}>
1094
- {tasks.map(task => {
1147
+ {visibleTasks.map(task => {
1095
1148
  const elapsed = task.finishedAt !== undefined
1096
1149
  ? Math.round((task.finishedAt - task.startedAt) / 1000)
1097
1150
  : Math.round((Date.now() - task.startedAt) / 1000)
@@ -1203,6 +1256,7 @@ export function StudioView(props: {
1203
1256
  item.mode,
1204
1257
  item.models[0]?.model ?? '',
1205
1258
  item.models.map(model => model.model),
1259
+ overridesOfCompare(item.models),
1206
1260
  )}>↺</button>
1207
1261
  <button type="button" className={css.historyIcon} title="删除整个对比任务" onClick={() => void deleteHistoryEntries(item.models.map(model => model.entry.id))}>✕</button>
1208
1262
  </div>