dsh-audiogen 0.4.0 → 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/src/index.ts CHANGED
@@ -9,11 +9,15 @@
9
9
 
10
10
  import type { Context } from '@deepseek-ai/cordis'
11
11
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import { existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { dirname, join } from 'node:path'
12
15
  import z from 'schemastery'
13
16
  import type {} from '@deepseek-ai/dsh-host-webserver'
14
17
  import type {} from '@deepseek-ai/dsh-system-prompt'
15
18
  import type {} from '@deepseek-ai/dsh-tools'
16
19
  import { AUDIOGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
20
+ import { createGenerationBudget } from './audio-scheduler.ts'
17
21
  import { makeRoutes, type ChannelsView, type SettingsSeam } from './routes.ts'
18
22
  import type { AudioChannel } from './audio-engine.ts'
19
23
  import { registerAgentAudioTools, type AgentAudioToolConfig } from './agent-audio-tools.ts'
@@ -37,8 +41,11 @@ export interface Config {
37
41
  defaultChannelId?: string
38
42
  defaultModel?: string
39
43
  autoSaveToLibrary?: boolean
44
+ maxConcurrentGenerations?: number
40
45
  }
41
46
 
47
+ const DEFAULT_MAX_CONCURRENT = 5
48
+
42
49
  export const Config: z<Config> = z.object({
43
50
  enabled: z.boolean().default(true),
44
51
  announceToAgent: z.boolean().default(true),
@@ -57,6 +64,7 @@ export const Config: z<Config> = z.object({
57
64
  defaultChannelId: z.string().default(''),
58
65
  defaultModel: z.string().default(''),
59
66
  autoSaveToLibrary: z.boolean().default(false),
67
+ maxConcurrentGenerations: z.number().default(DEFAULT_MAX_CONCURRENT),
60
68
  })
61
69
 
62
70
  const DEFAULT_ENABLED = true
@@ -81,6 +89,34 @@ function guidanceFor(channels: AudioChannel[], defaultChannelId: string): string
81
89
  return `${AUDIOGEN_GUIDANCE} 当前渠道与模型:${table}。`
82
90
  }
83
91
 
92
+ /**
93
+ * 把随包分发的技能(skills/<id>/SKILL.md,含 frontmatter)同步到 DSH 用户技能根
94
+ * `~/.dsh/skills/<id>/SKILL.md` —— DSH web 会话的 skill-filesystem(standard 等
95
+ * preset 行)会扫描用户根,使会话可直接触发这些技能。仅创建缺失文件,绝不覆盖
96
+ * 用户已有内容;任何失败仅告警,不影响插件本身。
97
+ */
98
+ function syncBundledSkills(): void {
99
+ try {
100
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)))
101
+ const sourceRoot = join(packageRoot, 'skills')
102
+ if (existsSync(sourceRoot) !== true) return
103
+ const dshHome = process.env.DSH_HOME ?? join(process.env.HOME ?? '', '.dsh')
104
+ const targetRoot = join(dshHome, 'skills')
105
+ for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
106
+ if (entry.isDirectory() !== true) continue
107
+ const sourceFile = join(sourceRoot, entry.name, 'SKILL.md')
108
+ if (existsSync(sourceFile) !== true) continue
109
+ const targetDir = join(targetRoot, entry.name)
110
+ const targetFile = join(targetDir, 'SKILL.md')
111
+ if (existsSync(targetFile)) continue
112
+ mkdirSync(targetDir, { recursive: true })
113
+ copyFileSync(sourceFile, targetFile)
114
+ }
115
+ } catch {
116
+ // 技能同步为最佳努力:失败不阻断插件启动。
117
+ }
118
+ }
119
+
84
120
  function normalizeChannels(value: unknown): ChannelConfig[] {
85
121
  if (!Array.isArray(value)) return []
86
122
  const out: ChannelConfig[] = []
@@ -119,9 +155,11 @@ export interface EffectiveConfig {
119
155
  defaultChannelId: string
120
156
  defaultModel: string
121
157
  autoSaveToLibrary: boolean
158
+ maxConcurrentGenerations: number
122
159
  }
123
160
 
124
161
  export function apply(ctx: Context, config?: Config): void {
162
+ syncBundledSkills()
125
163
  let current: () => Config = () => config ?? {}
126
164
 
127
165
  const resolve = (): EffectiveConfig => {
@@ -146,9 +184,15 @@ export function apply(ctx: Context, config?: Config): void {
146
184
  defaultChannelId,
147
185
  defaultModel: typeof value.defaultModel === 'string' ? value.defaultModel.trim() : '',
148
186
  autoSaveToLibrary: value.autoSaveToLibrary === true,
187
+ maxConcurrentGenerations: typeof value.maxConcurrentGenerations === 'number' && Number.isFinite(value.maxConcurrentGenerations)
188
+ ? Math.max(1, Math.min(20, Math.floor(value.maxConcurrentGenerations)))
189
+ : DEFAULT_MAX_CONCURRENT,
149
190
  }
150
191
  }
151
192
 
193
+ // 全局并发闸门:所有上游调用(面板路由 + Agent 工具)共享「最大并发生成数」。
194
+ const budget = createGenerationBudget(() => resolve().maxConcurrentGenerations)
195
+
152
196
  const channelsView = (): ChannelsView => {
153
197
  const value = resolve()
154
198
  return { channels: value.channels, defaultChannelId: value.defaultChannelId }
@@ -161,6 +205,7 @@ export function apply(ctx: Context, config?: Config): void {
161
205
  settings: seam,
162
206
  resolveChannels: channelsView,
163
207
  autoSave: () => resolve().autoSaveToLibrary,
208
+ budget,
164
209
  })
165
210
  const disposers = routes.map(route => ctx.webServer.register(route))
166
211
  return () => { for (const dispose of disposers) dispose() }
@@ -176,6 +221,7 @@ export function apply(ctx: Context, config?: Config): void {
176
221
  channels: value.channels,
177
222
  defaultChannelId: value.defaultChannelId,
178
223
  autoSaveToLibrary: value.autoSaveToLibrary,
224
+ budget,
179
225
  }
180
226
  }), 'dsh-audiogen: agent audio tools')
