dsh-audiogen 0.4.19 → 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/lib/index.js CHANGED
@@ -35,7 +35,12 @@ const MODEL_API = { discover: "/api/dsh-audiogen/models/discover" };
35
35
  /** Vendor voice management (browse/filter + delete) endpoints. */
36
36
  const VOICES_API = {
37
37
  list: "/api/dsh-audiogen/voices/list",
38
- delete: "/api/dsh-audiogen/voices/delete"
38
+ delete: "/api/dsh-audiogen/voices/delete",
39
+ recommend: "/api/dsh-audiogen/voices/recommend",
40
+ recommendHistory: {
41
+ list: "/api/dsh-audiogen/voices/recommend-history/list",
42
+ remove: "/api/dsh-audiogen/voices/recommend-history/remove"
43
+ }
39
44
  };
40
45
  /** Loopback-only audio file reader for panel/tool-result previews. */
41
46
  const AUDIO_API = { file: "/api/dsh-audiogen/audio" };
@@ -1054,7 +1059,7 @@ async function enhancePromptText(deps, prompt, mode, override) {
1054
1059
  } finally {
1055
1060
  clearTimeout(timer);
1056
1061
  }
1057
- const result = stripFences(output.trim());
1062
+ const result = stripFences$1(output.trim());
1058
1063
  if (result === "") {
1059
1064
  if (terminalFailure !== "") throw new AudioGenError(`增强失败:LLM 调用出错(${terminalFailure})。请检查「设置 → 模型」的默认模型是否可用`, "enhance-llm-error");
1060
1065
  throw new AudioGenError("模型未返回增强内容:请检查「设置 → 模型」的默认模型是否可用(或稍后重试)", "enhance-empty-result");
@@ -1062,7 +1067,7 @@ async function enhancePromptText(deps, prompt, mode, override) {
1062
1067
  return result;
1063
1068
  }
1064
1069
  /** 去掉模型可能包裹的 ``` 代码围栏。 */
1065
- function stripFences(value) {
1070
+ function stripFences$1(value) {
1066
1071
  if (value === "") return value;
1067
1072
  return value.replace(/^```[a-zA-Z]*\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
1068
1073
  }
@@ -1074,6 +1079,40 @@ const SHARED_VOICE_SORT_OPTIONS = [
1074
1079
  "oldest",
1075
1080
  "newest"
1076
1081
  ];
1082
+ /**
1083
+ * ISO 639-1 -> matching substrings across the two vendor vocabularies:
1084
+ * ElevenLabs uses ISO codes ("en"), MiniMax system ids use language labels
1085
+ * ("Chinese (Mandarin)_..." / "Japanese_..."). The aliases make one dropdown
1086
+ * value match both. "zh" also covers Cantonese (Chinese (Cantonese)).
1087
+ */
1088
+ const LANGUAGE_ALIASES = {
1089
+ zh: [
1090
+ "zh",
1091
+ "chinese",
1092
+ "mandarin",
1093
+ "cantonese",
1094
+ "yue"
1095
+ ],
1096
+ en: ["en", "english"],
1097
+ ja: ["ja", "japanese"],
1098
+ ko: ["ko", "korean"],
1099
+ es: ["es", "spanish"],
1100
+ fr: ["fr", "french"],
1101
+ de: ["de", "german"],
1102
+ ru: ["ru", "russian"],
1103
+ it: ["it", "italian"],
1104
+ pt: ["pt", "portuguese"],
1105
+ ar: ["ar", "arabic"],
1106
+ hi: ["hi", "hindi"]
1107
+ };
1108
+ function languageMatches(language, locale, needle) {
1109
+ const haystack = [language ?? "", locale ?? ""].join(" ").toLowerCase();
1110
+ const value = needle.trim().toLowerCase();
1111
+ if (value === "") return true;
1112
+ if (haystack.includes(value)) return true;
1113
+ for (const alias of LANGUAGE_ALIASES[value] ?? []) if (haystack.includes(alias)) return true;
1114
+ return false;
1115
+ }
1077
1116
  const MINIMAX_LANGUAGE_PREFIXES = [
1078
1117
  "Chinese (Mandarin)",
1079
1118
  "Chinese (Cantonese)",
@@ -1105,7 +1144,7 @@ function baseUrl$1(url) {
1105
1144
  function languageFromMiniMaxId(voiceId) {
1106
1145
  for (const prefix of MINIMAX_LANGUAGE_PREFIXES) if (voiceId.startsWith(`${prefix}_`)) return prefix;
1107
1146
  }
1108
- function asStringList(value) {
1147
+ function asStringList$1(value) {
1109
1148
  if (Array.isArray(value)) return value.map((item) => String(item)).filter((item) => item.trim() !== "");
1110
1149
  if (typeof value === "string" && value.trim() !== "") return [value.trim()];
1111
1150
  return [];
@@ -1130,7 +1169,7 @@ async function postJson$1(url, apiKey, body) {
1130
1169
  }
1131
1170
  function normalizeMiniMax(voice, source) {
1132
1171
  const voiceId = String(voice.voice_id ?? "").trim();
1133
- const description = asStringList(voice.description).join(";") || void 0;
1172
+ const description = asStringList$1(voice.description).join(";") || void 0;
1134
1173
  const language = languageFromMiniMaxId(voiceId);
1135
1174
  return {
1136
1175
  provider: "minimax",
@@ -1171,10 +1210,14 @@ function normalizeElevenLabs(voice, source) {
1171
1210
  return (typeof owned === "string" && owned.trim() !== "" ? owned : typeof direct === "string" && direct.trim() !== "" ? direct : void 0)?.trim() || void 0;
1172
1211
  };
1173
1212
  const description = pick("description");
1213
+ const name = String(voice.name ?? voiceId).trim() || voiceId;
1214
+ const keptLabels = {};
1215
+ for (const [key, value] of Object.entries(labels ?? {})) if (typeof value === "string" && value.trim() !== "") keptLabels[key] = value;
1216
+ const descriptive = pick("descriptive");
1174
1217
  return {
1175
1218
  provider: "elevenlabs",
1176
1219
  voice_id: voiceId,
1177
- name: String(voice.name ?? voiceId).trim() || voiceId,
1220
+ name,
1178
1221
  source,
1179
1222
  ...pick("language") === void 0 ? {} : { language: pick("language") },
1180
1223
  ...pick("locale") === void 0 ? {} : { locale: pick("locale") },
@@ -1183,6 +1226,8 @@ function normalizeElevenLabs(voice, source) {
1183
1226
  ...pick("age") === void 0 ? {} : { age: pick("age") },
1184
1227
  ...pick("use_case") === void 0 ? {} : { use_case: pick("use_case") },
1185
1228
  ...pick("category") === void 0 ? {} : { category: pick("category") },
1229
+ ...Object.keys(keptLabels).length === 0 ? {} : { labels: keptLabels },
1230
+ ...descriptive === void 0 ? {} : { descriptive },
1186
1231
  ...description === void 0 ? {} : { description },
1187
1232
  ...typeof voice.preview_url === "string" && voice.preview_url.trim() !== "" ? { preview_url: voice.preview_url.trim() } : {},
1188
1233
  deletable: source === "owned"
@@ -1285,6 +1330,32 @@ async function listVendorVoices(channel, options = {}) {
1285
1330
  ...result.note === void 0 ? {} : { note: result.note }
1286
1331
  };
1287
1332
  }
1333
+ /**
1334
+ * listVendorVoices 的网关友好版本:聚合网关(new-api 等)通常只代理生成接口、
1335
+ * 不映射 /v1/voices 与 /v1/shared-voices,此时把「渠道配置的音色目录」
1336
+ * (channel.models 的 alias 列表)作为候选池,保证音色浏览/推荐/选角仍可用。
1337
+ * 返回条目的 source 为 `configured`(只读、无试听、无性别/年龄元数据)。
1338
+ */
1339
+ async function listVendorVoicesWithFallback(channel, options = {}) {
1340
+ try {
1341
+ return await listVendorVoices(channel, options);
1342
+ } catch (error) {
1343
+ const entries = (channel.models ?? []).filter((model) => model.alias.trim() !== "").map((model) => ({
1344
+ provider: channel.preset || "configured",
1345
+ voice_id: model.alias,
1346
+ name: model.alias,
1347
+ source: "configured",
1348
+ deletable: false
1349
+ }));
1350
+ if (entries.length === 0) throw error;
1351
+ const reason = error instanceof Error ? error.message : String(error);
1352
+ return {
1353
+ vendor: channel.preset || "custom",
1354
+ ...applyFilter(entries, options),
1355
+ note: `音色库接口不可用(${reason.slice(0, 160)})。已回退为渠道配置的音色目录(${entries.length} 个):只能按名称选择,无试听与语言/性别/年龄元数据;配一个官方 API 地址(如 https://api.elevenlabs.io)的渠道可获得完整音色库`
1356
+ };
1357
+ }
1358
+ }
1288
1359
  async function deleteVendorVoice(channel, voiceId) {
1289
1360
  if (channel.apiUrl.trim() === "") throw new Error("渠道未配置 API 地址");
1290
1361
  if (channel.apiKey.trim() === "") throw new Error("渠道未配置 API 密钥");
@@ -1354,9 +1425,7 @@ function applyFilter(entries, options) {
1354
1425
  const count = cap(options);
1355
1426
  const matched = entries.filter((entry) => {
1356
1427
  if (source !== "" && entry.source !== source) return false;
1357
- if (language !== "") {
1358
- if (![entry.language ?? "", entry.locale ?? ""].join(" ").toLowerCase().includes(language)) return false;
1359
- }
1428
+ if (language !== "" && !languageMatches(entry.language, entry.locale, language)) return false;
1360
1429
  if (field(filters.accent) !== "" && field(entry.accent) !== field(filters.accent)) return false;
1361
1430
  if (field(filters.gender) !== "" && field(entry.gender) !== field(filters.gender)) return false;
1362
1431
  if (field(filters.age) !== "" && field(entry.age) !== field(filters.age)) return false;
@@ -1374,7 +1443,9 @@ function applyFilter(entries, options) {
1374
1443
  entry.accent ?? "",
1375
1444
  entry.use_case ?? "",
1376
1445
  entry.gender ?? "",
1377
- entry.age ?? ""
1446
+ entry.age ?? "",
1447
+ entry.descriptive ?? "",
1448
+ ...Object.values(entry.labels ?? {})
1378
1449
  ].join(" ").toLowerCase();
1379
1450
  for (const token of keyword.split(/\s+/)) if (token !== "" && !haystack.includes(token)) return false;
1380
1451
  }
@@ -1387,6 +1458,252 @@ function applyFilter(entries, options) {
1387
1458
  };
1388
1459
  }
1389
1460
  //#endregion
1461
+ //#region src/voice-recommend.ts
1462
+ /** 推荐记录上限(超出最旧淘汰,新记录永远插在最前)。 */
1463
+ const RECORD_LIMIT = 50;
1464
+ function recommendStorePath() {
1465
+ return path.join(process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh"), "dsh-audiogen", "voice-recommends.json");
1466
+ }
1467
+ async function loadRecommendStore() {
1468
+ try {
1469
+ const text = await readFile(recommendStorePath(), "utf-8");
1470
+ const payload = JSON.parse(text);
1471
+ if (typeof payload === "object" && payload !== null && Array.isArray(payload.entries)) return {
1472
+ version: 1,
1473
+ updatedAt: String(payload.updatedAt ?? ""),
1474
+ entries: payload.entries
1475
+ };
1476
+ } catch {}
1477
+ return {
1478
+ version: 1,
1479
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1480
+ entries: []
1481
+ };
1482
+ }
1483
+ /** 追加一条 AI 推荐记录(最好努力:失败不阻断推荐结果返回)。 */
1484
+ async function appendVoiceRecommendRecord(record) {
1485
+ const stored = {
1486
+ id: randomUUID(),
1487
+ createdAt: Date.now(),
1488
+ ...record
1489
+ };
1490
+ try {
1491
+ const store = await loadRecommendStore();
1492
+ store.entries = [stored, ...store.entries.filter((entry) => entry.id !== stored.id)].slice(0, RECORD_LIMIT);
1493
+ store.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
1494
+ const file = recommendStorePath();
1495
+ await mkdir(path.dirname(file), { recursive: true });
1496
+ await writeFile(file, JSON.stringify(store, null, 2) + "\n", "utf-8");
1497
+ } catch {}
1498
+ return stored;
1499
+ }
1500
+ /** 读取最近 N 条 AI 推荐记录(新→旧)。 */
1501
+ async function listVoiceRecommendRecords(limit = 20) {
1502
+ return (await loadRecommendStore()).entries.slice(0, Math.max(1, Math.min(50, Math.floor(limit))));
1503
+ }
1504
+ /** 删除一条 AI 推荐记录(不存在的 id 幂等成功)。 */
1505
+ async function removeVoiceRecommendRecord(id) {
1506
+ const store = await loadRecommendStore();
1507
+ const next = store.entries.filter((entry) => entry.id !== id);
1508
+ if (next.length === store.entries.length) return;
1509
+ const file = recommendStorePath();
1510
+ await mkdir(path.dirname(file), { recursive: true });
1511
+ await writeFile(file, JSON.stringify({
1512
+ ...store,
1513
+ entries: next,
1514
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1515
+ }, null, 2) + "\n", "utf-8");
1516
+ }
1517
+ /** 候选描述的截断长度(超出以 … 结尾)。 */
1518
+ const MAX_DESCRIPTION_CHARS$1 = 400;
1519
+ /** LLM 调用超时。 */
1520
+ const RECOMMEND_TIMEOUT_MS = 45e3;
1521
+ /** 输出 token 预算:默认模型多为推理模型,思考 token 计入输出,需留足预算。 */
1522
+ const RECOMMEND_MAX_TOKENS = 8192;
1523
+ /** 需求描述 + 候选池 → top-k 推荐(每条含 LLM 理由)。 */
1524
+ async function recommendVoices(deps, requirement, candidates, topK) {
1525
+ const text = requirement.trim();
1526
+ if (text === "") throw new AudioGenError("需求描述为空,无法推荐音色", "recommend-empty-requirement");
1527
+ if (candidates.length === 0) throw new AudioGenError("候选音色池为空:请先确认渠道配置了音色库,或放宽筛选条件", "recommend-no-candidates");
1528
+ const value = (deps.settings.describe({ redactSecrets: true }) ?? []).find((candidate) => String(candidate.ns) === "agent-default-model")?.value ?? {};
1529
+ const provider = typeof value.provider === "string" && value.provider.trim() !== "" ? value.provider.trim() : "";
1530
+ const model = typeof value.model === "string" && value.model.trim() !== "" ? value.model.trim() : "";
1531
+ if (provider === "" || model === "") throw new AudioGenError("未找到 Agent 默认模型(agent-default-model):请先在「设置 → 模型」中配置默认模型", "no-default-model");
1532
+ const runtime = deps.llm?.();
1533
+ if (runtime === void 0 || runtime.stream === void 0) throw new AudioGenError("宿主 LLM 服务不可用(ctx.llm 未注册)", "llm-unavailable");
1534
+ const messages = buildRecommendMessages(text, candidates, topK);
1535
+ const controller = new AbortController();
1536
+ const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), RECOMMEND_TIMEOUT_MS);
1537
+ timer.unref?.();
1538
+ let output = "";
1539
+ let terminalFailure = "";
1540
+ try {
1541
+ for await (const chunk of runtime.stream({
1542
+ provider,
1543
+ model,
1544
+ messages: [{
1545
+ role: "user",
1546
+ content: [{
1547
+ type: "text",
1548
+ text: messages.user
1549
+ }]
1550
+ }],
1551
+ system: messages.system,
1552
+ temperature: .2,
1553
+ maxTokens: RECOMMEND_MAX_TOKENS,
1554
+ signal: controller.signal
1555
+ })) {
1556
+ const record = chunk;
1557
+ if (record.type === "text-delta" && typeof record.text === "string") output += record.text;
1558
+ else if (record.type === "block-end" && record.block !== void 0 && record.block.type === "text" && typeof record.block.text === "string") output += record.block.text;
1559
+ else if (record.type === "finish" && record.reason !== void 0 && record.reason.kind !== "stop" && record.reason.kind !== void 0 && terminalFailure === "") {
1560
+ const failure = record.reason.failure;
1561
+ terminalFailure = typeof failure?.message === "string" && failure.message.trim() !== "" ? `${failure.message}${typeof failure.code === "string" ? `(${failure.code})` : ""}` : `stream ${record.reason.kind}`;
1562
+ }
1563
+ }
1564
+ } finally {
1565
+ clearTimeout(timer);
1566
+ }
1567
+ const content = stripFences(output.trim());
1568
+ if (content === "") {
1569
+ if (terminalFailure !== "") throw new AudioGenError(`音色推荐失败:LLM 调用出错(${terminalFailure})。请检查「设置 → 模型」的默认模型是否可用`, "recommend-llm-error");
1570
+ throw new AudioGenError("模型未返回推荐内容:请检查「设置 → 模型」的默认模型是否可用(或稍后重试)", "recommend-empty-result");
1571
+ }
1572
+ const recommendations = parseVoiceRecommendations(content, candidates, topK);
1573
+ if (recommendations.length === 0) throw new AudioGenError("模型返回的推荐未能匹配候选池:请重试,或先用语言/关键词等条件缩小候选范围", "recommend-parse-failed");
1574
+ return recommendations;
1575
+ }
1576
+ /** 纯函数:解析 LLM 响应并把推荐校验为候选池成员;无效/编造 id 丢弃。
1577
+ *
1578
+ * 兼容三种返回形态(模型并不总守 JSON):
1579
+ * 1. JSON {"recommendations": [{"voice_id"|"voice_name": "...", "reason": "..."}]}
1580
+ * 2. JSON 数组 [{"voice_id": "..."}, ...] 或单对象 {"voice_id": "..."}
1581
+ * 3. 纯文本:按行/逗号/空白切分后,在候选池中做完全匹配(id 或 name,忽略大小写)
1582
+ */
1583
+ function parseVoiceRecommendations(content, candidates, topK) {
1584
+ const limit = Math.max(1, Math.floor(Number.isFinite(topK) ? topK : 5));
1585
+ const results = [];
1586
+ const seen = /* @__PURE__ */ new Set();
1587
+ const findCandidate = (raw) => {
1588
+ const needle = String(raw).trim();
1589
+ if (needle === "") return void 0;
1590
+ const byId = candidates.find((candidate) => candidate.voice_id === needle);
1591
+ if (byId !== void 0) return byId;
1592
+ const byName = candidates.find((candidate) => candidate.name.toLowerCase() === needle.toLowerCase());
1593
+ if (byName !== void 0) return byName;
1594
+ const normalized = needle.toLowerCase().replace(/\s+/g, " ");
1595
+ return candidates.find((candidate) => candidate.voice_id.toLowerCase() === normalized || candidate.name.toLowerCase().replace(/\s+/g, " ") === normalized);
1596
+ };
1597
+ const push = (candidate, reason) => {
1598
+ if (candidate === void 0 || results.length >= limit) return;
1599
+ if (seen.has(candidate.voice_id)) return;
1600
+ seen.add(candidate.voice_id);
1601
+ results.push({
1602
+ ...candidate,
1603
+ reason
1604
+ });
1605
+ };
1606
+ const data = loadJsonLoose(content);
1607
+ const rawItems = data === null ? void 0 : Array.isArray(data) ? data : typeof data === "object" ? data.recommendations : void 0;
1608
+ if (Array.isArray(rawItems)) for (const item of rawItems) {
1609
+ if (results.length >= limit) break;
1610
+ if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
1611
+ const record = item;
1612
+ const raw = record.voice_id ?? record.voice_name ?? record.name ?? record.id;
1613
+ const reason = typeof record.reason === "string" ? record.reason.trim() : "";
1614
+ push(findCandidate(String(raw)), reason);
1615
+ }
1616
+ if (results.length < limit) {
1617
+ const flat = content.replace(/```[a-zA-Z]*/g, " ");
1618
+ const lower = flat.toLowerCase();
1619
+ const found = candidates.filter((candidate) => {
1620
+ if (candidate.voice_id.length >= 3 && lower.includes(candidate.voice_id.toLowerCase())) return true;
1621
+ if (candidate.name.length >= 3 && lower.includes(candidate.name.toLowerCase())) return true;
1622
+ return false;
1623
+ }).sort((a, b) => {
1624
+ const ai = lower.indexOf(a.voice_id.toLowerCase());
1625
+ const aiAlt = lower.indexOf(a.name.toLowerCase());
1626
+ const bi = lower.indexOf(b.voice_id.toLowerCase());
1627
+ const biAlt = lower.indexOf(b.name.toLowerCase());
1628
+ const posA = ai === -1 ? aiAlt : ai === -1 ? ai : ai;
1629
+ const posB = bi === -1 ? biAlt : bi === -1 ? bi : bi;
1630
+ return (posA === -1 ? Number.MAX_SAFE_INTEGER : posA) - (posB === -1 ? Number.MAX_SAFE_INTEGER : posB);
1631
+ });
1632
+ for (const candidate of found) {
1633
+ if (results.length >= limit) break;
1634
+ push(candidate, "");
1635
+ }
1636
+ if (results.length < limit) {
1637
+ const tokens = flat.replace(/[{}[\],:;"'`\n,。!?、;:()()【】《》]+/g, " ").split(/\s+/).map((token) => token.trim()).filter((token) => token !== "");
1638
+ for (const token of tokens) {
1639
+ if (results.length >= limit) break;
1640
+ push(findCandidate(token), "");
1641
+ }
1642
+ }
1643
+ }
1644
+ return results;
1645
+ }
1646
+ /** 构造推荐提示:系统(选角专家)+ 用户(需求 + 压缩候选 JSON)。 */
1647
+ function buildRecommendMessages(requirement, candidates, topK) {
1648
+ const semantic = (entry) => entry.language !== void 0 && entry.language !== "" || entry.gender !== void 0 && entry.gender !== "" || entry.age !== void 0 && entry.age !== "" || entry.accent !== void 0 && entry.accent !== "" || entry.description !== void 0 && entry.description !== "";
1649
+ const shown = [...candidates].sort((a, b) => semantic(a) === semantic(b) ? 0 : semantic(a) ? -1 : 1).slice(0, 80);
1650
+ const note = candidates.length > shown.length ? `(注意:共 ${candidates.length} 条候选,仅展示前 ${shown.length} 条,其余候选未展示,请只从展示列表中挑选,或先用筛选条件缩小候选集。)` : "";
1651
+ const system = [
1652
+ "你是资深配音选角专家。根据用户描述的需求,从候选音色列表中挑选最合适的音色。",
1653
+ "要求:",
1654
+ "1. 只从候选列表中选,voice_id 必须与候选完全一致,不得编造。如果候选只有音色名没有语义字段,请结合名字里的语言/性别/年龄线索判断(如 Male_Young_、Chinese (Mandarin)_ 前缀)。",
1655
+ "2. 综合考虑语言、性别、年龄感、音色气质与需求的匹配度,以及配音用途(旁白/角色/广告/游戏等)。",
1656
+ `3. 只输出一个 JSON 对象:{"recommendations": [{"voice_id": "候选中的精确 voice_id", "reason": "简短中文理由"}]},最多 ${Math.max(1, Math.floor(topK))} 条,按推荐优先级排序;候选的 voice_id 与 voice_name 可能相同或不同,一律用 voice_id 字段。`,
1657
+ "4. 同需求下避免推荐多个明显同质的音色;不要输出 JSON 以外的任何文字。"
1658
+ ].join("\n");
1659
+ const compact = shown.map((entry) => ({
1660
+ voice_id: entry.voice_id,
1661
+ voice_name: entry.name,
1662
+ provider: entry.provider,
1663
+ source: entry.source,
1664
+ language: entry.language ?? null,
1665
+ locale: entry.locale ?? null,
1666
+ accent: entry.accent ?? null,
1667
+ gender: entry.gender ?? null,
1668
+ age: entry.age ?? null,
1669
+ category: entry.category ?? null,
1670
+ use_case: entry.use_case ?? null,
1671
+ descriptive: entry.descriptive ?? null,
1672
+ labels: entry.labels ?? null,
1673
+ description: truncate(entry.description, MAX_DESCRIPTION_CHARS$1),
1674
+ has_preview: entry.preview_url !== void 0 && entry.preview_url !== ""
1675
+ }));
1676
+ return {
1677
+ system,
1678
+ user: `需求:${requirement}\n\n候选音色(共 ${shown.length} 条):\n${JSON.stringify(compact, null, 1)}\n${note}`
1679
+ };
1680
+ }
1681
+ function truncate(value, limit) {
1682
+ if (value === void 0) return null;
1683
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
1684
+ }
1685
+ /** 去掉模型可能包裹的 ``` 代码围栏。 */
1686
+ function stripFences(value) {
1687
+ if (value === "") return value;
1688
+ return value.replace(/^```[a-zA-Z]*\s*\n?/, "").replace(/\n?```\s*$/, "").trim();
1689
+ }
1690
+ /** 宽松 JSON 解析:整体失败时尝试提取第一个 { 到最后一个 }。 */
1691
+ function loadJsonLoose(content) {
1692
+ const text = stripFences(content);
1693
+ try {
1694
+ return JSON.parse(text);
1695
+ } catch {
1696
+ const start = text.indexOf("{");
1697
+ const end = text.lastIndexOf("}");
1698
+ if (start === -1 || end <= start) return null;
1699
+ try {
1700
+ return JSON.parse(text.slice(start, end + 1));
1701
+ } catch {
1702
+ return null;
1703
+ }
1704
+ }
1705
+ }
1706
+ //#endregion
1390
1707
  //#region src/audio-presets.ts
1391
1708
  const AUDIO_PRESETS = [
1392
1709
  {
@@ -2302,6 +2619,60 @@ async function mergeLibraryProvenance(given, files, channels) {
2302
2619
  ...given.params !== void 0 || params !== void 0 ? { params: given.params ?? params } : {}
2303
2620
  };
2304
2621
  }
2622
+ /** 从请求体解析音色列表/推荐共用的筛选选项。 */
2623
+ function voiceListOptionsOf(body) {
2624
+ const str = (key) => {
2625
+ const value = body?.[key];
2626
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
2627
+ };
2628
+ const filters = {
2629
+ ...str("search") === void 0 ? {} : { search: str("search") },
2630
+ ...str("use_case") === void 0 ? {} : { use_case: str("use_case") },
2631
+ ...str("accent") === void 0 ? {} : { accent: str("accent") },
2632
+ ...str("gender") === void 0 ? {} : { gender: str("gender") },
2633
+ ...str("age") === void 0 ? {} : { age: str("age") },
2634
+ ...str("locale") === void 0 ? {} : { locale: str("locale") },
2635
+ ...str("category") === void 0 ? {} : { category: str("category") },
2636
+ ...str("sort") === void 0 ? {} : { sort: str("sort") },
2637
+ ...body?.featured === true ? { featured: true } : {},
2638
+ ...body?.free_users_allowed === true ? { free_users_allowed: true } : {},
2639
+ ...body?.descriptive === true ? { descriptive: true } : {}
2640
+ };
2641
+ return {
2642
+ ...str("language") === void 0 ? {} : { language: str("language") },
2643
+ ...str("keyword") === void 0 ? {} : { keyword: str("keyword") },
2644
+ ...str("source") === void 0 ? {} : { source: str("source") },
2645
+ ...typeof body?.limit === "number" && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {},
2646
+ ...Object.keys(filters).length === 0 ? {} : { serverFilters: filters }
2647
+ };
2648
+ }
2649
+ /** 推荐记录的筛选条件快照(面板展示用,不含密钥)。 */
2650
+ function recommendFiltersOf(body) {
2651
+ const str = (key) => {
2652
+ const value = body?.[key];
2653
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
2654
+ };
2655
+ const out = {};
2656
+ for (const key of [
2657
+ "language",
2658
+ "keyword",
2659
+ "source",
2660
+ "search",
2661
+ "use_case",
2662
+ "accent",
2663
+ "gender",
2664
+ "age",
2665
+ "locale",
2666
+ "category",
2667
+ "sort"
2668
+ ]) {
2669
+ const value = str(key);
2670
+ if (value !== void 0) out[key] = value;
2671
+ }
2672
+ if (body?.featured === true) out.featured = true;
2673
+ if (body?.free_users_allowed === true) out.free_users_allowed = true;
2674
+ return out;
2675
+ }
2305
2676
  /** Build every /api/dsh-audiogen route. */
2306
2677
  function makeRoutes(deps) {
2307
2678
  const guard = (req, res, method) => {
@@ -2413,31 +2784,8 @@ function makeRoutes(deps) {
2413
2784
  });
2414
2785
  return;
2415
2786
  }
2416
- const str = (key) => {
2417
- const value = body?.[key];
2418
- return typeof value === "string" && value.trim() !== "" ? value.trim() : void 0;
2419
- };
2420
- const filters = {
2421
- ...str("search") === void 0 ? {} : { search: str("search") },
2422
- ...str("use_case") === void 0 ? {} : { use_case: str("use_case") },
2423
- ...str("accent") === void 0 ? {} : { accent: str("accent") },
2424
- ...str("gender") === void 0 ? {} : { gender: str("gender") },
2425
- ...str("age") === void 0 ? {} : { age: str("age") },
2426
- ...str("locale") === void 0 ? {} : { locale: str("locale") },
2427
- ...str("category") === void 0 ? {} : { category: str("category") },
2428
- ...str("sort") === void 0 ? {} : { sort: str("sort") },
2429
- ...body?.featured === true ? { featured: true } : {},
2430
- ...body?.free_users_allowed === true ? { free_users_allowed: true } : {},
2431
- ...body?.descriptive === true ? { descriptive: true } : {}
2432
- };
2433
2787
  try {
2434
- const result = await listVendorVoices(channel, {
2435
- ...str("language") === void 0 ? {} : { language: str("language") },
2436
- ...str("keyword") === void 0 ? {} : { keyword: str("keyword") },
2437
- ...str("source") === void 0 ? {} : { source: str("source") },
2438
- ...typeof body?.limit === "number" && Number.isFinite(body.limit) ? { limit: Math.floor(body.limit) } : {},
2439
- ...Object.keys(filters).length === 0 ? {} : { serverFilters: filters }
2440
- });
2788
+ const result = await listVendorVoicesWithFallback(channel, voiceListOptionsOf(body));
2441
2789
  writeJson(res, 200, {
2442
2790
  ok: true,
2443
2791
  vendor: result.vendor,
@@ -2501,6 +2849,129 @@ function makeRoutes(deps) {
2501
2849
  }
2502
2850
  }
2503
2851
  },
2852
+ {
2853
+ kind: "exact",
2854
+ path: VOICES_API.recommend,
2855
+ handler: async (req, res) => {
2856
+ if (!guard(req, res, "POST")) return;
2857
+ const body = await readJsonBody(req);
2858
+ const requirement = typeof body?.requirement === "string" ? body.requirement.trim() : "";
2859
+ if (requirement === "") {
2860
+ writeJson(res, 200, {
2861
+ ok: false,
2862
+ code: "recommend-requirement-required",
2863
+ message: "需求描述(requirement)不能为空。"
2864
+ });
2865
+ return;
2866
+ }
2867
+ const channel = channelOf(deps.resolveChannels(), body?.channel);
2868
+ if (channel === void 0) {
2869
+ writeJson(res, 200, {
2870
+ ok: false,
2871
+ code: "channel-not-configured",
2872
+ message: "没有可用的音频渠道(需要已配置 API 地址与密钥),请先在设置中添加。"
2873
+ });
2874
+ return;
2875
+ }
2876
+ const rawTopK = body?.top_k;
2877
+ const topK = typeof rawTopK === "number" && Number.isFinite(rawTopK) ? Math.max(1, Math.min(10, Math.floor(rawTopK))) : 5;
2878
+ try {
2879
+ const result = await listVendorVoicesWithFallback(channel, {
2880
+ ...voiceListOptionsOf(body),
2881
+ limit: 500
2882
+ });
2883
+ const recommendations = await deps.recommend(requirement, result.voices, topK);
2884
+ appendVoiceRecommendRecord({
2885
+ channel: channel.name,
2886
+ vendor: result.vendor,
2887
+ requirement,
2888
+ candidate_count: result.voices.length,
2889
+ top_k: topK,
2890
+ channel_id: channel.id,
2891
+ filters: recommendFiltersOf(body),
2892
+ recommendations: recommendations.map((item) => ({
2893
+ voice_id: item.voice_id,
2894
+ name: item.name,
2895
+ source: item.source,
2896
+ deletable: item.deletable,
2897
+ ...item.language === void 0 ? {} : { language: item.language },
2898
+ ...item.locale === void 0 ? {} : { locale: item.locale },
2899
+ ...item.accent === void 0 ? {} : { accent: item.accent },
2900
+ ...item.gender === void 0 ? {} : { gender: item.gender },
2901
+ ...item.age === void 0 ? {} : { age: item.age },
2902
+ ...item.use_case === void 0 ? {} : { use_case: item.use_case },
2903
+ ...item.category === void 0 ? {} : { category: item.category },
2904
+ ...item.labels === void 0 ? {} : { labels: item.labels },
2905
+ ...item.descriptive === void 0 ? {} : { descriptive: item.descriptive },
2906
+ ...item.description === void 0 ? {} : { description: item.description },
2907
+ ...item.preview_url === void 0 ? {} : { preview_url: item.preview_url },
2908
+ reason: item.reason
2909
+ }))
2910
+ }).catch(() => {});
2911
+ writeJson(res, 200, {
2912
+ ok: true,
2913
+ vendor: result.vendor,
2914
+ channel: channel.name,
2915
+ requirement,
2916
+ candidate_count: result.voices.length,
2917
+ top_k: topK,
2918
+ recommendations,
2919
+ recorded: true,
2920
+ ...result.note === void 0 ? {} : { note: result.note }
2921
+ });
2922
+ } catch (error) {
2923
+ writeJson(res, 200, {
2924
+ ok: false,
2925
+ code: "voice-recommend-failed",
2926
+ message: messageOf(error)
2927
+ });
2928
+ }
2929
+ }
2930
+ },
2931
+ {
2932
+ kind: "exact",
2933
+ path: VOICES_API.recommendHistory.list,
2934
+ handler: async (req, res) => {
2935
+ if (!guard(req, res, "POST")) return;
2936
+ const rawLimit = (await readJsonBody(req))?.limit;
2937
+ const entries = await listVoiceRecommendRecords(typeof rawLimit === "number" && Number.isFinite(rawLimit) ? Math.max(1, Math.min(50, Math.floor(rawLimit))) : 20);
2938
+ writeJson(res, 200, {
2939
+ ok: true,
2940
+ count: entries.length,
2941
+ entries
2942
+ });
2943
+ }
2944
+ },
2945
+ {
2946
+ kind: "exact",
2947
+ path: VOICES_API.recommendHistory.remove,
2948
+ handler: async (req, res) => {
2949
+ if (!guard(req, res, "POST")) return;
2950
+ const body = await readJsonBody(req);
2951
+ const id = typeof body?.id === "string" ? body.id.trim() : "";
2952
+ if (id === "") {
2953
+ writeJson(res, 200, {
2954
+ ok: false,
2955
+ code: "record-id-required",
2956
+ message: "record id 不能为空。"
2957
+ });
2958
+ return;
2959
+ }
2960
+ try {
2961
+ await removeVoiceRecommendRecord(id);
2962
+ writeJson(res, 200, {
2963
+ ok: true,
2964
+ removed: id
2965
+ });
2966
+ } catch (error) {
2967
+ writeJson(res, 200, {
2968
+ ok: false,
2969
+ code: "record-remove-failed",
2970
+ message: messageOf(error)
2971
+ });
2972
+ }
2973
+ }
2974
+ },
2504
2975
  {
2505
2976
  kind: "exact",
2506
2977
  path: SETTINGS_API.describe,
@@ -3018,6 +3489,456 @@ function makeRoutes(deps) {
3018
3489
  ];
3019
3490
  }
3020
3491
  //#endregion
3492
+ //#region src/voice-cast.ts
3493
+ /**
3494
+ * 角色音色选角(voice casting):把「角色画像 → 候选音色 → 主音色 + 备用音色」
3495
+ * 流水线落到插件里,等价于 standalone 音频工作室里 MiniMax/ElevenLabs 的选角逻辑。
3496
+ *
3497
+ * 分工(重要):
3498
+ * - 本模块只做确定性的事:角色画像归一化、硬过滤(gender/age/use_case 严格,
3499
+ * accent 可放松)、投票校验(voice_id 必须属于该角色候选池、备份补齐、
3500
+ * lead/major 主音色不重复)、选定记录持久化。
3501
+ * - 「选谁」的推理/全局权衡由 Agent(DeepSeek Harness 当前模型)完成:
3502
+ * action=cast 拿到每个角色的候选列表后,Agent 在上下文中统一选角,
3503
+ * 再把结果交给 action=save_cast 校验落盘。这样既防幻觉(工具校验),
3504
+ * 又保留整组阵容的全局视野(Agent 推理)。
3505
+ *
3506
+ * 与 audio_studio_standalone 的对应关系:
3507
+ * - 输入结构 = tts/tools/build_character_adapter_input.py 的角色画像(主字段一致)
3508
+ * - 硬过滤 = elevenlabs/voice-selection/tools/elevenlabs_voice_selection.py
3509
+ * _map_gender / _map_age_filter / _filter_candidates_with_fallback
3510
+ * - 校验 = _validate_voice_selection_plan(成员校验 + 备份补齐 + 复用检查)
3511
+ */
3512
+ const MAX_CANDIDATES_PER_CHARACTER = 60;
3513
+ const MAX_SAMPLE_LINES = 3;
3514
+ const MAX_DESCRIPTION_CHARS = 400;
3515
+ const BACKUP_LIMIT = 2;
3516
+ const LEAD_DIALOGUE_COUNT = 200;
3517
+ const MAJOR_DIALOGUE_COUNT = 50;
3518
+ /** 性状拆分:与 standalone 的 TRAIT_SPLIT_RE 一致。 */
3519
+ const TRAIT_SPLIT_RE = /[;;、,,/|]+/;
3520
+ function isRecord(value) {
3521
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3522
+ }
3523
+ function asString(value) {
3524
+ if (typeof value === "string") return value.trim();
3525
+ if (typeof value === "number") return String(value);
3526
+ return "";
3527
+ }
3528
+ function asStringList(value) {
3529
+ if (Array.isArray(value)) return value.map((item) => asString(item)).filter((item) => item !== "");
3530
+ const text = asString(value);
3531
+ return text === "" ? [] : [text];
3532
+ }
3533
+ function splitTraits(value) {
3534
+ const seen = /* @__PURE__ */ new Set();
3535
+ const out = [];
3536
+ for (const raw of asStringList(value)) for (const part of raw.split(TRAIT_SPLIT_RE)) {
3537
+ const item = part.trim();
3538
+ if (item !== "" && !seen.has(item)) {
3539
+ seen.add(item);
3540
+ out.push(item);
3541
+ }
3542
+ }
3543
+ return out;
3544
+ }
3545
+ function asNumber(value) {
3546
+ const text = asString(value);
3547
+ if (text === "") return void 0;
3548
+ const parsed = Number(text);
3549
+ return Number.isFinite(parsed) ? parsed : void 0;
3550
+ }
3551
+ /** 从角色名生成稳定的 character_id(缺省时自动补,与上游 char_u<hex> 风格一致)。 */
3552
+ function slugifyCharacterId(characterName, fallback) {
3553
+ let slug = "";
3554
+ for (const char of characterName.toLowerCase()) if (/[a-z0-9]/.test(char)) slug += char;
3555
+ else if (char === "-" || char === "_") slug += "_";
3556
+ else if (char.trim() !== "") slug += `${slug.endsWith("_") || slug === "" ? "" : "_"}u${char.codePointAt(0).toString(16)}`;
3557
+ slug = slug.split("_").filter((part) => part !== "").join("_") || fallback;
3558
+ return `char_${slug}`;
3559
+ }
3560
+ function importanceTier(dialogueCount, explicit) {
3561
+ if (explicit === "lead" || explicit === "major" || explicit === "supporting") return explicit;
3562
+ if (dialogueCount >= LEAD_DIALOGUE_COUNT) return "lead";
3563
+ if (dialogueCount >= MAJOR_DIALOGUE_COUNT) return "major";
3564
+ return "supporting";
3565
+ }
3566
+ /** 接受 JSON 数组 / 单个对象 / {characters: [...]} / JSON 字符串 → 角色剖面。 */
3567
+ function parseCharacterProfiles(input) {
3568
+ let value = input;
3569
+ if (typeof value === "string") {
3570
+ const text = value.trim();
3571
+ if (text === "") throw new AudioGenError("characters 不能为空:请传入角色画像 JSON(数组或单个对象)或 JSON 字符串", "cast-characters-empty");
3572
+ try {
3573
+ value = JSON.parse(text);
3574
+ } catch {
3575
+ throw new AudioGenError("characters 无法解析为 JSON:请先把角色信息整理成类似 [{\"character_id\": \"char_xxx\", \"character_name\": \"名字\", \"gender\": \"女性\", \"age_stage\": \"少女\", \"voice_traits\": [\"清亮\"], \"personality_traits\": [\"活泼\"], \"appearance\": [\"…\"], \"dialogue_count\": 12}] 的结构再调用", "cast-characters-parse-failed");
3576
+ }
3577
+ }
3578
+ let list;
3579
+ if (Array.isArray(value)) list = value;
3580
+ else if (isRecord(value)) {
3581
+ const inner = value.characters ?? value.classified_characters;
3582
+ if (Array.isArray(inner)) list = inner;
3583
+ else list = [value];
3584
+ } else throw new AudioGenError("characters 必须是 JSON 数组、单个对象或 JSON 字符串", "cast-characters-invalid");
3585
+ const profiles = [];
3586
+ const missingNames = [];
3587
+ list.forEach((item, index) => {
3588
+ if (!isRecord(item)) {
3589
+ missingNames.push(`characters[${index}] 不是对象`);
3590
+ return;
3591
+ }
3592
+ const characterName = asString(item.character_name) || asString(item.name);
3593
+ if (characterName === "") {
3594
+ missingNames.push(`characters[${index}] 缺少 character_name`);
3595
+ return;
3596
+ }
3597
+ const characterId = asString(item.character_id) || slugifyCharacterId(characterName, `r${index}`);
3598
+ const sampleLines = (Array.isArray(item.sample_lines) ? item.sample_lines : typeof item.sample_lines === "string" ? item.sample_lines.split(/\n+/).map((line) => ({
3599
+ text: line.trim(),
3600
+ emotion_hint: ""
3601
+ })) : []).filter((entry) => typeof entry === "string" || isRecord(entry)).filter((entry) => {
3602
+ return (typeof entry === "string" ? entry.trim() : asString(entry.text) || asString(entry.dialogue)) !== "";
3603
+ }).slice(0, MAX_SAMPLE_LINES).map((entry) => {
3604
+ const text = typeof entry === "string" ? entry.trim() : asString(entry.text) || asString(entry.dialogue);
3605
+ const emotion = typeof entry === "string" ? "" : asString(entry.emotion_hint) || asString(entry.emotion);
3606
+ return {
3607
+ text,
3608
+ ...emotion === "" ? {} : { emotion_hint: emotion }
3609
+ };
3610
+ });
3611
+ profiles.push({
3612
+ character_id: characterId,
3613
+ character_name: characterName,
3614
+ gender: asString(item.gender) || void 0,
3615
+ age_stage: asStringList(item.age_stage ?? item.age),
3616
+ ...asString(item.age_stage_source) === "" ? {} : { age_stage_source: asString(item.age_stage_source) },
3617
+ voice_traits: splitTraits(item.voice_traits),
3618
+ personality_traits: splitTraits(item.personality_traits),
3619
+ appearance: asStringList(item.appearance),
3620
+ sample_lines: sampleLines,
3621
+ dialogue_count: asNumber(item.dialogue_count),
3622
+ importance_tier: asString(item.importance_tier) || asString(item.tier) || void 0,
3623
+ ...asString(item.language) === "" ? {} : { language: asString(item.language) },
3624
+ ...asString(item.use_case) === "" ? {} : { use_case: asString(item.use_case) }
3625
+ });
3626
+ });
3627
+ if (missingNames.length > 0) throw new AudioGenError(`characters 数据不完整:${missingNames.slice(0, 5).join(";")}(请为每个角色提供 character_name)`, "cast-character-missing-name");
3628
+ if (profiles.length === 0) throw new AudioGenError("characters 为空:请传入至少一个角色画像", "cast-characters-empty");
3629
+ return profiles;
3630
+ }
3631
+ /** 性别归一:女/female → female;男/male → male;其余 undefined(不过滤)。 */
3632
+ function mapGender(text) {
3633
+ const value = (text ?? "").toLowerCase();
3634
+ if (value === "") return void 0;
3635
+ if (value.includes("女") || value.includes("female")) return "female";
3636
+ if (value.includes("男") || value.includes("male")) return "male";
3637
+ }
3638
+ /** 年龄段归一:老/老年→old;中年→middle_aged;少年/少女/青年/成年→young。 */
3639
+ function mapAgeFilter(ageStages) {
3640
+ const text = (ageStages ?? []).join(" ");
3641
+ if (text.trim() === "") return [];
3642
+ if (text.includes("老") || text.includes("老年")) return ["old"];
3643
+ if (text.includes("中年") || text.includes("middle")) return ["middle_aged"];
3644
+ if (text.includes("少年") || text.includes("少女") || text.includes("少男") || text.includes("儿童") || text.includes("幼年") || text.includes("teen") || text.includes("child") || text.includes("young")) return ["young"];
3645
+ if (text.includes("青年") || text.includes("年轻") || text.includes("成年") || text.includes("youth") || text.includes("adult")) return ["young"];
3646
+ return [];
3647
+ }
3648
+ /** 一个角色对候选池的硬过滤(gender/age/use_case 严格,accent 可放松)。 */
3649
+ function filterCandidatesWithFallback(pool, options) {
3650
+ const gender = options.gender;
3651
+ const ages = options.ages ?? [];
3652
+ const accent = options.accent;
3653
+ const useCase = options.use_case;
3654
+ const language = options.language;
3655
+ const matches = (voice, checkAccent) => {
3656
+ if (gender !== void 0 && (voice.gender ?? "").toLowerCase() !== gender.toLowerCase()) return false;
3657
+ if (ages.length > 0 && !ages.includes((voice.age ?? "").toLowerCase())) return false;
3658
+ if (language !== void 0 && !languageMatchesEntry(voice, language)) return false;
3659
+ if (useCase !== void 0 && useCase !== "" && (voice.use_case ?? "").toLowerCase() !== useCase.toLowerCase()) return false;
3660
+ if (checkAccent && accent !== void 0 && accent !== "" && (voice.accent ?? "").toLowerCase() !== accent.toLowerCase()) return false;
3661
+ return true;
3662
+ };
3663
+ const strict = pool.filter((voice) => matches(voice, true));
3664
+ const applied = [];
3665
+ if (gender !== void 0) applied.push(`gender=${gender}`);
3666
+ if (ages.length > 0) applied.push(`age=${ages.join("/")}`);
3667
+ if (useCase !== void 0 && useCase !== "") applied.push(`use_case=${useCase}`);
3668
+ if (accent !== void 0 && accent !== "") applied.push("accent");
3669
+ if (language !== void 0 && language !== "") applied.push(`language=${language}`);
3670
+ const appliedText = applied.join("+") === "" ? "none" : applied.join("+");
3671
+ if (strict.length > 0) return {
3672
+ candidates: strict,
3673
+ relaxedAccent: false,
3674
+ notes: `candidate_filter=${appliedText}`
3675
+ };
3676
+ const relaxed = pool.filter((voice) => matches(voice, false));
3677
+ if (relaxed.length > 0) return {
3678
+ candidates: relaxed,
3679
+ relaxedAccent: true,
3680
+ notes: `candidate_filter=${appliedText};accent 已放松`
3681
+ };
3682
+ return {
3683
+ candidates: [],
3684
+ relaxedAccent: false,
3685
+ notes: "candidate_filter=empty;严格过滤后无候选(可放宽 use_case 或换渠道)"
3686
+ };
3687
+ }
3688
+ function languageMatchesEntry(voice, needle) {
3689
+ const haystack = [
3690
+ voice.language ?? "",
3691
+ voice.locale ?? "",
3692
+ voice.name ?? ""
3693
+ ].join(" ").toLowerCase();
3694
+ const value = needle.trim().toLowerCase();
3695
+ if (value === "") return true;
3696
+ if (haystack.includes(value)) return true;
3697
+ for (const alias of {
3698
+ zh: [
3699
+ "zh",
3700
+ "chinese",
3701
+ "mandarin",
3702
+ "cantonese"
3703
+ ],
3704
+ en: ["en", "english"],
3705
+ ja: ["ja", "japanese"],
3706
+ ko: ["ko", "korean"],
3707
+ es: ["es", "spanish"],
3708
+ fr: ["fr", "french"],
3709
+ de: ["de", "german"],
3710
+ ru: ["ru", "russian"],
3711
+ it: ["it", "italian"],
3712
+ pt: ["pt", "portuguese"],
3713
+ ar: ["ar", "arabic"],
3714
+ hi: ["hi", "hindi"]
3715
+ }[value] ?? []) if (haystack.includes(alias)) return true;
3716
+ return false;
3717
+ }
3718
+ function toSlimVoice(voice) {
3719
+ const trimmed = voice.description !== void 0 && voice.description.length > MAX_DESCRIPTION_CHARS ? `${voice.description.slice(0, MAX_DESCRIPTION_CHARS - 1)}…` : voice.description;
3720
+ return {
3721
+ voice_id: voice.voice_id,
3722
+ name: voice.name,
3723
+ source: voice.source,
3724
+ deletable: voice.deletable,
3725
+ ...voice.language === void 0 ? {} : { language: voice.language },
3726
+ ...voice.locale === void 0 ? {} : { locale: voice.locale },
3727
+ ...voice.accent === void 0 ? {} : { accent: voice.accent },
3728
+ ...voice.gender === void 0 ? {} : { gender: voice.gender },
3729
+ ...voice.age === void 0 ? {} : { age: voice.age },
3730
+ ...voice.use_case === void 0 ? {} : { use_case: voice.use_case },
3731
+ ...voice.category === void 0 ? {} : { category: voice.category },
3732
+ ...voice.labels === void 0 ? {} : { labels: voice.labels },
3733
+ ...voice.descriptive === void 0 ? {} : { descriptive: voice.descriptive },
3734
+ ...trimmed === void 0 ? {} : { description: trimmed },
3735
+ ...voice.preview_url === void 0 ? {} : { preview_url: voice.preview_url }
3736
+ };
3737
+ }
3738
+ async function fetchPool(channel, options) {
3739
+ if (channel.apiUrl.trim() === "") throw new AudioGenError("渠道未配置 API 地址", "audio-api-not-configured");
3740
+ if (channel.apiKey.trim() === "") throw new AudioGenError("渠道未配置 API 密钥", "audio-api-not-configured");
3741
+ const useCase = options.use_case !== void 0 && options.use_case !== "" ? options.use_case : void 0;
3742
+ const result = await listVendorVoicesWithFallback(channel, {
3743
+ limit: 500,
3744
+ ...options.language !== void 0 && options.language !== "" ? { language: options.language } : {},
3745
+ ...useCase === void 0 ? {} : { serverFilters: { use_case: useCase } }
3746
+ });
3747
+ return {
3748
+ vendor: result.vendor,
3749
+ pool: result.voices,
3750
+ ...result.note === void 0 ? {} : { note: result.note }
3751
+ };
3752
+ }
3753
+ /** action=cast:角色画像 → 每个角色的硬过滤候选列表(不选角色,不调 LLM)。 */
3754
+ async function prepareVoiceCast(channel, profiles, options = {}) {
3755
+ const { vendor, pool, note } = await fetchPool(channel, options);
3756
+ const fallbackPool = pool.some((entry) => entry.source === "configured");
3757
+ const hasMetadata = vendor !== "minimax" && !fallbackPool;
3758
+ const views = [];
3759
+ for (const profile of profiles) {
3760
+ const gender = hasMetadata ? mapGender(profile.gender) : void 0;
3761
+ const ages = hasMetadata ? mapAgeFilter(profile.age_stage) : [];
3762
+ const language = hasMetadata ? profile.language ?? options.language : void 0;
3763
+ const useCase = hasMetadata ? profile.use_case ?? options.use_case : void 0;
3764
+ const accent = hasMetadata ? options.accent : void 0;
3765
+ const dialogueCount = profile.dialogue_count ?? 0;
3766
+ const tier = importanceTier(dialogueCount, profile.importance_tier);
3767
+ const filtered = filterCandidatesWithFallback(pool, {
3768
+ ...gender === void 0 ? {} : { gender },
3769
+ ...ages.length > 0 ? { ages } : {},
3770
+ ...accent === void 0 || accent === "" ? {} : { accent },
3771
+ ...useCase === void 0 || useCase === "" ? {} : { use_case: useCase },
3772
+ ...language === void 0 || language === "" ? {} : { language }
3773
+ });
3774
+ const candidates = filtered.candidates.slice(0, MAX_CANDIDATES_PER_CHARACTER);
3775
+ const mapped = {
3776
+ ...gender === void 0 ? {} : { gender },
3777
+ ...ages.length > 0 ? { age: ages } : {},
3778
+ ...accent === void 0 || accent === "" ? {} : { accent },
3779
+ ...useCase === void 0 || useCase === "" ? {} : { use_case: useCase },
3780
+ ...language === void 0 || language === "" ? {} : { language },
3781
+ notes: `age_stage=${(profile.age_stage ?? []).join("/") || "unknown"}; ${filtered.notes}${vendor === "minimax" ? ";MiniMax 音色无性别/年龄/用途元数据,实际仅按语言与名称/描述筛选" : ""}`
3782
+ };
3783
+ const slim = {
3784
+ character_id: profile.character_id,
3785
+ character_name: profile.character_name,
3786
+ dialogue_count: dialogueCount,
3787
+ importance_tier: tier
3788
+ };
3789
+ if (profile.gender !== void 0) slim.gender = profile.gender;
3790
+ if ((profile.age_stage ?? []).length > 0) slim.age_stage = profile.age_stage;
3791
+ if (profile.age_stage_source !== void 0) slim.age_stage_source = profile.age_stage_source;
3792
+ if ((profile.voice_traits ?? []).length > 0) slim.voice_traits = profile.voice_traits;
3793
+ if ((profile.personality_traits ?? []).length > 0) slim.personality_traits = profile.personality_traits;
3794
+ if ((profile.appearance ?? []).length > 0) slim.appearance = profile.appearance;
3795
+ if ((profile.sample_lines ?? []).length > 0) slim.sample_lines = profile.sample_lines;
3796
+ if (profile.language !== void 0) slim.language = profile.language;
3797
+ if (profile.use_case !== void 0) slim.use_case = profile.use_case;
3798
+ views.push({
3799
+ character: slim,
3800
+ mapped_filters: mapped,
3801
+ candidate_count: candidates.length,
3802
+ candidate_voices: candidates.map(toSlimVoice),
3803
+ ...filtered.candidates.length > candidates.length ? { note: `candidates truncated: ${filtered.candidates.length} → ${candidates.length}` } : {}
3804
+ });
3805
+ }
3806
+ return {
3807
+ vendor,
3808
+ channel: channel.name,
3809
+ pool_size: pool.length,
3810
+ use_case_filter: options.use_case ?? "",
3811
+ accent_preference: options.accent ?? "",
3812
+ character_count: profiles.length,
3813
+ characters: views,
3814
+ ...note === void 0 ? {} : { note }
3815
+ };
3816
+ }
3817
+ /** action=save_cast:Agent 选角结果 → 校验 + 补齐 + 落盘(选定记录)。 */
3818
+ async function saveVoiceCast(channel, profiles, selectionInputs, options = {}) {
3819
+ const prepared = await prepareVoiceCast(channel, profiles, options);
3820
+ new Map(prepared.characters.map((view) => [String(view.character.character_id), view]));
3821
+ const inputByCharacter = /* @__PURE__ */ new Map();
3822
+ for (const input of selectionInputs) {
3823
+ if (input === null || typeof input !== "object") continue;
3824
+ const characterId = asString(input.character_id);
3825
+ if (characterId !== "") inputByCharacter.set(characterId, input);
3826
+ }
3827
+ const entries = [];
3828
+ const issues = [];
3829
+ const sawReuse = /* @__PURE__ */ new Map();
3830
+ for (const view of prepared.characters) {
3831
+ const characterId = String(view.character.character_id ?? "");
3832
+ const characterName = String(view.character.character_name ?? characterId);
3833
+ const candidates = view.candidate_voices;
3834
+ const input = inputByCharacter.get(characterId);
3835
+ const dialogueCount = asNumber(view.character.dialogue_count) ?? 0;
3836
+ const tier = String(view.character.importance_tier ?? importanceTier(dialogueCount));
3837
+ let primary = asString(input?.voice_id ?? "");
3838
+ let status = "ok";
3839
+ const recordIssues = [];
3840
+ if (primary === "" || !candidates.some((voice) => voice.voice_id === primary)) {
3841
+ const fallback = candidates[0]?.voice_id ?? "";
3842
+ if (fallback !== "") {
3843
+ recordIssues.push("voice_id_not_in_candidates; fallback=first_candidate");
3844
+ primary = fallback;
3845
+ status = "tool_fallback";
3846
+ }
3847
+ }
3848
+ const requiredBackupCount = Math.min(BACKUP_LIMIT, Math.max(candidates.length - 1, 0));
3849
+ const backups = [];
3850
+ const rawBackups = Array.isArray(input?.backup_voice_ids) ? input.backup_voice_ids : [];
3851
+ for (const raw of rawBackups) {
3852
+ const id = asString(raw);
3853
+ if (id === "" || id === primary || backups.includes(id)) continue;
3854
+ if (!candidates.some((voice) => voice.voice_id === id)) continue;
3855
+ backups.push(id);
3856
+ if (backups.length >= requiredBackupCount) break;
3857
+ }
3858
+ if (backups.length < requiredBackupCount) {
3859
+ for (const voice of candidates) {
3860
+ if (backups.length >= requiredBackupCount) break;
3861
+ if (voice.voice_id === primary || backups.includes(voice.voice_id)) continue;
3862
+ backups.push(voice.voice_id);
3863
+ }
3864
+ if (status === "ok") status = "fixed";
3865
+ recordIssues.push("backup_voice_ids_auto_filled");
3866
+ }
3867
+ const primaryVoice = candidates.find((voice) => voice.voice_id === primary);
3868
+ const reason = asString(input?.reason ?? "");
3869
+ const record = {
3870
+ character_id: characterId,
3871
+ character_name: asString(input?.character_name) || characterName,
3872
+ voice_id: primary,
3873
+ voice_name: primaryVoice?.name ?? "",
3874
+ backup_voice_ids: backups,
3875
+ reason: reason === "" ? status === "tool_fallback" ? "tool fallback: selected from filtered candidates" : "" : reason,
3876
+ dialogue_count: dialogueCount,
3877
+ importance_tier: tier,
3878
+ selection_status: status,
3879
+ issues: recordIssues,
3880
+ selected_at: (/* @__PURE__ */ new Date()).toISOString()
3881
+ };
3882
+ entries.push(record);
3883
+ sawReuse.set(primary, [...sawReuse.get(primary) ?? [], {
3884
+ character_id: characterId,
3885
+ character_name: characterName,
3886
+ tier
3887
+ }]);
3888
+ }
3889
+ for (const [voiceId, users] of sawReuse) {
3890
+ const important = users.filter((user) => user.tier === "lead" || user.tier === "major");
3891
+ if (important.length > 1) for (const user of important) issues.push({
3892
+ character_id: user.character_id,
3893
+ character_name: user.character_name,
3894
+ issue: "primary_voice_reused",
3895
+ detail: `主音色 ${voiceId} 被 ${important.map((item) => item.character_name).join("、")} 复用;lead/major 角色应尽量使用不同主音色`
3896
+ });
3897
+ }
3898
+ const storePath = await writeCastSelections(channel, entries);
3899
+ return {
3900
+ vendor: prepared.vendor,
3901
+ channel: channel.name,
3902
+ store_path: storePath,
3903
+ count: entries.length,
3904
+ entries,
3905
+ issues
3906
+ };
3907
+ }
3908
+ function castSelectionsPath() {
3909
+ return path.join(process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh"), "dsh-audiogen", "cast-selections.json");
3910
+ }
3911
+ async function loadCastSelections() {
3912
+ try {
3913
+ const text = await readFile(castSelectionsPath(), "utf-8");
3914
+ const payload = JSON.parse(text);
3915
+ if (typeof payload === "object" && payload !== null && typeof payload.channels === "object") return payload;
3916
+ } catch {}
3917
+ return {
3918
+ version: 1,
3919
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3920
+ channels: {}
3921
+ };
3922
+ }
3923
+ /** 按渠道合并选定记录(同 character_id 覆盖),写回本地 JSON。 */
3924
+ async function writeCastSelections(channel, records) {
3925
+ const store = await loadCastSelections();
3926
+ const bucket = store.channels[channel.id] ?? {
3927
+ provider: channel.preset || "custom",
3928
+ name: channel.name,
3929
+ updatedAt: "",
3930
+ entries: {}
3931
+ };
3932
+ for (const record of records) bucket.entries[record.character_id] = record;
3933
+ bucket.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3934
+ store.channels[channel.id] = bucket;
3935
+ store.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3936
+ const file = castSelectionsPath();
3937
+ await mkdir(path.dirname(file), { recursive: true });
3938
+ await writeFile(file, JSON.stringify(store, null, 2) + "\n", "utf-8");
3939
+ return file;
3940
+ }
3941
+ //#endregion
3021
3942
  //#region src/agent-audio-tools.ts
3022
3943
  const audioRefSchema = {
3023
3944
  type: "object",
@@ -3708,28 +4629,79 @@ function registerAgentAudioTools(ctx, resolve) {
3708
4629
  };
3709
4630
  }
3710
4631
  }));
3711
- /** 厂商音色管理:浏览/筛选 + 删除(仅自建)。删除不可逆,必须 confirm=true。 */
4632
+ /** 单条音色条目 schema(voices / candidate_voices 共用)。 */
4633
+ const voiceItemSchema = {
4634
+ type: "object",
4635
+ additionalProperties: false,
4636
+ properties: {
4637
+ voice_id: {
4638
+ type: "string",
4639
+ required: true
4640
+ },
4641
+ name: {
4642
+ type: "string",
4643
+ required: true
4644
+ },
4645
+ source: {
4646
+ type: "string",
4647
+ required: true
4648
+ },
4649
+ deletable: {
4650
+ type: "boolean",
4651
+ required: true
4652
+ },
4653
+ language: { type: "string" },
4654
+ locale: { type: "string" },
4655
+ accent: { type: "string" },
4656
+ gender: { type: "string" },
4657
+ age: { type: "string" },
4658
+ use_case: { type: "string" },
4659
+ category: { type: "string" },
4660
+ descriptive: { type: "string" },
4661
+ labels: {
4662
+ type: "object",
4663
+ additionalProperties: true
4664
+ },
4665
+ description: { type: "string" },
4666
+ preview_url: { type: "string" }
4667
+ }
4668
+ };
4669
+ /** 厂商音色管理:浏览/筛选 + 按需求描述推荐 + 删除(仅自建)+ 角色选角(cast)。删除不可逆,必须 confirm=true。 */
3712
4670
  const managementDisposer = ctx.tools.register(defineTool({
3713
4671
  name: "manage_audio_voices",
3714
- description: "Manage vendor voice libraries (MiniMax / ElevenLabs). action=list: browse available TTS voices of a channel — official/shared voices plus voices designed/cloned by the account; filtering supports the official /v1/shared-voices server-side filters (search/use_case/accent/gender/age/locale/category/sort/featured/free_users_allowed/descriptive) for the ElevenLabs shared library, plus local language/keyword/source filtering everywhere; returns voice_id/name/source/description/preview_url and whether each voice is deletable. action=delete: delete one OWNED voice (custom/owned only; official/shared/system voices are read-only and refused) — irreversible, so confirm must be true (pass the exact voice_id from action=list). Use the returned voice_id with generate_audio (mode=tts, voice=<voice_id>) to speak with the selected voice.",
4672
+ description: "Manage vendor voice libraries (MiniMax / ElevenLabs). action=list: browse available TTS voices of a channel — official/shared voices plus voices designed/cloned by the account; filtering supports the official /v1/shared-voices server-side filters (search/use_case/accent/gender/age/locale/category/sort/featured/free_users_allowed/descriptive) for the ElevenLabs shared library, plus local language/keyword/source filtering everywhere; returns voice_id/name/source/description/preview_url and whether each voice is deletable. Channels whose gateway lacks the vendor voice-library endpoints (e.g. new-api relays) automatically fall back to the configured model-alias catalog as the candidate pool (source=configured, name only). action=recommend: let the agent default model pick the top-k voices for a natural-language requirement (e.g. \"17岁清亮甜美的少女音,适合活泼女主角,英式口音\") from the same candidate pool — pass requirement (required) and optional top_k (1-10, default 5); returns ranked voices with a short reason each; voice_ids are validated against the pool (hallucinated ids are dropped). action=cast: 角色音色选角第一步 — pass characters (角色画像 JSON 数组/对象/JSON 字符串:character_id, character_name, gender 男/女, age_stage 少年/青年/中年/老年, voice_traits, personality_traits, appearance, sample_lines, dialogue_count, language, use_case) plus optional language/use_case/accent; the tool applies deterministic hard filters per character (gender/age/use_case strict; accent is a preference and is relaxed only when the strict pool is empty) and returns each character’s mapped filters + filtered candidate_voices (primary + backup slots). Then select voices globally in-context (lead/major 角色主音色不要复用), and call action=save_cast with the same characters plus selections [{character_id, voice_id, character_name?, backup_voice_ids?, reason?}] to validate membership, auto-fill backups, flag primary reuse and persist the cast plan to ~/.dsh/dsh-audiogen/cast-selections.json. action=delete: delete one OWNED voice (custom/owned only; official/shared/system voices are read-only and refused) — irreversible, so confirm must be true (pass the exact voice_id from action=list). Use the returned voice_id with generate_audio (mode=tts, voice=<voice_id>) to speak with the selected voice.",
3715
4673
  parameters: {
3716
4674
  action: {
3717
4675
  type: "string",
3718
- enum: ["list", "delete"],
4676
+ enum: [
4677
+ "list",
4678
+ "recommend",
4679
+ "delete",
4680
+ "cast",
4681
+ "save_cast"
4682
+ ],
3719
4683
  required: true,
3720
- description: "list = browse/filter voices; delete = remove an owned voice."
4684
+ description: "list = browse/filter voices; recommend = pick voices for a requirement with the agent default model; cast = prepare per-character filtered candidate pools (deterministic hard filters, no LLM); save_cast = validate + persist a cast plan made in-context; delete = remove an owned voice."
3721
4685
  },
3722
4686
  channel: {
3723
4687
  type: "string",
3724
4688
  description: "Channel name or id (e.g. the channel shown in settings). Defaults to the default channel; required when more than one channel is configured."
3725
4689
  },
4690
+ characters: {
4691
+ type: "json",
4692
+ description: "Required for cast/save_cast: 角色画像 — JSON array of profile objects, a single profile object, or a JSON string. Each profile: character_id (optional, auto-derived from character_name), character_name (required), gender (男/女 or male/female), age_stage (少年/青年/中年/老年…, string or array), age_stage_source (explicit/appearance_inferred/unknown), voice_traits, personality_traits, appearance (string or string[]), sample_lines ([{text, emotion_hint?}] or text), dialogue_count (number), language (en/zh/ja…), use_case. Convert free-text character descriptions into this structure before calling."
4693
+ },
4694
+ selections: {
4695
+ type: "json",
4696
+ description: "Required for save_cast: the cast plan made by you — JSON array of {character_id, voice_id, character_name?, backup_voice_ids?, reason?}. voice_id must come from the cast action’s candidate_voices; the tool validates membership, fills missing backups, flags lead/major primary reuse and persists to ~/.dsh/dsh-audiogen/cast-selections.json."
4697
+ },
3726
4698
  language: {
3727
4699
  type: "string",
3728
- description: "Filter for list: language substring (ISO code like en/zh/ja, or a label like Chinese (Mandarin))."
4700
+ description: "Filter for list/recommend: language substring (ISO code like en/zh/ja, or a label like Chinese (Mandarin)). For cast: pool language for the candidates (per-character language overrides it)."
3729
4701
  },
3730
4702
  keyword: {
3731
4703
  type: "string",
3732
- description: "Filter for list: free text over voice name/description/accent/use_case."
4704
+ description: "Filter for list/recommend: free text over voice name/description/accent/use_case."
3733
4705
  },
3734
4706
  source: {
3735
4707
  type: "string",
@@ -3739,7 +4711,7 @@ function registerAgentAudioTools(ctx, resolve) {
3739
4711
  "owned",
3740
4712
  "shared"
3741
4713
  ],
3742
- description: "Filter for list: MiniMax system/custom; ElevenLabs owned (account) / shared (community)."
4714
+ description: "Filter for list/recommend: MiniMax system/custom; ElevenLabs owned (account) / shared (community)."
3743
4715
  },
3744
4716
  search: {
3745
4717
  type: "string",
@@ -3747,11 +4719,11 @@ function registerAgentAudioTools(ctx, resolve) {
3747
4719
  },
3748
4720
  use_case: {
3749
4721
  type: "string",
3750
- description: "Official filter: use case, e.g. characters_animation / conversational / narration / gaming (ElevenLabs shared library)."
4722
+ description: "Official filter: use case, e.g. characters_animation / conversational / narration / gaming (ElevenLabs shared library). For cast: hard filter per character (default characters_animation for ElevenLabs; pass \"\" to disable)."
3751
4723
  },
3752
4724
  accent: {
3753
4725
  type: "string",
3754
- description: "Official filter: accent, e.g. british / american / australian."
4726
+ description: "Official filter: accent, e.g. british / american / australian. For cast: accent is a preference (strict first, relaxed when the strict pool is empty)."
3755
4727
  },
3756
4728
  gender: {
3757
4729
  type: "string",
@@ -3791,6 +4763,14 @@ function registerAgentAudioTools(ctx, resolve) {
3791
4763
  type: "boolean",
3792
4764
  description: "Official filter: voices with descriptions only (true only)."
3793
4765
  },
4766
+ requirement: {
4767
+ type: "string",
4768
+ description: "Required for recommend: the natural-language voice requirement, e.g. \"低沉磁性的中年男声,适合沉稳旁白\"."
4769
+ },
4770
+ top_k: {
4771
+ type: "integer",
4772
+ description: "Optional for recommend: how many voices to return (1-10, default 5)."
4773
+ },
3794
4774
  voice_id: {
3795
4775
  type: "string",
3796
4776
  description: "Required for delete: the exact voice_id from action=list."
@@ -3813,7 +4793,13 @@ function registerAgentAudioTools(ctx, resolve) {
3813
4793
  kind: {
3814
4794
  type: "string",
3815
4795
  required: true,
3816
- enum: ["list", "delete"]
4796
+ enum: [
4797
+ "list",
4798
+ "recommend",
4799
+ "delete",
4800
+ "cast",
4801
+ "save_cast"
4802
+ ]
3817
4803
  },
3818
4804
  vendor: {
3819
4805
  type: "string",
@@ -3825,7 +4811,13 @@ function registerAgentAudioTools(ctx, resolve) {
3825
4811
  },
3826
4812
  count: { type: "integer" },
3827
4813
  truncated: { type: "boolean" },
4814
+ requirement: { type: "string" },
4815
+ candidate_count: { type: "integer" },
3828
4816
  voices: {
4817
+ type: "array",
4818
+ items: voiceItemSchema
4819
+ },
4820
+ recommendations: {
3829
4821
  type: "array",
3830
4822
  items: {
3831
4823
  type: "object",
@@ -3848,15 +4840,199 @@ function registerAgentAudioTools(ctx, resolve) {
3848
4840
  required: true
3849
4841
  },
3850
4842
  language: { type: "string" },
4843
+ locale: { type: "string" },
3851
4844
  accent: { type: "string" },
3852
4845
  gender: { type: "string" },
3853
4846
  age: { type: "string" },
3854
4847
  use_case: { type: "string" },
4848
+ category: { type: "string" },
4849
+ descriptive: { type: "string" },
4850
+ labels: {
4851
+ type: "object",
4852
+ additionalProperties: true
4853
+ },
3855
4854
  description: { type: "string" },
3856
- preview_url: { type: "string" }
4855
+ preview_url: { type: "string" },
4856
+ reason: {
4857
+ type: "string",
4858
+ required: true
4859
+ }
4860
+ }
4861
+ }
4862
+ },
4863
+ pool_size: { type: "integer" },
4864
+ use_case_filter: { type: "string" },
4865
+ accent_preference: { type: "string" },
4866
+ character_count: { type: "integer" },
4867
+ characters: {
4868
+ type: "array",
4869
+ items: {
4870
+ type: "object",
4871
+ additionalProperties: false,
4872
+ properties: {
4873
+ character: {
4874
+ type: "object",
4875
+ additionalProperties: false,
4876
+ properties: {
4877
+ character_id: {
4878
+ type: "string",
4879
+ required: true
4880
+ },
4881
+ character_name: {
4882
+ type: "string",
4883
+ required: true
4884
+ },
4885
+ gender: { type: "string" },
4886
+ age_stage: {
4887
+ type: "array",
4888
+ items: { type: "string" }
4889
+ },
4890
+ age_stage_source: { type: "string" },
4891
+ voice_traits: {
4892
+ type: "array",
4893
+ items: { type: "string" }
4894
+ },
4895
+ personality_traits: {
4896
+ type: "array",
4897
+ items: { type: "string" }
4898
+ },
4899
+ appearance: {
4900
+ type: "array",
4901
+ items: { type: "string" }
4902
+ },
4903
+ sample_lines: {
4904
+ type: "array",
4905
+ items: {
4906
+ type: "object",
4907
+ additionalProperties: false,
4908
+ properties: {
4909
+ text: {
4910
+ type: "string",
4911
+ required: true
4912
+ },
4913
+ emotion_hint: { type: "string" }
4914
+ }
4915
+ }
4916
+ },
4917
+ dialogue_count: {
4918
+ type: "integer",
4919
+ required: true
4920
+ },
4921
+ importance_tier: {
4922
+ type: "string",
4923
+ required: true
4924
+ },
4925
+ language: { type: "string" },
4926
+ use_case: { type: "string" }
4927
+ }
4928
+ },
4929
+ mapped_filters: {
4930
+ type: "object",
4931
+ additionalProperties: false,
4932
+ properties: {
4933
+ gender: { type: "string" },
4934
+ age: {
4935
+ type: "array",
4936
+ items: { type: "string" }
4937
+ },
4938
+ fallback_age: {
4939
+ type: "array",
4940
+ items: { type: "string" }
4941
+ },
4942
+ accent: { type: "string" },
4943
+ use_case: { type: "string" },
4944
+ language: { type: "string" },
4945
+ notes: {
4946
+ type: "string",
4947
+ required: true
4948
+ }
4949
+ }
4950
+ },
4951
+ candidate_count: {
4952
+ type: "integer",
4953
+ required: true
4954
+ },
4955
+ candidate_voices: {
4956
+ type: "array",
4957
+ required: true,
4958
+ items: voiceItemSchema
4959
+ },
4960
+ note: { type: "string" }
4961
+ }
4962
+ }
4963
+ },
4964
+ selections: {
4965
+ type: "array",
4966
+ items: {
4967
+ type: "object",
4968
+ additionalProperties: false,
4969
+ properties: {
4970
+ character_id: {
4971
+ type: "string",
4972
+ required: true
4973
+ },
4974
+ character_name: {
4975
+ type: "string",
4976
+ required: true
4977
+ },
4978
+ voice_id: {
4979
+ type: "string",
4980
+ required: true
4981
+ },
4982
+ voice_name: {
4983
+ type: "string",
4984
+ required: true
4985
+ },
4986
+ backup_voice_ids: {
4987
+ type: "array",
4988
+ items: { type: "string" },
4989
+ required: true
4990
+ },
4991
+ reason: {
4992
+ type: "string",
4993
+ required: true
4994
+ },
4995
+ dialogue_count: { type: "integer" },
4996
+ importance_tier: { type: "string" },
4997
+ selection_status: {
4998
+ type: "string",
4999
+ required: true
5000
+ },
5001
+ issues: {
5002
+ type: "array",
5003
+ items: { type: "string" },
5004
+ required: true
5005
+ },
5006
+ selected_at: { type: "string" }
5007
+ }
5008
+ }
5009
+ },
5010
+ issues: {
5011
+ type: "array",
5012
+ items: {
5013
+ type: "object",
5014
+ additionalProperties: false,
5015
+ properties: {
5016
+ character_id: {
5017
+ type: "string",
5018
+ required: true
5019
+ },
5020
+ character_name: {
5021
+ type: "string",
5022
+ required: true
5023
+ },
5024
+ issue: {
5025
+ type: "string",
5026
+ required: true
5027
+ },
5028
+ detail: {
5029
+ type: "string",
5030
+ required: true
5031
+ }
3857
5032
  }
3858
5033
  }
3859
5034
  },
5035
+ store_path: { type: "string" },
3860
5036
  voice_id: { type: "string" },
3861
5037
  deleted: { type: "boolean" },
3862
5038
  message: { type: "string" },
@@ -3874,7 +5050,46 @@ function registerAgentAudioTools(ctx, resolve) {
3874
5050
  const config = resolve();
3875
5051
  if (!config.enabled) throw new AudioGenError("AI audio is disabled. Open Settings > Plugins > AI Audio and enable it.", "plugin-disabled");
3876
5052
  const channel = resolveVoiceManagementChannel(config, args.channel);
3877
- if ((args.action === "delete" ? "delete" : "list") === "list") {
5053
+ const action = args.action === "delete" ? "delete" : args.action === "recommend" ? "recommend" : args.action === "cast" ? "cast" : args.action === "save_cast" ? "save_cast" : "list";
5054
+ if (action === "cast" || action === "save_cast") {
5055
+ const characters = parseCharacterProfiles(args.characters);
5056
+ const defaultUseCase = isElevenLabs$1(channel) && typeof args.use_case !== "string" ? "characters_animation" : void 0;
5057
+ const castOptions = {
5058
+ ...typeof args.use_case === "string" ? { use_case: args.use_case.trim() } : defaultUseCase === void 0 ? {} : { use_case: defaultUseCase },
5059
+ ...typeof args.language === "string" && args.language.trim() !== "" ? { language: args.language.trim() } : {},
5060
+ ...typeof args.accent === "string" && args.accent.trim() !== "" ? { accent: args.accent.trim() } : {}
5061
+ };
5062
+ if (action === "cast") {
5063
+ const prepared = await prepareVoiceCast(channel, characters, castOptions);
5064
+ return {
5065
+ status: "ok",
5066
+ kind: "cast",
5067
+ vendor: prepared.vendor,
5068
+ channel: prepared.channel,
5069
+ pool_size: prepared.pool_size,
5070
+ use_case_filter: prepared.use_case_filter,
5071
+ accent_preference: prepared.accent_preference,
5072
+ character_count: prepared.character_count,
5073
+ characters: prepared.characters,
5074
+ ...prepared.note === void 0 ? {} : { note: prepared.note }
5075
+ };
5076
+ }
5077
+ const rawSelections = args.selections;
5078
+ const selectionInputs = Array.isArray(rawSelections) ? rawSelections : [];
5079
+ if (selectionInputs.length === 0) throw new AudioGenError("save_cast requires selections — an array of {character_id, voice_id, character_name?, backup_voice_ids?, reason?} matching the cast result", "cast-selections-required");
5080
+ const saved = await saveVoiceCast(channel, characters, selectionInputs, castOptions);
5081
+ return {
5082
+ status: "ok",
5083
+ kind: "save_cast",
5084
+ vendor: saved.vendor,
5085
+ channel: saved.channel,
5086
+ store_path: saved.store_path,
5087
+ count: saved.count,
5088
+ selections: saved.entries,
5089
+ issues: saved.issues
5090
+ };
5091
+ }
5092
+ if (action === "list" || action === "recommend") {
3878
5093
  const source = typeof args.source === "string" && [
3879
5094
  "system",
3880
5095
  "custom",
@@ -3900,12 +5115,64 @@ function registerAgentAudioTools(ctx, resolve) {
3900
5115
  ...args.free_users_allowed === true ? { free_users_allowed: true } : {},
3901
5116
  ...args.descriptive === true ? { descriptive: true } : {}
3902
5117
  };
3903
- const result = await listVendorVoices(channel, {
5118
+ const result = await listVendorVoicesWithFallback(channel, {
3904
5119
  ...typeof args.language === "string" && args.language.trim() !== "" ? { language: args.language.trim() } : {},
3905
5120
  ...typeof args.keyword === "string" && args.keyword.trim() !== "" ? { keyword: args.keyword.trim() } : {},
3906
5121
  ...source === void 0 ? {} : { source },
3907
- ...Object.keys(serverFilters).length === 0 ? {} : { serverFilters }
5122
+ ...Object.keys(serverFilters).length === 0 ? {} : { serverFilters },
5123
+ ...action === "recommend" ? { limit: 500 } : {}
3908
5124
  });
5125
+ if (action === "recommend") {
5126
+ const requirement = typeof args.requirement === "string" ? args.requirement.trim() : "";
5127
+ if (requirement === "") throw new AudioGenError("recommend requires requirement (a natural-language voice description).", "recommend-requirement-required");
5128
+ const rawTopK = args.top_k;
5129
+ const topK = typeof rawTopK === "number" && Number.isFinite(rawTopK) ? Math.max(1, Math.min(10, Math.floor(rawTopK))) : 5;
5130
+ if (config.recommend === void 0) throw new AudioGenError("Voice recommendation is unavailable (LLM service not wired).", "recommend-unavailable");
5131
+ const recommendations = await config.recommend(requirement, result.voices, topK);
5132
+ appendVoiceRecommendRecord({
5133
+ channel: channel.name,
5134
+ vendor: result.vendor,
5135
+ requirement,
5136
+ candidate_count: result.voices.length,
5137
+ top_k: topK,
5138
+ channel_id: channel.id,
5139
+ filters: {
5140
+ ...typeof args.language === "string" && args.language.trim() !== "" ? { language: args.language.trim() } : {},
5141
+ ...typeof args.keyword === "string" && args.keyword.trim() !== "" ? { keyword: args.keyword.trim() } : {},
5142
+ ...source === void 0 ? {} : { source },
5143
+ ...typeof args.use_case === "string" && args.use_case.trim() !== "" ? { use_case: args.use_case.trim() } : {},
5144
+ ...typeof args.accent === "string" && args.accent.trim() !== "" ? { accent: args.accent.trim() } : {}
5145
+ },
5146
+ recommendations: recommendations.map((item) => ({
5147
+ voice_id: item.voice_id,
5148
+ name: item.name,
5149
+ source: item.source,
5150
+ deletable: item.deletable,
5151
+ ...item.language === void 0 ? {} : { language: item.language },
5152
+ ...item.locale === void 0 ? {} : { locale: item.locale },
5153
+ ...item.accent === void 0 ? {} : { accent: item.accent },
5154
+ ...item.gender === void 0 ? {} : { gender: item.gender },
5155
+ ...item.age === void 0 ? {} : { age: item.age },
5156
+ ...item.use_case === void 0 ? {} : { use_case: item.use_case },
5157
+ ...item.category === void 0 ? {} : { category: item.category },
5158
+ ...item.labels === void 0 ? {} : { labels: item.labels },
5159
+ ...item.descriptive === void 0 ? {} : { descriptive: item.descriptive },
5160
+ ...item.description === void 0 ? {} : { description: item.description },
5161
+ ...item.preview_url === void 0 ? {} : { preview_url: item.preview_url },
5162
+ reason: item.reason
5163
+ }))
5164
+ }).catch(() => {});
5165
+ return {
5166
+ status: "ok",
5167
+ kind: "recommend",
5168
+ vendor: result.vendor,
5169
+ channel: channel.name,
5170
+ requirement,
5171
+ candidate_count: result.voices.length,
5172
+ recommendations,
5173
+ ...result.note === void 0 ? {} : { note: result.note }
5174
+ };
5175
+ }
3909
5176
  return {
3910
5177
  status: "ok",
3911
5178
  kind: "list",
@@ -3989,7 +5256,7 @@ const DEFAULT_ENABLED = true;
3989
5256
  const DEFAULT_ANNOUNCE = true;
3990
5257
  const DEFAULT_ALLOW_AGENT_AUDIO = true;
3991
5258
  const SECTION_ORDER = 160;
3992
- const AUDIOGEN_GUIDANCE = "本机已安装 dsh-audiogen 插件(DSH AI 音频):侧边栏「AI 音频」入口。能力:通过「渠道」对接多个音频生成厂商(OpenAI TTS、ElevenLabs、MiniMax、Stability Audio、自定义 OpenAI 兼容接口),支持 TTS 文本转语音、音乐生成和音效生成。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发。Agent 可直接调用 `generate_audio` 提交 TTS/音乐/音效任务,默认等待完成并返回同源音频 URL;可用 `manage_audio_voices` 浏览/筛选/删除厂商音色(MiniMax、ElevenLabs),再用选定音色的 voice_id 调用 `generate_audio` 生成。限制:生成消耗上游 API 额度;音频内容由上游模型生成;模型只能使用用户在各渠道配置目录中的模型。用户提到「音频 / 语音 / TTS / 配乐 / 音效 / AI 音频」时即指本插件,请据此协作。";
5259
+ const AUDIOGEN_GUIDANCE = "本机已安装 dsh-audiogen 插件(DSH AI 音频):侧边栏「AI 音频」入口。能力:通过「渠道」对接多个音频生成厂商(OpenAI TTS、ElevenLabs、MiniMax、Stability Audio、自定义 OpenAI 兼容接口),支持 TTS 文本转语音、音乐生成和音效生成。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发。Agent 可直接调用 `generate_audio` 提交 TTS/音乐/音效任务,默认等待完成并返回同源音频 URL;可用 `manage_audio_voices` 浏览/筛选/删除厂商音色(MiniMax、ElevenLabs),也能用该工具的 action=recommend 按自然语言需求描述(如「清亮甜美的少女音」)让默认模型推荐 top-k 音色,再用选定音色的 voice_id 调用 `generate_audio` 生成。角色音色选角(小说/游戏配音 casting):传角色画像(JSON 数组/对象,字段 character_id/character_name/gender/age_stage/voice_traits/personality_traits/appearance/sample_lines/dialogue_count/language;文本描述需先整理成该结构)调用 `manage_audio_voices` 的 action=cast,工具按性别/年龄/用途做确定性硬过滤(accent 为偏好、候选为空才放松)并返回每个角色的候选池,你在上下文中全局权衡主音色(lead/major 不复用)+ 备用音色后,用 action=save_cast 校验落盘(~/.dsh/dsh-audiogen/cast-selections.json),随后按选定 voice_id 生成 TTS;MiniMax 也可先用 `generate_audio(mode=voice_design)` 为角色创作专属音色再选角。限制:生成消耗上游 API 额度;音频内容由上游模型生成;模型只能使用用户在各渠道配置目录中的模型;音色管理仅 MiniMax 与 ElevenLabs 渠道支持。用户提到「音频 / 语音 / TTS / 配乐 / 音效 / AI 音频」时即指本插件,请据此协作。";
3993
5260
  function guidanceFor(channels, defaultChannelId) {
3994
5261
  if (channels.length === 0) return `${AUDIOGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥。`;
3995
5262
  const table = channels.map((channel) => {
@@ -4106,6 +5373,14 @@ function apply(ctx, config) {
4106
5373
  model
4107
5374
  } : void 0;
4108
5375
  };
5376
+ const recommend = async (requirement, candidates, topK) => {
5377
+ const seam = ctx.get("settings");
5378
+ if (seam?.describe === void 0) throw new AudioGenError("设置服务不可用,无法推荐音色", "settings-unavailable");
5379
+ return recommendVoices({
5380
+ settings: seam,
5381
+ llm: () => ctx.get("llm")
5382
+ }, requirement, candidates, topK);
5383
+ };
4109
5384
  /** 读取「设置 → 模型」目录:各提供方 + 可广播的模型列表(增强模型下拉候选)。 */
4110
5385
  const llmModelOptions = async () => {
4111
5386
  const llm = ctx.get("llm");
@@ -4154,6 +5429,7 @@ function apply(ctx, config) {
4154
5429
  autoSave: () => resolve().autoSaveToLibrary,
4155
5430
  budget,
4156
5431
  enhance,
5432
+ recommend,
4157
5433
  llmModelOptions
4158
5434
  }).map((route) => ctx.webServer.register(route));
4159
5435
  return () => {
@@ -4171,7 +5447,8 @@ function apply(ctx, config) {
4171
5447
  defaultChannelId: value.defaultChannelId,
4172
5448
  autoSaveToLibrary: value.autoSaveToLibrary,
4173
5449
  budget,
4174
- enhance
5450
+ enhance,
5451
+ recommend
4175
5452
  };
4176
5453
  }), "dsh-audiogen: agent audio tools");
4177
5454
  });