dsh-audiogen 0.4.1 → 0.4.2

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
@@ -22,6 +22,8 @@ const SETTINGS_API = {
22
22
  };
23
23
  /** The audio-generation proxy route. */
24
24
  const GENERATE_API = "/api/dsh-audiogen/generate";
25
+ /** Loopback-only task cancellation route (aborts the host-side upstream call). */
26
+ const TASK_API = { cancel: "/api/dsh-audiogen/task/cancel" };
25
27
  /** Host-mediated built-in provider catalog (channels the user can instantiate). */
26
28
  const PRESETS_API = "/api/dsh-audiogen/presets";
27
29
  /** Host-mediated model/voice discovery endpoint. */
@@ -52,6 +54,63 @@ const LIBRARY_TYPES = [
52
54
  "tts"
53
55
  ];
54
56
  //#endregion
57
+ //#region src/audio-scheduler.ts
58
+ function createGenerationBudget(limit) {
59
+ let active = 0;
60
+ const waiting = [];
61
+ const clampLimit = () => {
62
+ const raw = Number(limit());
63
+ if (!Number.isFinite(raw) || raw < 1) return 5;
64
+ return Math.min(20, Math.floor(raw));
65
+ };
66
+ const pump = () => {
67
+ const max = clampLimit();
68
+ while (active < max && waiting.length > 0) {
69
+ const entry = waiting.shift();
70
+ if (entry.signal?.aborted === true) {
71
+ entry.reject(new DOMException("The operation was aborted.", "AbortError"));
72
+ continue;
73
+ }
74
+ entry.cleanup?.();
75
+ active += 1;
76
+ let released = false;
77
+ entry.resolve(() => {
78
+ if (released) return;
79
+ released = true;
80
+ active = Math.max(0, active - 1);
81
+ pump();
82
+ });
83
+ }
84
+ };
85
+ const acquire = (signal) => new Promise((resolve, reject) => {
86
+ const entry = {
87
+ resolve,
88
+ reject,
89
+ signal
90
+ };
91
+ const onAbort = () => {
92
+ const index = waiting.indexOf(entry);
93
+ if (index < 0) return;
94
+ waiting.splice(index, 1);
95
+ entry.cleanup = void 0;
96
+ reject(new DOMException("The operation was aborted.", "AbortError"));
97
+ };
98
+ entry.cleanup = () => {
99
+ signal?.removeEventListener("abort", onAbort);
100
+ };
101
+ if (signal !== void 0) {
102
+ if (signal.aborted === true) {
103
+ reject(new DOMException("The operation was aborted.", "AbortError"));
104
+ return;
105
+ }
106
+ signal.addEventListener("abort", onAbort, { once: true });
107
+ }
108
+ waiting.push(entry);
109
+ pump();
110
+ });
111
+ return { acquire };
112
+ }
113
+ //#endregion
55
114
  //#region src/audio-engine.ts
56
115
  /** An audio generation failure with a user-presentable message. */