181
227
  })
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '0.4.0'
11
+ export const PLUGIN_VERSION = '0.4.2'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
14
  export const SETTINGS_API = {
@@ -19,6 +19,11 @@ export const SETTINGS_API = {
19
19
  /** The audio-generation proxy route. */
20
20
  export const GENERATE_API = '/api/dsh-audiogen/generate' as const
21
21
 
22
+ /** Loopback-only task cancellation route (aborts the host-side upstream call). */
23
+ export const TASK_API = {
24
+ cancel: '/api/dsh-audiogen/task/cancel',
25
+ } as const
26
+
22
27
  /** Host-mediated built-in provider catalog (channels the user can instantiate). */
23
28
  export const PRESETS_API = '/api/dsh-audiogen/presets' as const
24
29
 
@@ -154,6 +159,8 @@ export interface GenerateAudioRequest {
154
159
  channel?: string
155
160
  /** Upstream model id actually sent (host-filled from alias mapping). */
156
161
  upstream?: string
162
+ /** 客户端任务 id:宿主按 taskId 聚合 AbortController 支持真取消。 */
163
+ taskId?: string
157
164
 
158
165
  // ---- MiniMax TTS 专属字段(其他厂商渠道忽略)——————
159
166
  /** MiniMax 音色情绪,如 happy/sad/angry/nervous/fearful/bored;默认按音色自身。 */
@@ -372,4 +379,6 @@ export interface AudiogenConfig {
372
379
  defaultModel?: string
373
380
  /** 生成完成后自动加入资源库(面板与 Agent 生成均生效;单次可取消勾选)。 */
374
381
  autoSaveToLibrary?: boolean
382
+ /** 最大并发生成数(同时打到上游的请求数,含对比任务内多模型)。默认 5。 */
383
+ maxConcurrentGenerations?: number
375
384
  }
package/src/routes.ts CHANGED
@@ -11,17 +11,21 @@ import { randomUUID } from 'node:crypto'
11
11
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
12
12
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
13
  import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
14
+ import type { GenerationBudget } from './audio-scheduler.ts'
14
15
  import { discoverAudioModels } from './audio-models.ts'
15
16
  import { AUDIO_PRESETS } from './audio-presets.ts'
16
17
  import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile, listLibrary, saveToLibrary, updateLibraryEntry, removeLibraryEntries, readLibraryFile } from './audio-store.ts'
17
18
  import {
18
- AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, LIBRARY_API, MODEL_API, PRESETS_API, SETTINGS_API,
19
+ AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, LIBRARY_API, MODEL_API, PRESETS_API, SETTINGS_API, TASK_API,
19
20
  LIBRARY_TYPES,
20
21
  type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput, type LibraryAudioInput, type LibraryProvenance, type LibraryType,
21
22
  } from './protocol.ts'
22
23
 
23
24
  const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
24
25
 
26
+ /** 宿主侧任务取消注册表:taskId → 该任务当前在途请求的 AbortController 集合。 */
27
+ const taskAborts = new Map<string, Set<AbortController>>()
28
+
25
29
  /** Settings seam face the bridge needs. */
26
30
  export interface SettingsSeam {
27
31
  describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
@@ -41,6 +45,8 @@ export interface AudiogenRoutesDeps {
41
45
  resolveChannels: () => ChannelsView
42
46
  /** Whether the auto-save-to-library setting is on. */
43
47
  autoSave: () => boolean
48
+ /** Global upstream concurrency gate (maxConcurrentGenerations). */
49
+ budget: GenerationBudget
44
50
  }
45
51
 
46
52
  function isLoopbackRequest(request: IncomingMessage): boolean {
@@ -416,8 +422,24 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
416
422
  }
417
423
  const request = resolved.request
418
424
  const channel = view.channels.find(candidate => candidate.id === request.channelId)!
425
+ // 任务取消支持:同一 taskId 可能对应多个并行子请求(对比任务),
426
+ // 宿主侧各自持有 AbortController,取消端点统一 abort。
427
+ const taskId = typeof body?.taskId === 'string' && body.taskId.trim() !== '' ? body.taskId.trim() : ''
428
+ const controller = new AbortController()
429
+ if (taskId !== '') {
430
+ const set = taskAborts.get(taskId) ?? new Set<AbortController>()
431
+ set.add(controller)
432
+ taskAborts.set(taskId, set)
433
+ }
419
434
  try {
420
- const outputs = await generateAudio(channel, request)
435
+ // 全局并发闸门:与 Agent 工具共享「最大并发生成数」上限。
436
+ const release = await deps.budget.acquire(controller.signal)
437
+ let outputs
438
+ try {
439
+ outputs = await generateAudio(channel, request, controller.signal)
440
+ } finally {
441
+ release()
442
+ }
421
443
  const generated: GeneratedAudio[] = []
422
444
  for (const [index, output] of outputs.entries()) {
423
445
  const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
@@ -477,7 +499,33 @@ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
477
499
  } catch (error) {
478
500
  const code = error instanceof AudioGenError ? error.code : 'generate-failed'
479
501
  writeJson(res, 200, { ok: false, code, message: messageOf(error) })
502
+ } finally {
503
+ if (taskId !== '') {
504
+ const set = taskAborts.get(taskId)
505
+ set?.delete(controller)
506
+ if (set !== undefined && set.size === 0) taskAborts.delete(taskId)
507
+ }
508
+ }
509
+ },
510
+ },
511
+ // ---------------------------------------------------------- task cancel
512
+ {
513
+ kind: 'exact',
514
+ path: TASK_API.cancel,
515
+ handler: async (req, res) => {
516
+ if (!guard(req, res, 'POST')) return
517
+ const body = await readJsonBody(req)
518
+ const taskId = typeof body?.taskId === 'string' ? body.taskId.trim() : ''
519
+ if (taskId === '') {
520
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'taskId is required' })
521
+ return
522
+ }
523
+ const controllers = taskAborts.get(taskId)
524
+ if (controllers !== undefined) {
525
+ for (const controller of controllers) controller.abort()
526
+ taskAborts.delete(taskId)
480
527
  }
528
+ writeJson(res, 200, { ok: true, aborted: controllers !== undefined ? controllers.size : 0 })
481
529
  },
482
530
  },
483
531
  // ----------------------------------------------------------- audio file