dsh-audiogen 0.4.5 → 0.4.7

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,32 +884,60 @@ 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");
893
901
  const runtime = deps.llm?.();
894
902
  if (runtime === void 0 || runtime.stream === void 0) throw new AudioGenError("宿主 LLM 服务不可用(ctx.llm 未注册)", "llm-unavailable");
903
+ const controller = new AbortController();
904
+ const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), 3e4);
905
+ timer.unref?.();
895
906
  let output = "";
896
- for await (const chunk of runtime.stream({
897
- provider,
898
- model,
899
- messages: [{
900
- role: "user",
901
- content: text
902
- }],
903
- system: instructionsFor(mode),
904
- temperature: .7,
905
- maxTokens: 1200
906
- })) {
907
- const record = chunk;
908
- if (record.type === "text-delta" && typeof record.text === "string") output += record.text;
907
+ let terminalFailure = "";
908
+ try {
909
+ for await (const chunk of runtime.stream({
910
+ provider,
911
+ model,
912
+ messages: [{
913
+ role: "user",
914
+ content: [{
915
+ type: "text",
916
+ text
917
+ }]
918
+ }],
919
+ system: instructionsFor(mode),
920
+ temperature: .7,
921
+ maxTokens: 1200,
922
+ signal: controller.signal
923
+ })) {
924
+ const record = chunk;
925
+ if (record.type === "text-delta" && typeof record.text === "string") output += record.text;
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
+ }
931
+ }
932
+ } finally {
933
+ clearTimeout(timer);
934
+ }
935
+ const result = stripFences(output.trim());
936
+ if (result === "") {
937
+ if (terminalFailure !== "") throw new AudioGenError(`增强失败:LLM 调用出错(${terminalFailure})。请检查「设置 → 模型」的默认模型是否可用`, "enhance-llm-error");
938
+ throw new AudioGenError("模型未返回增强内容:请检查「设置 → 模型」的默认模型是否可用(或稍后重试)", "enhance-empty-result");
909
939
  }
910
- return stripFences(output.trim());
940
+ return result;
911
941
  }
912
942
  /** 去掉模型可能包裹的 ``` 代码围栏。 */
913
943
  function stripFences(value) {
@@ -1860,6 +1890,25 @@ function makeRoutes(deps) {
1860
1890
  });
1861
1891
  }
1862
1892
  },
1893
+ {
1894
+ kind: "exact",
1895
+ path: LLM_MODELS_API,
1896
+ handler: async (req, res) => {
1897
+ if (!guard(req, res, "POST")) return;
1898
+ try {
1899
+ writeJson(res, 200, {
1900
+ ok: true,
1901
+ providers: await deps.llmModelOptions()
1902
+ });
1903
+ } catch (error) {
1904
+ writeJson(res, 200, {
1905
+ ok: false,
1906
+ code: "llm-models-failed",
1907
+ message: messageOf(error)
1908
+ });
1909
+ }
1910
+ }
1911
+ },
1863
1912
  {
1864
1913
  kind: "exact",
1865
1914
  path: MODEL_API.discover,
@@ -3123,7 +3172,8 @@ const Config = z.object({
3123
3172
  defaultChannelId: z.string().default(""),
3124
3173
  defaultModel: z.string().default(""),
3125
3174
  autoSaveToLibrary: z.boolean().default(false),
3126
- maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT)
3175
+ maxConcurrentGenerations: z.union([z.number(), z.string()]).default(DEFAULT_MAX_CONCURRENT),
3176
+ enhanceModel: z.string().default("")
3127
3177
  });
3128
3178
  const DEFAULT_ENABLED = true;
3129
3179
  const DEFAULT_ANNOUNCE = true;
@@ -3216,6 +3266,7 @@ function apply(ctx, config) {
3216
3266
  })),
3217
3267
  defaultChannelId,
3218
3268
  defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
3269
+ enhanceModel: typeof value.enhanceModel === "string" ? value.enhanceModel.trim() : "",
3219
3270
  autoSaveToLibrary: value.autoSaveToLibrary === true,