57
116
  var AudioGenError = class extends Error {
@@ -1462,6 +1521,8 @@ const LIBRARY_TYPES_VALID = [
1462
1521
  //#endregion
1463
1522
  //#region src/routes.ts
1464
1523
  const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024;
1524
+ /** 宿主侧任务取消注册表:taskId → 该任务当前在途请求的 AbortController 集合。 */
1525
+ const taskAborts = /* @__PURE__ */ new Map();
1465
1526
  function isLoopbackRequest(request) {
1466
1527
  const address = request.socket.remoteAddress;
1467
1528
  if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
@@ -1865,8 +1926,21 @@ function makeRoutes(deps) {
1865
1926
  }
1866
1927
  const request = resolved.request;
1867
1928
  const channel = view.channels.find((candidate) => candidate.id === request.channelId);
1929
+ const taskId = typeof body?.taskId === "string" && body.taskId.trim() !== "" ? body.taskId.trim() : "";
1930
+ const controller = new AbortController();
1931
+ if (taskId !== "") {
1932
+ const set = taskAborts.get(taskId) ?? /* @__PURE__ */ new Set();
1933
+ set.add(controller);
1934
+ taskAborts.set(taskId, set);
1935
+ }
1868
1936
  try {
1869
- const outputs = await generateAudio(channel, request);
1937
+ const release = await deps.budget.acquire(controller.signal);
1938
+ let outputs;
1939
+ try {
1940
+ outputs = await generateAudio(channel, request, controller.signal);
1941
+ } finally {
1942
+ release();
1943
+ }
1870
1944
  const generated = [];
1871
1945
  for (const [index, output] of outputs.entries()) {
1872
1946
  const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`);
@@ -1937,7 +2011,39 @@ function makeRoutes(deps) {
1937
2011
  code: error instanceof AudioGenError ? error.code : "generate-failed",
1938
2012
  message: messageOf(error)
1939
2013
  });
2014
+ } finally {
2015
+ if (taskId !== "") {
2016
+ const set = taskAborts.get(taskId);
2017
+ set?.delete(controller);
2018
+ if (set !== void 0 && set.size === 0) taskAborts.delete(taskId);
2019
+ }
2020
+ }
2021
+ }
2022
+ },
2023
+ {
2024
+ kind: "exact",
2025
+ path: TASK_API.cancel,
2026
+ handler: async (req, res) => {
2027
+ if (!guard(req, res, "POST")) return;
2028
+ const body = await readJsonBody(req);
2029
+ const taskId = typeof body?.taskId === "string" ? body.taskId.trim() : "";
2030
+ if (taskId === "") {
2031
+ writeJson(res, 200, {
2032
+ ok: false,
2033
+ code: "bad-request",
2034
+ message: "taskId is required"
2035
+ });
2036
+ return;
1940
2037
  }
2038
+ const controllers = taskAborts.get(taskId);
2039
+ if (controllers !== void 0) {
2040
+ for (const controller of controllers) controller.abort();
2041
+ taskAborts.delete(taskId);
2042
+ }
2043
+ writeJson(res, 200, {
2044
+ ok: true,
2045
+ aborted: controllers !== void 0 ? controllers.size : 0
2046
+ });
1941
2047
  }
1942
2048
  },
1943
2049
  {
@@ -2620,7 +2726,13 @@ function registerAgentAudioTools(ctx, resolve) {
2620
2726
  const runOne = async (picked) => {
2621
2727
  const request = buildRequest(picked);
2622
2728
  try {
2623
- const outputs = await generateAudio(picked.channel, request, exec.signal);
2729
+ const release = await (config.budget?.acquire(exec.signal) ?? Promise.resolve(() => {}));
2730
+ let outputs;
2731
+ try {
2732
+ outputs = await generateAudio(picked.channel, request, exec.signal);
2733
+ } finally {
2734
+ release();
2735
+ }
2624
2736
  const audio = [];
2625
2737
  const saved = [];
2626
2738
  for (const [index, output] of outputs.entries()) {
@@ -2904,6 +3016,7 @@ const name = "audiogen";
2904
3016
  const inject = ["webServer", "systemPrompt"];
2905
3017
  /** The branded settings namespace of this plugin. */
2906
3018
  const AudioGenSettingsNamespace = settingsNamespace(AUDIOGEN_SETTINGS_NAMESPACE);
3019
+ const DEFAULT_MAX_CONCURRENT = 5;
2907
3020
  const Config = z.object({
2908
3021
  enabled: z.boolean().default(true),
2909
3022
  announceToAgent: z.boolean().default(true),
@@ -2921,7 +3034,8 @@ const Config = z.object({
2921
3034
  channelSecrets: z.dict(z.string().role("secret")).default({}),
2922
3035
  defaultChannelId: z.string().default(""),
2923
3036
  defaultModel: z.string().default(""),
2924
- autoSaveToLibrary: z.boolean().default(false)
3037
+ autoSaveToLibrary: z.boolean().default(false),
3038
+ maxConcurrentGenerations: z.number().default(DEFAULT_MAX_CONCURRENT)
2925
3039
  });
2926
3040
  const DEFAULT_ENABLED = true;
2927
3041
  const DEFAULT_ANNOUNCE = true;
@@ -3014,9 +3128,11 @@ function apply(ctx, config) {
3014
3128
  })),
3015
3129
  defaultChannelId,
3016
3130
  defaultModel: typeof value.defaultModel === "string" ? value.defaultModel.trim() : "",
3017
- autoSaveToLibrary: value.autoSaveToLibrary === true
3131
+ autoSaveToLibrary: value.autoSaveToLibrary === true,
3132
+ maxConcurrentGenerations: typeof value.maxConcurrentGenerations === "number" && Number.isFinite(value.maxConcurrentGenerations) ? Math.max(1, Math.min(20, Math.floor(value.maxConcurrentGenerations))) : DEFAULT_MAX_CONCURRENT
3018
3133
  };
3019
3134
  };
3135
+ const budget = createGenerationBudget(() => resolve().maxConcurrentGenerations);
3020
3136
  const channelsView = () => {
3021
3137
  const value = resolve();
3022
3138
  return {
@@ -3030,7 +3146,8 @@ function apply(ctx, config) {
3030
3146
  const disposers = makeRoutes({
3031
3147
  settings: seam,
3032
3148
  resolveChannels: channelsView,
3033
- autoSave: () => resolve().autoSaveToLibrary
3149
+ autoSave: () => resolve().autoSaveToLibrary,
3150
+ budget
3034
3151
  }).map((route) => ctx.webServer.register(route));
3035
3152
  return () => {
3036
3153
  for (const dispose of disposers) dispose();
@@ -3045,7 +3162,8 @@ function apply(ctx, config) {
3045
3162
  allowAgentAudioGeneration: value.allowAgentAudioGeneration,
3046
3163
  channels: value.channels,
3047
3164
  defaultChannelId: value.defaultChannelId,
3048
- autoSaveToLibrary: value.autoSaveToLibrary
3165
+ autoSaveToLibrary: value.autoSaveToLibrary,
3166
+ budget
3049
3167
  };
3050
3168
  }), "dsh-audiogen: agent audio tools");
3051
3169
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-audiogen",
3
3
  "description": "AI audio generation plugin for the dsh web GUI: multi-vendor TTS/music/sound-effect channels (OpenAI-compatible, ElevenLabs, MiniMax, Stability AI and custom), per-channel model/voice catalogs, Agent tool and a sidebar AI 音频 panel.",
4
- "version": "0.4.1",
4
+ "version": "0.4.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { AudioChannel } from './audio-engine.ts'
11
11
  import { generateAudio, AudioGenError } from './audio-engine.ts'
12
+ import type { GenerationBudget } from './audio-scheduler.ts'
12
13
  import { appendHistory, saveAudioFile, saveToLibrary, listLibrary } from './audio-store.ts'
13
14
  import type { AudioMode, GenerateAudioRequest, LibraryType } from './protocol.ts'
14
15
 
@@ -18,6 +19,8 @@ export interface AgentAudioToolConfig {
18
19
  channels: AudioChannel[]
19
20
  defaultChannelId: string
20
21
  autoSaveToLibrary: boolean
22
+ /** 全局并发闸门(与面板路由共享「最大并发生成数」)。 */
23
+ budget?: GenerationBudget
21
24
  }
22
25
 
23
26
  interface AgentAudioRef {
@@ -300,7 +303,14 @@ export function registerAgentAudioTools(ctx: Context, resolve: () => AgentAudioT
300
303
  const runOne = async (picked: { channel: AudioChannel; alias: string; upstream: string }): Promise<AgentAudioGroup> => {
301
304
  const request = buildRequest(picked)
302
305
  try {
303
- const outputs = await generateAudio(picked.channel, request, exec.signal)
306
+ // 与面板路由共享全局并发闸门(限流时排队;取消时立即出队)。
307
+ const release = await (config.budget?.acquire(exec.signal) ?? Promise.resolve(() => { /* 默认不限制 */ }))
308
+ let outputs
309
+ try {
310
+ outputs = await generateAudio(picked.channel, request, exec.signal)
311
+ } finally {
312
+ release()
313
+ }
304
314
  const audio: AgentAudioRef[] = []
305
315
  const saved: SavedAudioRef[] = []
306
316
  for (const [index, output] of outputs.entries()) {
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Global generation budget: a FIFO semaphore shared by every upstream audio
3
+ * call (panel routes and the Agent tool). The limit comes from the plugin
4
+ * setting 「最大并发生成数」(default 5): a 3-model compare task takes 3 slots,
5
+ * other tasks queue or run concurrently up to the same cap.
6
+ *
7
+ * Acquire resolves with a release function once a slot is free; aborting the
8
+ * signal while queued rejects immediately (no slot is occupied).
9
+ */
10
+
11
+ export interface GenerationBudget {
12
+ /** Wait for a free slot; resolves with the release function. */
13
+ acquire(signal?: AbortSignal): Promise<() => void>
14
+ }
15
+
16
+ export function createGenerationBudget(limit: () => number): GenerationBudget {
17
+ let active = 0
18
+ const waiting: Array<{
19
+ resolve: (release: () => void) => void
20
+ reject: (reason: unknown) => void
21
+ signal?: AbortSignal
22
+ cleanup?: () => void
23
+ }> = []
24
+
25
+ const clampLimit = (): number => {
26
+ const raw = Number(limit())
27
+ if (!Number.isFinite(raw) || raw < 1) return 5
28
+ return Math.min(20, Math.floor(raw))
29
+ }
30
+
31
+ const pump = (): void => {
32
+ const max = clampLimit()
33
+ while (active < max && waiting.length > 0) {
34
+ const entry = waiting.shift()!
35
+ if (entry.signal?.aborted === true) {
36
+ entry.reject(new DOMException('The operation was aborted.', 'AbortError'))
37
+ continue
38
+ }
39
+ entry.cleanup?.()
40
+ active += 1
41
+ let released = false
42
+ entry.resolve(() => {
43
+ if (released) return
44
+ released = true
45
+ active = Math.max(0, active - 1)
46
+ pump()
47
+ })
48
+ }
49
+ }
50
+
51
+ const acquire = (signal?: AbortSignal): Promise<() => void> => new Promise<() => void>((resolve, reject) => {
52
+ const entry: { resolve: (release: () => void) => void; reject: (reason: unknown) => void; signal?: AbortSignal; cleanup?: () => void } = { resolve, reject, signal }
53
+ const onAbort = (): void => {
54
+ const index = waiting.indexOf(entry)
55
+ if (index < 0) return // 已在运行:由调用方的 signal 中断上游请求
56
+ waiting.splice(index, 1)
57
+ entry.cleanup = undefined
58
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
59
+ }
60
+ entry.cleanup = () => { signal?.removeEventListener('abort', onAbort) }
61
+ if (signal !== undefined) {
62
+ if (signal.aborted === true) {
63
+ reject(new DOMException('The operation was aborted.', 'AbortError'))
64
+ return
65
+ }
66
+ signal.addEventListener('abort', onAbort, { once: true })
67
+ }
68
+ waiting.push(entry)
69
+ pump()
70
+ })
71
+
72
+ return { acquire }
73
+ }
@@ -28,6 +28,7 @@ export interface AudioGenSettings {
28
28
  allowAgentAudioGeneration?: boolean
29
29
  defaultModel?: string
30
30
  autoSaveToLibrary?: boolean
31
+ maxConcurrentGenerations?: number
31
32
  }
32
33
 
33
34
  export interface AudioGenSettingsCardState extends CardShell {
@@ -37,6 +38,7 @@ export interface AudioGenSettingsCardState extends CardShell {
37
38
  allowAgentAudioGeneration: CardFieldState
38
39
  defaultModel: CardFieldState
39
40
  autoSaveToLibrary: CardFieldState
41
+ maxConcurrentGenerations: CardFieldState
40
42
  }
41
43
 
42
44
  export interface AudioGenSettingsCardFace extends CardActions {
@@ -57,6 +59,7 @@ export class AudioGenSettingsCardController {
57
59
  booleanField('allowAgentAudioGeneration'),
58
60
  textField('defaultModel'),
59
61
  booleanField('autoSaveToLibrary'),
62
+ textField('maxConcurrentGenerations'),
60
63
  ])
61
64
  this.channelsForm = new ChannelsForm(scope)
62
65
  }
@@ -72,6 +75,7 @@ export class AudioGenSettingsCardController {
72
75
  allowAgentAudioGeneration: this.form.field('allowAgentAudioGeneration'),
73
76
  defaultModel: this.form.field('defaultModel'),
74
77
  autoSaveToLibrary: this.form.field('autoSaveToLibrary'),
78
+ maxConcurrentGenerations: this.form.field('maxConcurrentGenerations'),
75
79
  }
76
80
  }
77
81
 
@@ -723,6 +727,20 @@ export function AudioGenSettingsCard(props: AudioGenSettingsCardProps) {
723
727
  <input type="checkbox" checked={state.autoSaveToLibrary.text === 'true'} disabled={!state.writable} onChange={event => props.edit('autoSaveToLibrary', String(event.target.checked))} /> {t('settings.autoSaveLibrary')}
724
728
  </label>
725
729
  </div>
730
+ <div className={css.field}>
731
+ <label className={css.label}>
732
+ <span>{t('settings.maxConcurrent')}</span>
733
+ <input
734
+ type="number"
735
+ min="1"
736
+ max="20"
737
+ className={css.input}
738
+ value={state.maxConcurrentGenerations.text}
739
+ disabled={!state.writable}
740
+ onChange={event => props.edit('maxConcurrentGenerations', event.target.value)}
741
+ />
742
+ </label>
743
+ </div>
726
744
  <div className={css.footer}>
727
745
  {state.failed ? <p className={css.failed}>保存失败</p> : null}
728
746
  <button type="button" className={css.discard} disabled={!state.dirty || state.saving} onClick={() => props.discard()}>{t('settings.discard')}</button>
package/src/client/api.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import {
7
- GENERATE_API, HISTORY_API, LIBRARY_API,
7
+ GENERATE_API, HISTORY_API, LIBRARY_API, TASK_API,
8
8
  type GenerateAudioRequest, type GeneratedAudio, type HistoryEntry,
9
9
  type LibraryEntry, type LibrarySaveRequest, type LibraryUpdateRequest,
10
10
  } from '../protocol.ts'
@@ -20,59 +20,58 @@ export interface GenerateResponse {
20
20
  message?: string
21
21
  }
22
22
 
23
+ /** POST helper: the host API requires the JSON content type on every POST. */
24
+ function postJson(path: string, body: unknown, signal?: AbortSignal): Promise<Response> {
25
+ return fetch(path, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json' },
28
+ body: JSON.stringify(body),
29
+ ...(signal === undefined ? {} : { signal }),
30
+ })
31
+ }
32
+
23
33
  export class AudiogenApi {
24
- async generate(request: GenerateAudioRequest): Promise<GenerateResponse> {
25
- const response = await fetch(GENERATE_API, {
26
- method: 'POST',
27
- headers: { 'content-type': 'application/json' },
28
- body: JSON.stringify(request),
29
- })
34
+ async generate(request: GenerateAudioRequest, signal?: AbortSignal): Promise<GenerateResponse> {
35
+ const response = await postJson(GENERATE_API, { ...request, taskId: request.taskId }, signal)
30
36
  const body = await response.json() as GenerateResponse
31
37
  return body
32
38
  }
33
39
 
40
+ /** 取消进行中的任务:宿主侧中断全部在途上游调用,剩余模型跳过。 */
41
+ async cancelTask(taskId: string): Promise<void> {
42
+ await postJson(TASK_API.cancel, { taskId }).catch(() => { /* best-effort */ })
43
+ }
44
+
34
45
  async history(): Promise<HistoryEntry[]> {
35
- const response = await fetch(HISTORY_API.list, { method: 'POST' })
46
+ const response = await postJson(HISTORY_API.list, {})
36
47
  const body = await response.json() as { ok?: boolean; history?: HistoryEntry[] }
37
48
  return body.ok === true ? (body.history ?? []) : []
38
49
  }
39
50
 
40
51
  async clearHistory(): Promise<void> {
41
- await fetch(HISTORY_API.clear, { method: 'POST' })
52
+ await postJson(HISTORY_API.clear, {})
42
53
  }
43
54
 
44
55
  async libraryList(): Promise<LibraryEntry[]> {
45
- const response = await fetch(LIBRARY_API.list, { method: 'POST' })
56
+ const response = await postJson(LIBRARY_API.list, {})
46
57
  const body = await response.json() as { ok?: boolean; entries?: LibraryEntry[] }
47
58
  return body.ok === true ? (body.entries ?? []) : []
48
59
  }
49
60
 
50
61
  async librarySave(request: LibrarySaveRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
51
- const response = await fetch(LIBRARY_API.save, {
52
- method: 'POST',
53
- headers: { 'content-type': 'application/json' },
54
- body: JSON.stringify(request),
55
- })
62
+ const response = await postJson(LIBRARY_API.save, request)
56
63
  const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
57
64
  return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
58
65
  }
59
66
 
60
67
  async libraryUpdate(request: LibraryUpdateRequest): Promise<{ ok: boolean; entry?: LibraryEntry; message?: string }> {
61
- const response = await fetch(LIBRARY_API.update, {
62
- method: 'POST',
63
- headers: { 'content-type': 'application/json' },
64
- body: JSON.stringify(request),
65
- })
68
+ const response = await postJson(LIBRARY_API.update, request)
66
69
  const body = await response.json() as { ok?: boolean; entry?: LibraryEntry; message?: string }
67
70
  return { ok: body.ok === true, ...(body.entry === undefined ? {} : { entry: body.entry }), ...(body.message === undefined ? {} : { message: body.message }) }
68
71
  }
69
72
 
70
73
  async libraryRemove(ids: string[]): Promise<{ ok: boolean }> {
71
- const response = await fetch(LIBRARY_API.remove, {
72
- method: 'POST',
73
- headers: { 'content-type': 'application/json' },
74
- body: JSON.stringify({ ids }),
75
- })
74
+ const response = await postJson(LIBRARY_API.remove, { ids })
76
75
  const body = await response.json() as { ok?: boolean }
77
76
  return { ok: body.ok === true }
78
77
  }