dsh-audiogen 0.1.0

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 ADDED
@@ -0,0 +1,1345 @@
1
+ import { SettingsConflictError, installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ import z from "schemastery";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ //#region src/protocol.ts
9
+ /**
10
+ * Wire contract shared by the host and client halves of dsh-audiogen:
11
+ * settings namespace, route paths, generate payload/result shapes.
12
+ * Pure types and constants — safe for the client bundle to inline.
13
+ */
14
+ /** Settings namespace this plugin owns (host settings seam + bridge). */
15
+ const AUDIOGEN_SETTINGS_NAMESPACE = "dsh-audiogen";
16
+ /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
17
+ const SETTINGS_API = {
18
+ describe: "/api/dsh-audiogen/settings/describe",
19
+ mutate: "/api/dsh-audiogen/settings/mutate"
20
+ };
21
+ /** The audio-generation proxy route. */
22
+ const GENERATE_API = "/api/dsh-audiogen/generate";
23
+ /** Host-mediated built-in provider catalog (channels the user can instantiate). */
24
+ const PRESETS_API = "/api/dsh-audiogen/presets";
25
+ /** Loopback-only audio file reader for panel/tool-result previews. */
26
+ const AUDIO_API = { file: "/api/dsh-audiogen/audio" };
27
+ /** Host-persisted generation history routes. */
28
+ const HISTORY_API = {
29
+ list: "/api/dsh-audiogen/history/list",
30
+ append: "/api/dsh-audiogen/history/append",
31
+ remove: "/api/dsh-audiogen/history/remove",
32
+ clear: "/api/dsh-audiogen/history/clear",
33
+ audio: "/api/dsh-audiogen/history/audio"
34
+ };
35
+ //#endregion
36
+ //#region src/audio-engine.ts
37
+ /** An audio generation failure with a user-presentable message. */
38
+ var AudioGenError = class extends Error {
39
+ code;
40
+ constructor(message, code = "audio-generate-failed") {
41
+ super(message);
42
+ this.name = "AudioGenError";
43
+ this.code = code;
44
+ }
45
+ };
46
+ /** Total budget for one upstream generation call. Audio models can be slow. */
47
+ const UPSTREAM_TIMEOUT_MS = 24e4;
48
+ /** Budget for downloading one result audio URL. */
49
+ const AUDIO_FETCH_TIMEOUT_MS = 6e4;
50
+ function requestSignal(source, timeoutMs) {
51
+ const controller = new AbortController();
52
+ const abortFromSource = () => {
53
+ controller.abort(source?.reason);
54
+ };
55
+ if (source?.aborted === true) abortFromSource();
56
+ else source?.addEventListener("abort", abortFromSource, { once: true });
57
+ const timeout = setTimeout(() => {
58
+ controller.abort(new DOMException("The operation timed out.", "TimeoutError"));
59
+ }, timeoutMs);
60
+ timeout.unref?.();
61
+ return {
62
+ signal: controller.signal,
63
+ dispose: () => {
64
+ clearTimeout(timeout);
65
+ source?.removeEventListener("abort", abortFromSource);
66
+ }
67
+ };
68
+ }
69
+ /** Detect a few common audio container formats from magic bytes. */
70
+ function detectAudioMime(data) {
71
+ if (data.length >= 4 && data[0] === 82 && data[1] === 73 && data[2] === 70 && data[3] === 70) return "audio/wav";
72
+ if (data.length >= 3 && data[0] === 73 && data[1] === 68 && data[2] === 51) return "audio/mpeg";
73
+ if (data.length >= 4 && data[0] === 102 && data[1] === 76 && data[2] === 97 && data[3] === 67) return "audio/flac";
74
+ if (data.length >= 4 && data[0] === 79 && data[1] === 103 && data[2] === 103 && data[3] === 83) return "audio/ogg";
75
+ if (data.length >= 4 && data[0] === 0 && data[1] === 0 && data[2] === 0 && data[3] === 24) return "audio/mp4";
76
+ if (data.length >= 4 && data[0] === 35 && data[1] === 33 && data[2] === 65 && data[3] === 77) return "audio/aiff";
77
+ }
78
+ function mimeFromContentType(value) {
79
+ if (value === null || value === "") return void 0;
80
+ return value.split(";")[0].trim().toLowerCase();
81
+ }
82
+ function audioMime(data, contentType) {
83
+ return detectAudioMime(data) ?? mimeFromContentType(contentType) ?? "audio/mpeg";
84
+ }
85
+ function isPreset(channel, id) {
86
+ return channel.preset === id || channel.apiUrl.toLowerCase().includes(id);
87
+ }
88
+ function isOpenAICompatible(channel, mode) {
89
+ return isPreset(channel, "openai") || /(^|\/)(v\d+\/)?audio\/speech$/i.test(channel.apiUrl.trim()) || channel.preset === "custom" && mode === "tts";
90
+ }
91
+ function isElevenLabs(channel) {
92
+ return isPreset(channel, "elevenlabs") || /elevenlabs/i.test(channel.apiUrl);
93
+ }
94
+ function isMiniMax(channel) {
95
+ return isPreset(channel, "minimax") || /minimax/i.test(channel.apiUrl);
96
+ }
97
+ function isStability(channel) {
98
+ return isPreset(channel, "stability") || /stability\.ai/i.test(channel.apiUrl);
99
+ }
100
+ function endpointBase(url) {
101
+ return url.trim().replace(/\/+$/, "");
102
+ }
103
+ /** Recursively look for the first likely base64 audio string in a JSON payload. */
104
+ function findBase64Audio(value) {
105
+ if (typeof value === "string" && value.length > 100 && !/^https?:\/\//i.test(value.trim())) return value;
106
+ if (Array.isArray(value)) {
107
+ for (const item of value) {
108
+ const found = findBase64Audio(item);
109
+ if (found !== void 0) return found;
110
+ }
111
+ return;
112
+ }
113
+ if (value === null || typeof value !== "object") return void 0;
114
+ const record = value;
115
+ for (const key of [
116
+ "audio",
117
+ "b64_json",
118
+ "base64",
119
+ "data",
120
+ "output",
121
+ "result",
122
+ "value"
123
+ ]) {
124
+ const candidate = record[key];
125
+ const found = findBase64Audio(candidate);
126
+ if (found !== void 0) return found;
127
+ }
128
+ }
129
+ /** Find the first provider-returned audio URL in a JSON payload. */
130
+ function findAudioUrl(value) {
131
+ if (typeof value === "string" && /^https?:\/\//i.test(value)) return value;
132
+ if (Array.isArray(value)) {
133
+ for (const item of value) {
134
+ const found = findAudioUrl(item);
135
+ if (found !== void 0) return found;
136
+ }
137
+ return;
138
+ }
139
+ if (value === null || typeof value !== "object") return void 0;
140
+ const record = value;
141
+ for (const key of [
142
+ "url",
143
+ "audio_url",
144
+ "href",
145
+ "link",
146
+ "audio",
147
+ "data",
148
+ "output",
149
+ "result",
150
+ "value"
151
+ ]) {
152
+ const candidate = record[key];
153
+ const found = findAudioUrl(candidate);
154
+ if (found !== void 0) return found;
155
+ }
156
+ }
157
+ async function fetchWithTimeout(url, init, timeoutMs) {
158
+ const budget = requestSignal(init.signal, timeoutMs);
159
+ try {
160
+ return await fetch(url, {
161
+ ...init,
162
+ signal: budget.signal
163
+ });
164
+ } finally {
165
+ budget.dispose();
166
+ }
167
+ }
168
+ async function normalizeAudioResponse(response, options) {
169
+ if (!response.ok) {
170
+ let detail = "";
171
+ try {
172
+ detail = (await response.text()).slice(0, 500);
173
+ } catch {}
174
+ throw new AudioGenError(`audio API error (HTTP ${response.status})${detail === "" ? "" : `: ${detail}`}`, "audio-api-error");
175
+ }
176
+ const contentType = mimeFromContentType(response.headers.get("content-type")) ?? options.fallbackMime;
177
+ const buffer = new Uint8Array(await response.arrayBuffer());
178
+ const text = new TextDecoder().decode(buffer).trim();
179
+ if (text.startsWith("{") || text.startsWith("[")) {
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(text);
183
+ } catch {
184
+ throw new AudioGenError("audio endpoint returned an unprocessable response body", "audio-bad-response");
185
+ }
186
+ const base64 = findBase64Audio(parsed);
187
+ if (base64 !== void 0 && base64.length > 0) {
188
+ let data;
189
+ try {
190
+ data = new Uint8Array(Buffer.from(base64, "base64"));
191
+ } catch {
192
+ throw new AudioGenError("audio endpoint returned invalid base64", "audio-bad-response");
193
+ }
194
+ return [{
195
+ data,
196
+ mime: detectAudioMime(data) ?? contentType ?? "audio/mpeg"
197
+ }];
198
+ }
199
+ const url = findAudioUrl(parsed);
200
+ if (url !== void 0) {
201
+ const fetched = await fetchWithTimeout(url, {
202
+ headers: options.apiKey === "" ? {} : { authorization: `Bearer ${options.apiKey}` },
203
+ redirect: "follow"
204
+ }, AUDIO_FETCH_TIMEOUT_MS);
205
+ if (!fetched.ok) throw new AudioGenError(`failed to fetch generated audio url: HTTP ${fetched.status}`, "audio-url-fetch-failed");
206
+ const data = new Uint8Array(await fetched.arrayBuffer());
207
+ return [{
208
+ data,
209
+ mime: audioMime(data, fetched.headers.get("content-type"))
210
+ }];
211
+ }
212
+ throw new AudioGenError("audio endpoint returned neither binary nor base64/url audio", "audio-empty-result");
213
+ }
214
+ return [{
215
+ data: buffer,
216
+ mime: audioMime(buffer, response.headers.get("content-type") ?? contentType ?? null)
217
+ }];
218
+ }
219
+ async function openAITTS(channel, request, signal) {
220
+ const base = endpointBase(channel.apiUrl);
221
+ const endpoint = /\/audio\/speech(\?|$)/i.test(base) ? base : `${base}/audio/speech`;
222
+ const model = (request.upstream ?? request.model) || "tts-1";
223
+ const voice = request.voice ?? "alloy";
224
+ const body = {
225
+ model,
226
+ input: request.prompt,
227
+ voice,
228
+ response_format: request.format ?? "mp3",
229
+ ...request.speed !== void 0 ? { speed: request.speed } : {}
230
+ };
231
+ return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
232
+ method: "POST",
233
+ redirect: "error",
234
+ headers: {
235
+ authorization: `Bearer ${channel.apiKey.trim()}`,
236
+ "content-type": "application/json",
237
+ accept: "audio/mpeg, application/json"
238
+ },
239
+ body: JSON.stringify(body),
240
+ signal
241
+ }, UPSTREAM_TIMEOUT_MS), {
242
+ apiKey: channel.apiKey,
243
+ fallbackMime: "audio/mpeg"
244
+ });
245
+ }
246
+ async function elevenLabs(channel, request, signal) {
247
+ const base = endpointBase(channel.apiUrl);
248
+ const model = (request.upstream ?? request.model) || "eleven_multilingual_v2";
249
+ const voiceId = (request.voice ?? request.model ?? model).trim();
250
+ const endpoint = `${base}/text-to-speech/${encodeURIComponent(voiceId)}`;
251
+ const body = {
252
+ text: request.prompt,
253
+ model_id: model,
254
+ voice_settings: {
255
+ stability: .5,
256
+ similarity_boost: .75,
257
+ style: 0,
258
+ use_speaker_boost: true,
259
+ ...request.speed !== void 0 ? { speed: request.speed } : {}
260
+ }
261
+ };
262
+ return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
263
+ method: "POST",
264
+ redirect: "error",
265
+ headers: {
266
+ "xi-api-key": channel.apiKey.trim(),
267
+ "content-type": "application/json",
268
+ accept: "audio/mpeg, application/json"
269
+ },
270
+ body: JSON.stringify(body),
271
+ signal
272
+ }, UPSTREAM_TIMEOUT_MS), {
273
+ apiKey: channel.apiKey,
274
+ fallbackMime: "audio/mpeg"
275
+ });
276
+ }
277
+ async function minimax(channel, request, signal) {
278
+ const base = endpointBase(channel.apiUrl);
279
+ const endpoint = /\/t2a_v2(\?|$)/i.test(base) ? base : `${base}/t2a_v2`;
280
+ const model = (request.upstream ?? request.model) || "speech-01-turbo";
281
+ const voice = request.voice ?? request.model ?? "";
282
+ const body = {
283
+ model,
284
+ text: request.prompt,
285
+ stream: false,
286
+ ...voice === "" ? {} : { voice_setting: {
287
+ voice_id: voice,
288
+ ...request.speed !== void 0 ? { speed: request.speed } : {},
289
+ vol: 1,
290
+ pitch: 0
291
+ } },
292
+ audio_setting: {
293
+ format: request.format ?? "mp3",
294
+ sample_rate: 32e3,
295
+ bitrate: 128e3
296
+ }
297
+ };
298
+ return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
299
+ method: "POST",
300
+ redirect: "error",
301
+ headers: {
302
+ authorization: `Bearer ${channel.apiKey.trim()}`,
303
+ "content-type": "application/json",
304
+ accept: "application/json, audio/mpeg"
305
+ },
306
+ body: JSON.stringify(body),
307
+ signal
308
+ }, UPSTREAM_TIMEOUT_MS), {
309
+ apiKey: channel.apiKey,
310
+ fallbackMime: "audio/mpeg"
311
+ });
312
+ }
313
+ async function stabilityAudio(channel, request, signal) {
314
+ const base = endpointBase(channel.apiUrl);
315
+ const endpoint = /\/generation(\?|$)/i.test(base) ? base : `${base}/generation`;
316
+ const body = {
317
+ model: (request.upstream ?? request.model) || "stable-audio-2.0",
318
+ prompt: request.prompt,
319
+ ...request.duration !== void 0 ? { duration: request.duration } : {},
320
+ ...request.format !== void 0 ? { output_format: request.format } : {}
321
+ };
322
+ return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
323
+ method: "POST",
324
+ redirect: "error",
325
+ headers: {
326
+ authorization: `Bearer ${channel.apiKey.trim()}`,
327
+ "content-type": "application/json",
328
+ accept: "application/json, audio/mpeg, audio/wav"
329
+ },
330
+ body: JSON.stringify(body),
331
+ signal
332
+ }, UPSTREAM_TIMEOUT_MS), {
333
+ apiKey: channel.apiKey,
334
+ fallbackMime: "audio/mpeg"
335
+ });
336
+ }
337
+ async function genericAudio(channel, request, signal) {
338
+ const base = endpointBase(channel.apiUrl);
339
+ if (request.mode === "tts" && !/\/generate(\?|$)/i.test(base)) return openAITTS(channel, request, signal);
340
+ const endpoint = /\/generate(\?|$)/i.test(base) ? base : `${base}/generate`;
341
+ const body = {
342
+ model: (request.upstream ?? request.model) || "default",
343
+ prompt: request.prompt,
344
+ mode: request.mode,
345
+ ...request.voice !== void 0 ? { voice: request.voice } : {},
346
+ ...request.duration !== void 0 ? { duration: request.duration } : {},
347
+ ...request.format !== void 0 ? { output_format: request.format } : {}
348
+ };
349
+ return normalizeAudioResponse(await fetchWithTimeout(endpoint, {
350
+ method: "POST",
351
+ redirect: "error",
352
+ headers: {
353
+ authorization: `Bearer ${channel.apiKey.trim()}`,
354
+ "content-type": "application/json",
355
+ accept: "application/json, audio/mpeg, audio/wav"
356
+ },
357
+ body: JSON.stringify(body),
358
+ signal
359
+ }, UPSTREAM_TIMEOUT_MS), {
360
+ apiKey: channel.apiKey,
361
+ fallbackMime: "audio/mpeg"
362
+ });
363
+ }
364
+ /**
365
+ * Generate one or more audio outputs from a configured channel.
366
+ * @returns normalized generated audio (base64, mime, bytes).
367
+ */
368
+ async function generateAudio(channel, request, signal) {
369
+ if (channel.apiUrl.trim() === "") throw new AudioGenError("channel API URL is not configured", "audio-no-endpoint");
370
+ if (channel.apiKey.trim() === "") throw new AudioGenError("channel API key is not configured", "audio-no-key");
371
+ if (request.prompt.trim() === "") throw new AudioGenError("audio prompt/text is required", "audio-empty-prompt");
372
+ if (isElevenLabs(channel)) return elevenLabs(channel, request, signal);
373
+ if (isMiniMax(channel)) return minimax(channel, request, signal);
374
+ if (isStability(channel)) return stabilityAudio(channel, request, signal);
375
+ if (isOpenAICompatible(channel, request.mode)) return openAITTS(channel, request, signal);
376
+ return genericAudio(channel, request, signal);
377
+ }
378
+ //#endregion
379
+ //#region src/audio-presets.ts
380
+ const AUDIO_PRESETS = [
381
+ {
382
+ id: "openai-tts",
383
+ name: "OpenAI · TTS",
384
+ apiUrl: "https://api.openai.com/v1",
385
+ hint: "OpenAI 官方语音合成接口(/audio/speech)",
386
+ models: [
387
+ {
388
+ alias: "tts-1",
389
+ id: "tts-1"
390
+ },
391
+ {
392
+ alias: "tts-1-hd",
393
+ id: "tts-1-hd"
394
+ },
395
+ {
396
+ alias: "gpt-4o-mini-tts",
397
+ id: "gpt-4o-mini-tts"
398
+ }
399
+ ]
400
+ },
401
+ {
402
+ id: "elevenlabs",
403
+ name: "ElevenLabs",
404
+ apiUrl: "https://api.elevenlabs.io/v1",
405
+ hint: "ElevenLabs TTS;模型列表请填写你的 Voice ID(如 Rachel / Adam 等别名)",
406
+ models: [
407
+ {
408
+ alias: "Rachel",
409
+ id: "21m00Tcm4TlvDq8ikWAM"
410
+ },
411
+ {
412
+ alias: "Adam",
413
+ id: "pNInz6obpgDQGcFmaJgB"
414
+ },
415
+ {
416
+ alias: "Antoni",
417
+ id: "ErXwobaYiN019PkySvjV"
418
+ },
419
+ {
420
+ alias: "Bella",
421
+ id: "EXAVITQu4vr4xnSDxMaL"
422
+ }
423
+ ]
424
+ },
425
+ {
426
+ id: "minimax",
427
+ name: "MiniMax",
428
+ apiUrl: "https://api.minimax.chat/v1",
429
+ hint: "MiniMax 语音合成(T2A);需在 API URL 后按官方要求携带 GroupId 或使用完整接口地址",
430
+ models: [
431
+ {
432
+ alias: "speech-01-turbo",
433
+ id: "speech-01-turbo"
434
+ },
435
+ {
436
+ alias: "speech-01-hd",
437
+ id: "speech-01-hd"
438
+ },
439
+ {
440
+ alias: "speech-02-turbo",
441
+ id: "speech-02-turbo"
442
+ },
443
+ {
444
+ alias: "speech-02-hd",
445
+ id: "speech-02-hd"
446
+ }
447
+ ]
448
+ },
449
+ {
450
+ id: "stability-audio",
451
+ name: "Stability AI · 音频",
452
+ apiUrl: "https://api.stability.ai/v2beta/audio",
453
+ hint: "Stability AI 音乐/音效生成(stable-audio 系列)",
454
+ models: [{
455
+ alias: "stable-audio-2.0",
456
+ id: "stable-audio-2.0"
457
+ }, {
458
+ alias: "stable-audio-1.0",
459
+ id: "stable-audio-1.0"
460
+ }]
461
+ },
462
+ {
463
+ id: "custom",
464
+ name: "自定义渠道",
465
+ apiUrl: "",
466
+ hint: "任意兼容接口;支持 OpenAI 兼容 TTS,或返回音频字节 / JSON 的通用 POST",
467
+ models: []
468
+ }
469
+ ];
470
+ /** Look up one built-in provider by id. */
471
+ function audioPresetById(id) {
472
+ return AUDIO_PRESETS.find((preset) => preset.id === id);
473
+ }
474
+ //#endregion
475
+ //#region src/audio-store.ts
476
+ /**
477
+ * Host-side persistence for generated audio and generation history.
478
+ * Files live under ~/.dsh/dsh-audiogen/audio/; history is one JSON document.
479
+ */
480
+ function dshHome() {
481
+ return process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh");
482
+ }
483
+ const AUDIO_DATA_DIR = path.join(dshHome(), "dsh-audiogen", "audio");
484
+ const HISTORY_FILE = path.join(dshHome(), "dsh-audiogen", "history.json");
485
+ async function ensureDir() {
486
+ await mkdir(AUDIO_DATA_DIR, { recursive: true });
487
+ }
488
+ function safeName(id) {
489
+ return id.replace(/[^a-zA-Z0-9._-]/g, "_");
490
+ }
491
+ /** Persist one generated audio file. Returns its metadata and public id. */
492
+ async function saveAudioFile(data, mime, name) {
493
+ await ensureDir();
494
+ const id = randomUUID();
495
+ const file = `${id}.${mime.split("/")[1]?.replace("mpeg", "mp3") ?? "bin"}`;
496
+ await writeFile(path.join(AUDIO_DATA_DIR, file), data);
497
+ return {
498
+ id,
499
+ file,
500
+ mime,
501
+ bytes: data.byteLength,
502
+ ...name === void 0 ? {} : { name }
503
+ };
504
+ }
505
+ /** Read a persisted audio file by its id/file name. */
506
+ async function readAudioFile(file) {
507
+ const safe = safeName(file);
508
+ const full = path.join(AUDIO_DATA_DIR, safe);
509
+ if (!full.startsWith(AUDIO_DATA_DIR)) return void 0;
510
+ try {
511
+ const data = await readFile(full);
512
+ return {
513
+ data,
514
+ mime: mimeFromFile(safe),
515
+ bytes: data.byteLength
516
+ };
517
+ } catch {
518
+ return;
519
+ }
520
+ }
521
+ function mimeFromFile(file) {
522
+ switch (path.extname(file).toLowerCase()) {
523
+ case ".wav": return "audio/wav";
524
+ case ".mp3": return "audio/mpeg";
525
+ case ".flac": return "audio/flac";
526
+ case ".ogg": return "audio/ogg";
527
+ case ".m4a": return "audio/mp4";
528
+ case ".aac": return "audio/aac";
529
+ case ".aiff": return "audio/aiff";
530
+ default: return "application/octet-stream";
531
+ }
532
+ }
533
+ async function readHistory() {
534
+ try {
535
+ const text = await readFile(HISTORY_FILE, "utf8");
536
+ const parsed = JSON.parse(text);
537
+ return Array.isArray(parsed) ? parsed : [];
538
+ } catch {
539
+ return [];
540
+ }
541
+ }
542
+ async function writeHistory(entries) {
543
+ await mkdir(path.dirname(HISTORY_FILE), { recursive: true });
544
+ await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2));
545
+ }
546
+ /** Append one history entry and enforce the cap. */
547
+ async function appendHistory(entry) {
548
+ const list = await readHistory();
549
+ const next = [{
550
+ id: entry.id,
551
+ createdAt: entry.createdAt,
552
+ mode: entry.mode,
553
+ model: entry.model,
554
+ prompt: entry.prompt,
555
+ ...entry.voice === void 0 ? {} : { voice: entry.voice },
556
+ ...entry.speed === void 0 ? {} : { speed: entry.speed },
557
+ ...entry.duration === void 0 ? {} : { duration: entry.duration },
558
+ ...entry.format === void 0 ? {} : { format: entry.format },
559
+ audio: entry.audio.map((audio) => ({
560
+ url: audio.url,
561
+ mime: audio.mime,
562
+ ...audio.duration === void 0 ? {} : { duration: audio.duration }
563
+ })),
564
+ ...entry.channelId === void 0 ? {} : { channelId: entry.channelId },
565
+ ...entry.channel === void 0 ? {} : { channel: entry.channel }
566
+ }, ...list].slice(0, 50);
567
+ await writeHistory(next);
568
+ return next;
569
+ }
570
+ async function listHistory() {
571
+ return readHistory();
572
+ }
573
+ async function removeHistory(id) {
574
+ const next = (await readHistory()).filter((entry) => entry.id !== id);
575
+ await writeHistory(next);
576
+ return next;
577
+ }
578
+ async function clearHistory() {
579
+ await writeHistory([]);
580
+ return [];
581
+ }
582
+ //#endregion
583
+ //#region src/routes.ts
584
+ const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
585
+ function isLoopbackRequest(request) {
586
+ const address = request.socket.remoteAddress;
587
+ if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
588
+ const host = request.headers.host;
589
+ if (typeof host !== "string") return false;
590
+ let hostUrl;
591
+ try {
592
+ hostUrl = new URL(`http://${host}`);
593
+ } catch {
594
+ return false;
595
+ }
596
+ if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
597
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
598
+ const origin = request.headers.origin;
599
+ if (origin === void 0) return true;
600
+ try {
601
+ return new URL(origin).host === hostUrl.host;
602
+ } catch {
603
+ return false;
604
+ }
605
+ }
606
+ function writeJson(res, status, body) {
607
+ const payload = JSON.stringify(body);
608
+ res.writeHead(status, {
609
+ "content-type": "application/json; charset=utf-8",
610
+ "referrer-policy": "no-referrer"
611
+ });
612
+ res.end(payload);
613
+ }
614
+ async function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
615
+ const chunks = [];
616
+ let size = 0;
617
+ for await (const chunk of req) {
618
+ const buffer = chunk;
619
+ size += buffer.length;
620
+ if (size > maxBytes) return void 0;
621
+ chunks.push(buffer);
622
+ }
623
+ try {
624
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
625
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
626
+ } catch {
627
+ return;
628
+ }
629
+ }
630
+ function messageOf(error) {
631
+ return error instanceof Error ? error.message : String(error);
632
+ }
633
+ function parseGenerateRequest(body) {
634
+ const mode = body.mode === "music" ? "music" : body.mode === "sfx" ? "sfx" : "tts";
635
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
636
+ if (prompt === "") return void 0;
637
+ return {
638
+ mode,
639
+ model: typeof body.model === "string" ? body.model.trim() : "",
640
+ prompt,
641
+ ...typeof body.voice === "string" && body.voice.trim() !== "" ? { voice: body.voice.trim() } : {},
642
+ ...typeof body.speed === "number" ? { speed: body.speed } : {},
643
+ ...typeof body.duration === "number" ? { duration: body.duration } : {},
644
+ ...typeof body.format === "string" && body.format.trim() !== "" ? { format: body.format.trim() } : {},
645
+ ...typeof body.channelId === "string" && body.channelId !== "" ? { channelId: body.channelId } : {}
646
+ };
647
+ }
648
+ function toView(descriptor) {
649
+ return {
650
+ ns: String(descriptor.ns),
651
+ schema: descriptor.schema,
652
+ value: descriptor.value,
653
+ ...descriptor.base === void 0 ? {} : { base: descriptor.base },
654
+ ...descriptor.user === void 0 ? {} : { user: descriptor.user },
655
+ ...descriptor.secrets === void 0 ? {} : { secrets: descriptor.secrets.map((secret) => ({
656
+ path: [...secret.path],
657
+ set: secret.set
658
+ })) },
659
+ revision: descriptor.revision
660
+ };
661
+ }
662
+ function failureOf(error) {
663
+ if (error instanceof SettingsConflictError) return {
664
+ ok: false,
665
+ code: "settings-conflict",
666
+ message: error.message
667
+ };
668
+ return {
669
+ ok: false,
670
+ code: "settings-rejected",
671
+ message: error instanceof Error ? error.message : String(error)
672
+ };
673
+ }
674
+ /**
675
+ * Resolve a requested model alias onto a concrete channel/upstream id.
676
+ */
677
+ function resolveChannelRequest(request, view) {
678
+ if (view.channels.length === 0) return {
679
+ ok: false,
680
+ code: "no-channels",
681
+ message: "尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥"
682
+ };
683
+ const explicit = view.channels.find((candidate) => candidate.id === request.channelId);
684
+ const defaults = view.channels.find((candidate) => candidate.id === view.defaultChannelId) ?? view.channels[0];
685
+ const target = explicit ?? defaults;
686
+ const asked = request.model.trim();
687
+ if (asked === "") {
688
+ const alias = target?.models[0]?.alias ?? "";
689
+ if (alias === "") return {
690
+ ok: false,
691
+ code: "no-models",
692
+ message: `渠道「${target?.name ?? ""}」尚未配置模型/音色,请先在设置中添加`
693
+ };
694
+ const mapping = target.models.find((model) => model.alias === alias);
695
+ return {
696
+ ok: true,
697
+ request: {
698
+ ...request,
699
+ model: alias,
700
+ upstream: mapping.id,
701
+ channelId: target.id,
702
+ channel: target.name
703
+ }
704
+ };
705
+ }
706
+ const hosting = view.channels.filter((channel) => channel.models.some((model) => model.alias === asked));
707
+ if (hosting.length === 0) return {
708
+ ok: false,
709
+ code: "audio-model-not-configured",
710
+ message: `模型/音色「${asked}」未在任一渠道配置;可用:${[...new Set(view.channels.flatMap((channel) => channel.models.map((model) => model.alias)))].join("、") || "(无)"}`
711
+ };
712
+ const picked = target !== void 0 && target.models.some((model) => model.alias === asked) ? target : hosting[0];
713
+ const mapping = picked.models.find((model) => model.alias === asked);
714
+ return {
715
+ ok: true,
716
+ request: {
717
+ ...request,
718
+ model: asked,
719
+ upstream: mapping.id,
720
+ channelId: picked.id,
721
+ channel: picked.name
722
+ }
723
+ };
724
+ }
725
+ /** Build every /api/dsh-audiogen route. */
726
+ function makeRoutes(deps) {
727
+ const guard = (req, res, method) => {
728
+ if (!isLoopbackRequest(req)) {
729
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
730
+ return false;
731
+ }
732
+ if (req.method !== method) {
733
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
734
+ return false;
735
+ }
736
+ return true;
737
+ };
738
+ const audioFileFrom = (rawUrl, basePath) => {
739
+ if (rawUrl === void 0) return void 0;
740
+ let pathname;
741
+ try {
742
+ pathname = new URL(rawUrl, "http://localhost").pathname;
743
+ } catch {
744
+ return;
745
+ }
746
+ if (!pathname.startsWith(`${basePath}/`)) return void 0;
747
+ return decodeURIComponent(pathname.slice(basePath.length + 1));
748
+ };
749
+ return [
750
+ {
751
+ kind: "exact",
752
+ path: PRESETS_API,
753
+ handler: async (req, res) => {
754
+ if (!guard(req, res, "POST")) return;
755
+ writeJson(res, 200, {
756
+ ok: true,
757
+ presets: AUDIO_PRESETS
758
+ });
759
+ }
760
+ },
761
+ {
762
+ kind: "exact",
763
+ path: SETTINGS_API.describe,
764
+ handler: async (req, res) => {
765
+ if (!guard(req, res, "POST")) return;
766
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === AUDIOGEN_SETTINGS_NAMESPACE);
767
+ writeJson(res, 200, {
768
+ ok: true,
769
+ value: {
770
+ namespaces: descriptor === void 0 ? [] : [toView(descriptor)],
771
+ writable: deps.settings.writable !== false
772
+ }
773
+ });
774
+ }
775
+ },
776
+ {
777
+ kind: "exact",
778
+ path: SETTINGS_API.mutate,
779
+ handler: async (req, res) => {
780
+ if (!guard(req, res, "POST")) return;
781
+ const body = await readJsonBody(req);
782
+ if (body === void 0) {
783
+ writeJson(res, 200, {
784
+ ok: false,
785
+ code: "settings-rejected",
786
+ message: "unreadable JSON body"
787
+ });
788
+ return;
789
+ }
790
+ const ns = typeof body.ns === "string" ? body.ns : "";
791
+ if (ns !== "dsh-audiogen" || !Array.isArray(body.ops)) {
792
+ writeJson(res, 200, {
793
+ ok: false,
794
+ code: "settings-rejected",
795
+ message: "malformed bridge settings request"
796
+ });
797
+ return;
798
+ }
799
+ const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : void 0;
800
+ try {
801
+ await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision);
802
+ } catch (error) {
803
+ writeJson(res, 200, failureOf(error));
804
+ return;
805
+ }
806
+ const descriptor = deps.settings.describe({ redactSecrets: true }).find((candidate) => String(candidate.ns) === ns);
807
+ if (descriptor === void 0) {
808
+ writeJson(res, 200, {
809
+ ok: false,
810
+ code: "internal",
811
+ message: `settings namespace "${ns}" was disposed after the mutate`
812
+ });
813
+ return;
814
+ }
815
+ writeJson(res, 200, {
816
+ ok: true,
817
+ value: toView(descriptor)
818
+ });
819
+ }
820
+ },
821
+ {
822
+ kind: "exact",
823
+ path: GENERATE_API,
824
+ handler: async (req, res) => {
825
+ if (!guard(req, res, "POST")) return;
826
+ const body = await readJsonBody(req);
827
+ const parsed = body === void 0 ? void 0 : parseGenerateRequest(body);
828
+ if (parsed === void 0) {
829
+ writeJson(res, 200, {
830
+ ok: false,
831
+ code: "bad-request",
832
+ message: "prompt/text is required"
833
+ });
834
+ return;
835
+ }
836
+ const view = deps.resolveChannels();
837
+ const resolved = resolveChannelRequest(parsed, view);
838
+ if (!resolved.ok) {
839
+ writeJson(res, 200, {
840
+ ok: false,
841
+ code: resolved.code,
842
+ message: resolved.message
843
+ });
844
+ return;
845
+ }
846
+ const request = resolved.request;
847
+ const channel = view.channels.find((candidate) => candidate.id === request.channelId);
848
+ try {
849
+ const outputs = await generateAudio(channel, request);
850
+ const generated = [];
851
+ for (const [index, output] of outputs.entries()) {
852
+ const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
853
+ generated.push({
854
+ id: saved.id,
855
+ b64: Buffer.from(output.data).toString("base64"),
856
+ mime: saved.mime,
857
+ bytes: saved.bytes,
858
+ url: `${AUDIO_API.file}/${encodeURIComponent(saved.file)}`
859
+ });
860
+ }
861
+ let history;
862
+ try {
863
+ history = await appendHistory({
864
+ id: randomUUID(),
865
+ createdAt: Date.now(),
866
+ mode: request.mode,
867
+ model: request.model,
868
+ prompt: request.prompt,
869
+ ...request.voice === void 0 ? {} : { voice: request.voice },
870
+ ...request.speed === void 0 ? {} : { speed: request.speed },
871
+ ...request.duration === void 0 ? {} : { duration: request.duration },
872
+ ...request.format === void 0 ? {} : { format: request.format },
873
+ audio: generated,
874
+ ...request.channelId === void 0 ? {} : { channelId: request.channelId },
875
+ ...request.channel === void 0 ? {} : { channel: request.channel }
876
+ });
877
+ } catch (error) {
878
+ writeJson(res, 200, {
879
+ ok: true,
880
+ outputs: generated,
881
+ historyError: messageOf(error)
882
+ });
883
+ return;
884
+ }
885
+ writeJson(res, 200, {
886
+ ok: true,
887
+ outputs: generated,
888
+ history
889
+ });
890
+ } catch (error) {
891
+ writeJson(res, 200, {
892
+ ok: false,
893
+ code: error instanceof AudioGenError ? error.code : "generate-failed",
894
+ message: messageOf(error)
895
+ });
896
+ }
897
+ }
898
+ },
899
+ {
900
+ kind: "prefix",
901
+ path: AUDIO_API.file,
902
+ handler: async (req, res) => {
903
+ if (!isLoopbackRequest(req)) {
904
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
905
+ return;
906
+ }
907
+ if (req.method !== "GET") {
908
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
909
+ return;
910
+ }
911
+ const file = audioFileFrom(req.url, AUDIO_API.file);
912
+ if (file === void 0) {
913
+ writeJson(res, 400, { error: "invalid audio file" });
914
+ return;
915
+ }
916
+ const stored = await readAudioFile(file);
917
+ if (stored === void 0) {
918
+ writeJson(res, 404, { error: "audio not found" });
919
+ return;
920
+ }
921
+ res.writeHead(200, {
922
+ "content-type": stored.mime,
923
+ "content-length": stored.bytes,
924
+ "cache-control": "private, max-age=3600"
925
+ });
926
+ res.end(stored.data);
927
+ }
928
+ },
929
+ {
930
+ kind: "exact",
931
+ path: HISTORY_API.list,
932
+ handler: async (req, res) => {
933
+ if (!guard(req, res, "POST")) return;
934
+ writeJson(res, 200, {
935
+ ok: true,
936
+ history: await listHistory()
937
+ });
938
+ }
939
+ },
940
+ {
941
+ kind: "exact",
942
+ path: HISTORY_API.clear,
943
+ handler: async (req, res) => {
944
+ if (!guard(req, res, "POST")) return;
945
+ writeJson(res, 200, {
946
+ ok: true,
947
+ history: await clearHistory()
948
+ });
949
+ }
950
+ },
951
+ {
952
+ kind: "exact",
953
+ path: HISTORY_API.remove,
954
+ handler: async (req, res) => {
955
+ if (!guard(req, res, "POST")) return;
956
+ const body = await readJsonBody(req);
957
+ writeJson(res, 200, {
958
+ ok: true,
959
+ history: await removeHistory(typeof body?.id === "string" ? body.id : "")
960
+ });
961
+ }
962
+ },
963
+ {
964
+ kind: "prefix",
965
+ path: HISTORY_API.audio,
966
+ handler: async (req, res) => {
967
+ if (!isLoopbackRequest(req)) {
968
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
969
+ return;
970
+ }
971
+ if (req.method !== "GET") {
972
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
973
+ return;
974
+ }
975
+ const file = audioFileFrom(req.url, HISTORY_API.audio);
976
+ if (file === void 0) {
977
+ writeJson(res, 400, { error: "invalid audio file" });
978
+ return;
979
+ }
980
+ const stored = await readAudioFile(file);
981
+ if (stored === void 0) {
982
+ writeJson(res, 404, { error: "audio not found" });
983
+ return;
984
+ }
985
+ res.writeHead(200, {
986
+ "content-type": stored.mime,
987
+ "content-length": stored.bytes,
988
+ "cache-control": "private, max-age=3600"
989
+ });
990
+ res.end(stored.data);
991
+ }
992
+ }
993
+ ];
994
+ }
995
+ //#endregion
996
+ //#region src/agent-audio-tools.ts
997
+ const resultSchema = {
998
+ type: "object",
999
+ additionalProperties: false,
1000
+ properties: {
1001
+ status: {
1002
+ type: "string",
1003
+ required: true
1004
+ },
1005
+ message: {
1006
+ type: "string",
1007
+ required: true
1008
+ },
1009
+ mode: {
1010
+ type: "string",
1011
+ required: true,
1012
+ enum: [
1013
+ "tts",
1014
+ "music",
1015
+ "sfx"
1016
+ ]
1017
+ },
1018
+ model: {
1019
+ type: "string",
1020
+ required: true
1021
+ },
1022
+ audio: {
1023
+ type: "array",
1024
+ required: true,
1025
+ items: {
1026
+ type: "object",
1027
+ additionalProperties: false,
1028
+ properties: {
1029
+ id: {
1030
+ type: "string",
1031
+ required: true
1032
+ },
1033
+ url: {
1034
+ type: "string",
1035
+ required: true
1036
+ },
1037
+ mime: {
1038
+ type: "string",
1039
+ required: true
1040
+ },
1041
+ bytes: {
1042
+ type: "integer",
1043
+ required: true
1044
+ }
1045
+ }
1046
+ }
1047
+ },
1048
+ error: { type: "string" }
1049
+ }
1050
+ };
1051
+ function renderResult(value) {
1052
+ return [{
1053
+ type: "text",
1054
+ text: JSON.stringify(value)
1055
+ }];
1056
+ }
1057
+ function resolveModel(config, requested) {
1058
+ const entries = config.channels.flatMap((channel) => channel.models.map((model) => ({
1059
+ channel,
1060
+ alias: model.alias,
1061
+ upstream: model.id
1062
+ })));
1063
+ if (entries.length === 0) throw new AudioGenError("No audio models/voices are configured. Open Settings > Plugins > AI Audio and add at least one.", "no-models-configured");
1064
+ const wanted = typeof requested === "string" && requested.trim() !== "" ? requested.trim() : "";
1065
+ if (wanted === "") {
1066
+ if (entries.length === 1) return entries[0];
1067
+ throw new AudioGenError(`Multiple audio models/voices are available — ask the user which channel and model to use, then call again. Options: ${entries.map((entry) => `"${entry.channel.name} · ${entry.alias}"`).join(", ")}.`, "model-choice-required");
1068
+ }
1069
+ const hosting = entries.filter((entry) => entry.alias === wanted);
1070
+ if (hosting.length === 0) throw new AudioGenError(`Audio model/voice "${wanted}" is not configured. Choose one of: ${[...new Set(entries.map((entry) => entry.alias))].join(", ")}.`, "audio-model-not-configured");
1071
+ return hosting.find((entry) => entry.channel.id === config.defaultChannelId) ?? hosting[0];
1072
+ }
1073
+ function ensureConfigured(config) {
1074
+ if (!config.enabled) throw new AudioGenError("AI audio generation is disabled. Open Settings > Plugins > AI Audio and enable it.", "plugin-disabled");
1075
+ if (!config.allowAgentAudioGeneration) throw new AudioGenError("Agent audio generation is disabled in Settings > Plugins > AI Audio.", "agent-generation-disabled");
1076
+ if (!config.channels.some((channel) => channel.apiUrl.trim() !== "" && channel.apiKey.trim() !== "")) throw new AudioGenError("Audio API credentials are not configured. Open Settings > Plugins > AI Audio, add a channel and fill its API URL and API key.", "audio-api-not-configured");
1077
+ }
1078
+ /** Register the Agent audio tool. */
1079
+ function registerAgentAudioTools(ctx, resolve) {
1080
+ return ctx.tools.register(defineTool({
1081
+ name: "generate_audio",
1082
+ description: "Generate audio with the configured audio provider. Supports text-to-speech, music generation and sound effects. The tool call waits for the upstream result and returns same-origin audio URLs; pass those URLs to the user for playback or download. If multiple models are configured, first ask the user which one to use or pass model explicitly.",
1083
+ parameters: {
1084
+ prompt: {
1085
+ type: "string",
1086
+ required: true,
1087
+ description: "For tts, the text to speak. For music/sfx, a descriptive prompt."
1088
+ },
1089
+ mode: {
1090
+ type: "string",
1091
+ enum: [
1092
+ "tts",
1093
+ "music",
1094
+ "sfx"
1095
+ ],
1096
+ description: "Generation mode. Defaults to tts."
1097
+ },
1098
+ model: {
1099
+ type: "string",
1100
+ description: "One of the configured audio models/voices. Defaults to the first configured model."
1101
+ },
1102
+ voice: {
1103
+ type: "string",
1104
+ description: "Optional voice id/name for TTS providers."
1105
+ },
1106
+ speed: {
1107
+ type: "number",
1108
+ description: "Optional speaking rate / speed multiplier where supported."
1109
+ },
1110
+ duration: {
1111
+ type: "number",
1112
+ description: "Requested duration in seconds for music/sfx."
1113
+ },
1114
+ format: {
1115
+ type: "string",
1116
+ description: "Output format such as mp3 or wav."
1117
+ }
1118
+ },
1119
+ output: {
1120
+ schema: resultSchema,
1121
+ render: (_args, value) => renderResult(value)
1122
+ },
1123
+ timeoutMs: 3e5,
1124
+ isConcurrencySafe: () => true,
1125
+ async execute(args, exec) {
1126
+ const config = resolve();
1127
+ ensureConfigured(config);
1128
+ const picked = resolveModel(config, args.model);
1129
+ const request = {
1130
+ mode: args.mode === "music" ? "music" : args.mode === "sfx" ? "sfx" : "tts",
1131
+ model: picked.alias,
1132
+ upstream: picked.upstream,
1133
+ channelId: picked.channel.id,
1134
+ channel: picked.channel.name,
1135
+ prompt: args.prompt.trim(),
1136
+ ...typeof args.voice === "string" && args.voice.trim() !== "" ? { voice: args.voice.trim() } : {},
1137
+ ...typeof args.speed === "number" ? { speed: args.speed } : {},
1138
+ ...typeof args.duration === "number" ? { duration: args.duration } : {},
1139
+ ...typeof args.format === "string" && args.format.trim() !== "" ? { format: args.format.trim() } : {}
1140
+ };
1141
+ try {
1142
+ const outputs = await generateAudio(picked.channel, request, exec.signal);
1143
+ const audio = [];
1144
+ for (const [index, output] of outputs.entries()) {
1145
+ const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
1146
+ audio.push({
1147
+ id: saved.id,
1148
+ url: `/api/dsh-audiogen/audio/${encodeURIComponent(saved.file)}`,
1149
+ mime: saved.mime,
1150
+ bytes: saved.bytes
1151
+ });
1152
+ }
1153
+ try {
1154
+ await appendHistory({
1155
+ id: randomUUID(),
1156
+ createdAt: Date.now(),
1157
+ mode: request.mode,
1158
+ model: picked.alias,
1159
+ prompt: request.prompt,
1160
+ ...request.voice === void 0 ? {} : { voice: request.voice },
1161
+ ...request.speed === void 0 ? {} : { speed: request.speed },
1162
+ ...request.duration === void 0 ? {} : { duration: request.duration },
1163
+ ...request.format === void 0 ? {} : { format: request.format },
1164
+ audio: outputs.map((output, index) => ({
1165
+ id: audio[index].id,
1166
+ b64: Buffer.from(output.data).toString("base64"),
1167
+ mime: audio[index].mime,
1168
+ bytes: audio[index].bytes,
1169
+ url: audio[index].url
1170
+ })),
1171
+ channelId: picked.channel.id,
1172
+ channel: picked.channel.name
1173
+ });
1174
+ } catch {}
1175
+ return {
1176
+ status: "completed",
1177
+ message: "Audio generation completed. The audio files can be played/downloaded from the returned URLs.",
1178
+ mode: request.mode,
1179
+ model: picked.alias,
1180
+ audio
1181
+ };
1182
+ } catch (error) {
1183
+ if (exec.signal?.aborted === true) throw error;
1184
+ return {
1185
+ status: "failed",
1186
+ message: "Audio generation failed.",
1187
+ mode: request.mode,
1188
+ model: picked.alias,
1189
+ audio: [],
1190
+ error: error instanceof Error ? error.message : String(error)
1191
+ };
1192
+ }
1193
+ }
1194
+ }));
1195
+ }
1196
+ //#endregion
1197
+ //#region src/index.ts
1198
+ /** Stable cordis plugin name. */
1199
+ const name = "audiogen";
1200
+ /** Services required before the surfaces can mount. */
1201
+ const inject = ["webServer", "systemPrompt"];
1202
+ /** The branded settings namespace of this plugin. */
1203
+ const AudioGenSettingsNamespace = settingsNamespace(AUDIOGEN_SETTINGS_NAMESPACE);
1204
+ const Config = z.object({
1205
+ enabled: z.boolean().default(true),
1206
+ announceToAgent: z.boolean().default(true),
1207
+ allowAgentAudioGeneration: z.boolean().default(true),
1208
+ channels: z.array(z.object({
1209
+ id: z.string(),
1210
+ preset: z.string().default(""),
1211
+ name: z.string().default(""),
1212
+ apiUrl: z.string().default(""),
1213
+ models: z.array(z.object({
1214
+ alias: z.string(),
1215
+ id: z.string()
1216
+ })).default([])
1217
+ })).default([]),
1218
+ channelSecrets: z.dict(z.string().role("secret")).default({}),
1219
+ defaultChannelId: z.string().default(""),
1220
+ defaultModel: z.string().default("")
1221
+ });
1222
+ const DEFAULT_ENABLED = true;
1223
+ const DEFAULT_ANNOUNCE = true;
1224
+ const DEFAULT_ALLOW_AGENT_AUDIO = true;
1225
+ const SECTION_ORDER = 160;
1226
+ const AUDIOGEN_GUIDANCE = "本机已安装 dsh-audiogen 插件(DSH AI 音频):侧边栏「AI 音频」入口。能力:通过「渠道」对接多个音频生成厂商(OpenAI TTS、ElevenLabs、MiniMax、Stability Audio、自定义 OpenAI 兼容接口),支持 TTS 文本转语音、音乐生成和音效生成。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发。Agent 可直接调用 `generate_audio` 提交 TTS/音乐/音效任务,默认等待完成并返回同源音频 URL。限制:生成消耗上游 API 额度;音频内容由上游模型生成;模型只能使用用户在各渠道配置目录中的模型。用户提到「音频 / 语音 / TTS / 配乐 / 音效 / AI 音频」时即指本插件,请据此协作。";
1227
+ function guidanceFor(channels, defaultChannelId) {
1228
+ if (channels.length === 0) return `${AUDIOGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥。`;
1229
+ const table = channels.map((channel) => {
1230
+ const aliases = channel.models.map((model) => model.alias).join("、");
1231
+ const mark = channel.id === defaultChannelId ? "(默认渠道)" : "";
1232
+ const key = channel.apiKey === "" ? "(未填密钥)" : "";
1233
+ const models = channel.models.length === 0 ? "未配置模型/音色" : `可用模型/音色:${aliases}`;
1234
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`;
1235
+ }).join(";");
1236
+ return `${AUDIOGEN_GUIDANCE} 当前渠道与模型:${table}。`;
1237
+ }
1238
+ function normalizeChannels(value) {
1239
+ if (!Array.isArray(value)) return [];
1240
+ const out = [];
1241
+ for (const item of value) {
1242
+ if (item === null || typeof item !== "object") continue;
1243
+ const raw = item;
1244
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
1245
+ if (id === "") continue;
1246
+ const models = [];
1247
+ if (Array.isArray(raw.models)) for (const entry of raw.models) {
1248
+ if (entry === null || typeof entry !== "object") continue;
1249
+ const record = entry;
1250
+ const alias = typeof record.alias === "string" ? record.alias.trim() : "";
1251
+ const upstream = typeof record.id === "string" ? record.id.trim() : "";
1252
+ if (alias === "") continue;
1253
+ models.push({
1254
+ alias,
1255
+ id: upstream === "" ? alias : upstream
1256
+ });
1257
+ }
1258
+ out.push({
1259
+ id,
1260
+ preset: typeof raw.preset === "string" ? raw.preset : "",
1261
+ name: typeof raw.name === "string" ? raw.name.trim() : "",
1262
+ apiUrl: typeof raw.apiUrl === "string" ? raw.apiUrl.trim() : "",
1263
+ models
1264
+ });
1265
+ }
1266
+ return out;
1267
+ }
1268
+ function apply(ctx, config) {
1269
+ let current = () => config ?? {};
1270
+ const resolve = () => {
1271
+ const value = current() ?? {};
1272
+ const channels = normalizeChannels(value.channels);
1273
+ const secrets = { ...value.channelSecrets ?? {} };
1274
+ const named = channels.map((channel) => ({
1275
+ ...channel,
1276
+ name: channel.name === "" ? audioPresetById(channel.preset)?.name ?? "未命名渠道" : channel.name
1277
+ }));
1278
+ const defaultChannelId = typeof value.defaultChannelId === "string" && named.some((channel) => channel.id === value.defaultChannelId) ? value.defaultChannelId : named[0]?.id ?? "";
1279
+ return {
1280
+ enabled: value.enabled ?? DEFAULT_ENABLED,
1281
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
1282
+ allowAgentAudioGeneration: value.allowAgentAudioGeneration ?? DEFAULT_ALLOW_AGENT_AUDIO,
1283
+ channels: named.map((channel) => ({
1284
+ ...channel,
1285
+ apiKey: typeof secrets[channel.id] === "string" ? secrets[channel.id] : ""
1286
+ })),
1287
+ defaultChannelId,
1288
+ defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : ""
1289
+ };
1290
+ };
1291
+ const channelsView = () => {
1292
+ const value = resolve();
1293
+ return {
1294
+ channels: value.channels,
1295
+ defaultChannelId: value.defaultChannelId
1296
+ };
1297
+ };
1298
+ ctx.inject(["settings", "webServer"], (sctx) => {
1299
+ const seam = sctx.get("settings");
1300
+ sctx.effect(() => {
1301
+ const disposers = makeRoutes({
1302
+ settings: seam,
1303
+ resolveChannels: channelsView
1304
+ }).map((route) => ctx.webServer.register(route));
1305
+ return () => {
1306
+ for (const dispose of disposers) dispose();
1307
+ };
1308
+ }, "dsh-audiogen: routes");
1309
+ });
1310
+ ctx.inject(["tools"], (tctx) => {
1311
+ tctx.effect(() => registerAgentAudioTools(tctx, () => {
1312
+ const value = resolve();
1313
+ return {
1314
+ enabled: value.enabled,
1315
+ allowAgentAudioGeneration: value.allowAgentAudioGeneration,
1316
+ channels: value.channels,
1317
+ defaultChannelId: value.defaultChannelId
1318
+ };
1319
+ }), "dsh-audiogen: agent audio tools");
1320
+ });
1321
+ let disposeSection;
1322
+ const sync = () => {
1323
+ if (disposeSection !== void 0) {
1324
+ disposeSection();
1325
+ disposeSection = void 0;
1326
+ }
1327
+ const value = resolve();
1328
+ if (!value.enabled || !value.announceToAgent) return;
1329
+ disposeSection = ctx.systemPrompt.section({
1330
+ name: "plugin:dsh-audiogen",
1331
+ order: SECTION_ORDER,
1332
+ text: guidanceFor(value.channels, value.defaultChannelId)
1333
+ });
1334
+ };
1335
+ installSettingsSection(ctx, AudioGenSettingsNamespace, Config, config ?? {}, {
1336
+ setSource: (source) => {
1337
+ current = source;
1338
+ sync();
1339
+ },
1340
+ onChange: sync
1341
+ });
1342
+ sync();
1343
+ }
1344
+ //#endregion
1345
+ export { AUDIOGEN_GUIDANCE, AudioGenSettingsNamespace, Config, apply, inject, name };