3220
3271
  maxConcurrentGenerations: (() => {
3221
3272
  const rawMax = value.maxConcurrentGenerations;
@@ -3231,7 +3282,51 @@ function apply(ctx, config) {
3231
3282
  return enhancePromptText({
3232
3283
  settings: seam,
3233
3284
  llm: () => ctx.get("llm")
3234
- }, prompt, mode);
3285
+ }, prompt, mode, enhanceSelectionOf(resolve()));
3286
+ };
3287
+ /** 解析设置的增强模型("provider|model");空/非法值返回 undefined = 跟随默认。 */
3288
+ const enhanceSelectionOf = (config) => {
3289
+ const raw = config.enhanceModel ?? "";
3290
+ const sep = raw.indexOf("|");
3291
+ if (sep <= 0 || sep >= raw.length - 1) return void 0;
3292
+ const provider = raw.slice(0, sep).trim();
3293
+ const model = raw.slice(sep + 1).trim();
3294
+ return provider !== "" && model !== "" ? {
3295
+ provider,
3296
+ model
3297
+ } : void 0;
3298
+ };
3299
+ /** 读取「设置 → 模型」目录:各提供方 + 可广播的模型列表(增强模型下拉候选)。 */
3300
+ const llmModelOptions = async () => {
3301
+ const llm = ctx.get("llm");
3302
+ if (llm === void 0 || llm.listProviders === void 0 || llm.listModels === void 0) return [];
3303
+ const options = [];
3304
+ const directory = /* @__PURE__ */ new Map();
3305
+ if (llm.listConfigurableProviders !== void 0) {
3306
+ for (const entry of llm.listConfigurableProviders() ?? []) if (typeof entry.provider === "string" && entry.provider !== "" && typeof entry.displayName === "string") directory.set(entry.provider, entry.displayName);
3307
+ }
3308
+ for (const info of llm.listProviders() ?? []) {
3309
+ const provider = typeof info.id === "string" ? info.id : "";
3310
+ if (provider === "") continue;
3311
+ let models = [];
3312
+ try {
3313
+ models = await llm.listModels(provider) ?? [];
3314
+ } catch {
3315
+ models = [];
3316
+ }
3317
+ for (const model of models) {
3318
+ const id = typeof model.id === "string" ? model.id.trim() : "";
3319
+ if (id === "") continue;
3320
+ const name = typeof model.name === "string" && model.name.trim() !== "" ? model.name.trim() : id;
3321
+ options.push({
3322
+ provider,
3323
+ providerName: directory.get(provider) ?? provider,
3324
+ id,
3325
+ name
3326
+ });
3327
+ }
3328
+ }
3329
+ return options;
3235
3330
  };
3236
3331
  const channelsView = () => {
3237
3332
  const value = resolve();
@@ -3248,7 +3343,8 @@ function apply(ctx, config) {
3248
3343
  resolveChannels: channelsView,
3249
3344
  autoSave: () => resolve().autoSaveToLibrary,
3250
3345
  budget,
3251
- enhance
3346
+ enhance,
3347
+ llmModelOptions
3252
3348
  }).map((route) => ctx.webServer.register(route));
3253
3349
  return () => {
3254
3350
  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.5",
4
+ "version": "0.4.7",
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')}
@@ -97,9 +97,9 @@
97
97
  flex-direction: column;
98
98
  gap: 11px;
99
99
  flex: none;
100
- width: 300px;
101
- min-width: 260px;
102
- max-width: 340px;
100
+ width: 380px;
101
+ min-width: 320px;
102
+ max-width: 440px;
103
103
  min-height: 0;
104
104
  overflow-y: auto;
105
105
  padding: 2px;
@@ -260,6 +260,7 @@
260
260
  .resultCol {
261
261
  flex: 1;
262
262
  min-width: 0;
263
+ max-width: 760px;
263
264
  display: flex;
264
265
  flex-direction: column;
265
266
  gap: 10px;
@@ -1314,3 +1315,9 @@
1314
1315
  display: flex;
1315
1316
  gap: 6px;
1316
1317
  }
1318
+
1319
+ .enhanceActionsRow {
1320
+ display: flex;
1321
+ justify-content: flex-end;
1322
+ margin-top: -4px;
1323
+ }
@@ -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
 
@@ -527,6 +550,11 @@ export function StudioView(props: {
527
550
  }
528
551
  const bool = (key: string): boolean | undefined => typeof params[key] === 'boolean' ? params[key] as boolean : undefined
529
552
  setMode(modeValue)
553
+ const promptValue = str('prompt')
554
+ if (promptValue !== undefined) {
555
+ // 直接写入被恢复模式自己的槽位(此刻闭包 mode 仍是旧值)。
556
+ setPromptByMode(current => (current[modeValue] === promptValue ? current : { ...current, [modeValue]: promptValue }))
557
+ }
530
558
  const modelValue = str('model') ?? singleModel
531
559
  if (modelValue !== '') setModel(modelValue)
532
560
  if (compareModelsRestore !== undefined && compareModelsRestore.length > 0) {
@@ -591,11 +619,11 @@ export function StudioView(props: {
591
619
  setError(null)
592
620
  try {
593
621
  const result = await api.enhancePrompt(prompt.trim(), mode)
594
- if (result.ok !== true || result.enhanced === undefined) {
622
+ if (result.ok !== true || result.enhanced === undefined || result.enhanced.trim() === '') {
595
623
  setError(result.message ?? '增强失败,请稍后重试')
596
624
  return
597
625
  }
598
- setEnhancePreview(result.enhanced)
626
+ setEnhancePreview(result.enhanced.trim())
599
627
  } catch (err) {
600
628
  setError(err instanceof Error ? err.message : String(err))
601
629
  } finally {
@@ -876,6 +904,8 @@ export function StudioView(props: {
876
904
 
877
905
  const needModel = mode !== 'voice_design'
878
906
  const runningCount = tasks.filter(task => task.status === 'running').length
907
+ /** 结果列只显示当前模式的任务(历史面板已按模式分组)。 */
908
+ const visibleTasks = useMemo(() => tasks.filter(task => task.mode === mode), [tasks, mode])
879
909
 
880
910
  return (
881
911
  <div className={css.studio}>
@@ -895,16 +925,16 @@ export function StudioView(props: {
895
925
  ))}
896
926
  </div>
897
927
 
898
- <div className={css.formSectionRow}>
899
- <p className={css.formSection}>输入</p>
900
- <button type="button" className={css.ghostButton} disabled={enhancing} onClick={() => void runEnhance()}>
901
- {enhancing ? '增强中…' : '✨ 增强提示词'}
902
- </button>
903
- </div>
928
+ <p className={css.formSection}>输入</p>
904
929
  <label className={css.label}>
905
930
  <span>{mode === 'voice_design' ? '音色描述' : mode === 'tts' ? '文本' : '提示词'}</span>
906
931
  <textarea className={css.textarea} value={prompt} onChange={event => setPrompt(event.target.value)} placeholder={tt('prompt.placeholder')} />
907
932
  </label>
933
+ <div className={css.enhanceActionsRow}>
934
+ <button type="button" className={css.ghostButton} disabled={enhancing} onClick={() => void runEnhance()}>
935
+ {enhancing ? '增强中…' : '✨ 增强提示词'}
936
+ </button>
937
+ </div>
908
938
  {enhancePreview !== null ? (
909
939
  <div className={css.enhanceCard}>
910
940
  <div className={css.enhanceCardHead}>
@@ -1029,7 +1059,11 @@ export function StudioView(props: {
1029
1059
  {visibleModels.length === 0 ? <option value="">(当前模式暂无可用模型)</option> : null}
1030
1060
  {groupedModels.map(group => (
1031
1061
  <optgroup key={group.channelId} label={group.channelName}>
1032
- {group.models.map(item => <option key={item.alias} value={item.alias}>{item.alias}</option>)}
1062
+ {group.models.map(item => (
1063
+ <option key={item.alias} value={item.alias}>
1064
+ {/^stable-audio-/i.test(item.alias) ? `${item.alias} · 音乐/音效` : item.alias}
1065
+ </option>
1066
+ ))}
1033
1067
  </optgroup>
1034
1068
  ))}
1035
1069
  </select>
@@ -1072,7 +1106,7 @@ export function StudioView(props: {
1072
1106
 
1073
1107
  <div className={css.resultCol}>
1074
1108
  {error !== null ? <p className={css.error}>{error}</p> : null}
1075
- {tasks.length === 0 ? (
1109
+ {visibleTasks.length === 0 ? (
1076
1110
  <div className={css.resultEmpty}>
1077
1111
  <span className={css.resultEmptyIcon}>🎵</span>
1078
1112
  <p>{tt('result.empty')}</p>
@@ -1089,7 +1123,7 @@ export function StudioView(props: {
1089
1123
  </div>
1090
1124
  ) : (
1091
1125
  <div className={css.taskList}>
1092
- {tasks.map(task => {
1126
+ {visibleTasks.map(task => {
1093
1127
  const elapsed = task.finishedAt !== undefined
1094
1128
  ? Math.round((task.finishedAt - task.startedAt) / 1000)
1095
1129
  : Math.round((Date.now() - task.startedAt) / 1000)