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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Sidebar entry injection.
3
+ *
4
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
5
+ * so the entry row is injected at the DOM level after the shell's New Session
6
+ * button. A MutationObserver self-heals when React re-renders.
7
+ */
8
+
9
+ import type { AudioGenController } from './controller.ts'
10
+ import css from './panel.module.css'
11
+
12
+ export const ENTRY_SELECTOR = '[data-dsh-audiogen-entry]'
13
+
14
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3.5h10v9H3z"/><path d="M1.5 5.5v5"/><path d="M14.5 5.5v5"/><path d="M6 6.5l4 1.5-4 1.5z"/></svg>'
15
+
16
+ const FAMILY_ENTRY_SELECTOR = '[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-imagegen-entry], [data-dsh-audiogen-entry]'
17
+
18
+ function sidebarRoot(): HTMLElement | undefined {
19
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
20
+ if (column === null) return undefined
21
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
22
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
23
+ }
24
+
25
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
26
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
27
+ if (nested !== null) return nested
28
+ for (const child of root.children) {
29
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ function createEntry(controller: AudioGenController, label: string, tooltip: string): HTMLButtonElement {
35
+ const entry = document.createElement('button')
36
+ entry.type = 'button'
37
+ entry.dataset.dshAudiogenEntry = ''
38
+ entry.className = css.entry
39
+ entry.setAttribute('aria-label', label)
40
+ entry.setAttribute('title', tooltip)
41
+ entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + label + '</span>'
42
+ entry.addEventListener('click', () => { controller.toggle() })
43
+ return entry
44
+ }
45
+
46
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
47
+ const button = newSessionButton(root)
48
+ if (button === undefined) return false
49
+ if (entry.parentElement !== root) {
50
+ const row = button.closest('[class*="logoRow"]')
51
+ const base = (row !== null && row.parentElement === root) ? row : button
52
+ const family = Array.from(root.children).filter(
53
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_ENTRY_SELECTOR),
54
+ )
55
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
56
+ root.insertBefore(entry, anchor)
57
+ }
58
+ return true
59
+ }
60
+
61
+ export function mountSidebarEntry(controller: AudioGenController, label: string, tooltip: string): () => void {
62
+ const entry = createEntry(controller, label, tooltip)
63
+ let root: HTMLElement | undefined
64
+ let placed = false
65
+
66
+ const tryPlace = (): void => {
67
+ if (root !== undefined && !root.isConnected) {
68
+ rootObserver.disconnect()
69
+ root = undefined
70
+ placed = false
71
+ }
72
+ if (placed) {
73
+ if (document.body.contains(entry)) return
74
+ rootObserver.disconnect()
75
+ root = undefined
76
+ placed = false
77
+ }
78
+ root ??= sidebarRoot()
79
+ if (root === undefined) return
80
+ placed = placeEntry(root, entry)
81
+ if (placed) {
82
+ rootObserver.observe(root, { childList: true, subtree: true })
83
+ }
84
+ }
85
+
86
+ const waitObserver = new MutationObserver(() => { tryPlace() })
87
+ waitObserver.observe(document.body, { childList: true, subtree: true })
88
+
89
+ const rootObserver = new MutationObserver(() => {
90
+ if (root === undefined || !root.isConnected) {
91
+ placed = false
92
+ tryPlace()
93
+ return
94
+ }
95
+ if (!root.contains(entry)) {
96
+ placed = placeEntry(root, entry)
97
+ }
98
+ })
99
+
100
+ const syncActive = () => {
101
+ if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
102
+ else delete entry.dataset.active
103
+ }
104
+ const unsubscribe = controller.subscribe(syncActive)
105
+ syncActive()
106
+
107
+ tryPlace()
108
+
109
+ return () => {
110
+ waitObserver.disconnect()
111
+ rootObserver.disconnect()
112
+ unsubscribe()
113
+ entry.remove()
114
+ }
115
+ }
package/src/index.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * dsh-audiogen — host half.
3
+ *
4
+ * Mounts the plugin settings section (multi-provider audio channels), the
5
+ * /api/dsh-audiogen route family (settings bridge, presets, generation proxy,
6
+ * audio/history serving), and the Agent audio tool. The browser half
7
+ * (./client) renders the sidebar entry and the audio generation panel.
8
+ */
9
+
10
+ import type { Context } from '@deepseek-ai/cordis'
11
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
12
+ import z from 'schemastery'
13
+ import type {} from '@deepseek-ai/dsh-host-webserver'
14
+ import type {} from '@deepseek-ai/dsh-system-prompt'
15
+ import type {} from '@deepseek-ai/dsh-tools'
16
+ import { AUDIOGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
17
+ import { makeRoutes, type ChannelsView, type SettingsSeam } from './routes.ts'
18
+ import type { AudioChannel } from './audio-engine.ts'
19
+ import { registerAgentAudioTools, type AgentAudioToolConfig } from './agent-audio-tools.ts'
20
+ import { audioPresetById } from './audio-presets.ts'
21
+
22
+ /** Stable cordis plugin name. */
23
+ export const name = 'audiogen'
24
+
25
+ /** Services required before the surfaces can mount. */
26
+ export const inject = ['webServer', 'systemPrompt']
27
+
28
+ /** The branded settings namespace of this plugin. */
29
+ export const AudioGenSettingsNamespace = settingsNamespace(AUDIOGEN_SETTINGS_NAMESPACE)
30
+
31
+ export interface Config {
32
+ enabled?: boolean
33
+ announceToAgent?: boolean
34
+ allowAgentAudioGeneration?: boolean
35
+ channels?: ChannelConfig[]
36
+ channelSecrets?: Record<string, string>
37
+ defaultChannelId?: string
38
+ defaultModel?: string
39
+ }
40
+
41
+ export const Config: z<Config> = z.object({
42
+ enabled: z.boolean().default(true),
43
+ announceToAgent: z.boolean().default(true),
44
+ allowAgentAudioGeneration: z.boolean().default(true),
45
+ channels: z.array(z.object({
46
+ id: z.string(),
47
+ preset: z.string().default(''),
48
+ name: z.string().default(''),
49
+ apiUrl: z.string().default(''),
50
+ models: z.array(z.object({
51
+ alias: z.string(),
52
+ id: z.string(),
53
+ })).default([]),
54
+ })).default([]),
55
+ channelSecrets: z.dict(z.string().role('secret')).default({}),
56
+ defaultChannelId: z.string().default(''),
57
+ defaultModel: z.string().default(''),
58
+ })
59
+
60
+ const DEFAULT_ENABLED = true
61
+ const DEFAULT_ANNOUNCE = true
62
+ const DEFAULT_ALLOW_AGENT_AUDIO = true
63
+
64
+ const SECTION_ORDER = 160
65
+
66
+ export 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 音频」时即指本插件,请据此协作。'
67
+
68
+ function guidanceFor(channels: AudioChannel[], defaultChannelId: string): string {
69
+ if (channels.length === 0) {
70
+ return `${AUDIOGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥。`
71
+ }
72
+ const table = channels.map(channel => {
73
+ const aliases = channel.models.map(model => model.alias).join('、')
74
+ const mark = channel.id === defaultChannelId ? '(默认渠道)' : ''
75
+ const key = channel.apiKey === '' ? '(未填密钥)' : ''
76
+ const models = channel.models.length === 0 ? '未配置模型/音色' : `可用模型/音色:${aliases}`
77
+ return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`
78
+ }).join(';')
79
+ return `${AUDIOGEN_GUIDANCE} 当前渠道与模型:${table}。`
80
+ }
81
+
82
+ function normalizeChannels(value: unknown): ChannelConfig[] {
83
+ if (!Array.isArray(value)) return []
84
+ const out: ChannelConfig[] = []
85
+ for (const item of value) {
86
+ if (item === null || typeof item !== 'object') continue
87
+ const raw = item as Record<string, unknown>
88
+ const id = typeof raw.id === 'string' ? raw.id.trim() : ''
89
+ if (id === '') continue
90
+ const models: ModelMapping[] = []
91
+ if (Array.isArray(raw.models)) {
92
+ for (const entry of raw.models) {
93
+ if (entry === null || typeof entry !== 'object') continue
94
+ const record = entry as Record<string, unknown>
95
+ const alias = typeof record.alias === 'string' ? record.alias.trim() : ''
96
+ const upstream = typeof record.id === 'string' ? record.id.trim() : ''
97
+ if (alias === '') continue
98
+ models.push({ alias, id: upstream === '' ? alias : upstream })
99
+ }
100
+ }
101
+ out.push({
102
+ id,
103
+ preset: typeof raw.preset === 'string' ? raw.preset : '',
104
+ name: typeof raw.name === 'string' ? raw.name.trim() : '',
105
+ apiUrl: typeof raw.apiUrl === 'string' ? raw.apiUrl.trim() : '',
106
+ models,
107
+ })
108
+ }
109
+ return out
110
+ }
111
+
112
+ export interface EffectiveConfig {
113
+ enabled: boolean
114
+ announceToAgent: boolean
115
+ allowAgentAudioGeneration: boolean
116
+ channels: AudioChannel[]
117
+ defaultChannelId: string
118
+ defaultModel: string
119
+ }
120
+
121
+ export function apply(ctx: Context, config?: Config): void {
122
+ let current: () => Config = () => config ?? {}
123
+
124
+ const resolve = (): EffectiveConfig => {
125
+ const value = current() ?? {}
126
+ const channels = normalizeChannels(value.channels)
127
+ const secrets: Record<string, string> = { ...(value.channelSecrets ?? {}) }
128
+ const named = channels.map(channel => ({
129
+ ...channel,
130
+ name: channel.name === '' ? (audioPresetById(channel.preset)?.name ?? '未命名渠道') : channel.name,
131
+ }))
132
+ const defaultChannelId = typeof value.defaultChannelId === 'string' && named.some(channel => channel.id === value.defaultChannelId)
133
+ ? value.defaultChannelId
134
+ : named[0]?.id ?? ''
135
+ return {
136
+ enabled: value.enabled ?? DEFAULT_ENABLED,
137
+ announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
138
+ allowAgentAudioGeneration: value.allowAgentAudioGeneration ?? DEFAULT_ALLOW_AGENT_AUDIO,
139
+ channels: named.map(channel => ({
140
+ ...channel,
141
+ apiKey: typeof secrets[channel.id] === 'string' ? secrets[channel.id] : '',
142
+ })),
143
+ defaultChannelId,
144
+ defaultModel: typeof value.defaultModel === 'string' ? value.defaultModel.trim() : '',
145
+ }
146
+ }
147
+
148
+ const channelsView = (): ChannelsView => {
149
+ const value = resolve()
150
+ return { channels: value.channels, defaultChannelId: value.defaultChannelId }
151
+ }
152
+
153
+ ctx.inject(['settings', 'webServer'], (sctx) => {
154
+ const seam = sctx.get('settings') as unknown as SettingsSeam
155
+ sctx.effect(() => {
156
+ const routes = makeRoutes({
157
+ settings: seam,
158
+ resolveChannels: channelsView,
159
+ })
160
+ const disposers = routes.map(route => ctx.webServer.register(route))
161
+ return () => { for (const dispose of disposers) dispose() }
162
+ }, 'dsh-audiogen: routes')
163
+ })
164
+
165
+ ctx.inject(['tools'], (tctx) => {
166
+ tctx.effect(() => registerAgentAudioTools(tctx, (): AgentAudioToolConfig => {
167
+ const value = resolve()
168
+ return {
169
+ enabled: value.enabled,
170
+ allowAgentAudioGeneration: value.allowAgentAudioGeneration,
171
+ channels: value.channels,
172
+ defaultChannelId: value.defaultChannelId,
173
+ }
174
+ }), 'dsh-audiogen: agent audio tools')
175
+ })
176
+
177
+ let disposeSection: (() => void) | undefined
178
+ const sync = (): void => {
179
+ if (disposeSection !== undefined) {
180
+ disposeSection()
181
+ disposeSection = undefined
182
+ }
183
+ const value = resolve()
184
+ if (!value.enabled || !value.announceToAgent) return
185
+ disposeSection = ctx.systemPrompt.section({
186
+ name: 'plugin:dsh-audiogen',
187
+ order: SECTION_ORDER,
188
+ text: guidanceFor(value.channels, value.defaultChannelId),
189
+ })
190
+ }
191
+
192
+ installSettingsSection(ctx, AudioGenSettingsNamespace, Config, config ?? {}, {
193
+ setSource: (source) => {
194
+ current = source
195
+ sync()
196
+ },
197
+ onChange: sync,
198
+ })
199
+
200
+ sync()
201
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Wire contract shared by the host and client halves of dsh-audiogen:
3
+ * settings namespace, route paths, generate payload/result shapes.
4
+ * Pure types and constants — safe for the client bundle to inline.
5
+ */
6
+
7
+ /** Settings namespace this plugin owns (host settings seam + bridge). */
8
+ export const AUDIOGEN_SETTINGS_NAMESPACE = 'dsh-audiogen'
9
+
10
+ /** Published package version shared by the host updater and the client UI. */
11
+ export const PLUGIN_VERSION = '0.1.0'
12
+
13
+ /** Same-origin route family (loopback-only, mirroring dsh-imagegen). */
14
+ export const SETTINGS_API = {
15
+ describe: '/api/dsh-audiogen/settings/describe',
16
+ mutate: '/api/dsh-audiogen/settings/mutate',
17
+ } as const
18
+
19
+ /** The audio-generation proxy route. */
20
+ export const GENERATE_API = '/api/dsh-audiogen/generate' as const
21
+
22
+ /** Host-mediated built-in provider catalog (channels the user can instantiate). */
23
+ export const PRESETS_API = '/api/dsh-audiogen/presets' as const
24
+
25
+ /** Loopback-only audio file reader for panel/tool-result previews. */
26
+ export const AUDIO_API = {
27
+ file: '/api/dsh-audiogen/audio',
28
+ } as const
29
+
30
+ /** Host-persisted generation history routes. */
31
+ export const HISTORY_API = {
32
+ list: '/api/dsh-audiogen/history/list',
33
+ append: '/api/dsh-audiogen/history/append',
34
+ remove: '/api/dsh-audiogen/history/remove',
35
+ clear: '/api/dsh-audiogen/history/clear',
36
+ audio: '/api/dsh-audiogen/history/audio',
37
+ } as const
38
+
39
+ /** Maximum number of history entries retained host-side (oldest evicted). */
40
+ export const HISTORY_MAX = 50
41
+
42
+ /** Audio generation modes. */
43
+ export type AudioMode = 'tts' | 'music' | 'sfx'
44
+
45
+ /** One model mapping in a channel's catalog: display alias → upstream id. */
46
+ export interface ModelMapping {
47
+ /** User-facing model/voice name (defaults to the upstream id). */
48
+ alias: string
49
+ /** Upstream model or voice id sent to the provider. */
50
+ id: string
51
+ }
52
+
53
+ /**
54
+ * One configured audio channel (provider). Secrets never live here — the API
55
+ * key is stored at `channelSecrets.<channelId>`.
56
+ */
57
+ export interface ChannelConfig {
58
+ /** Stable channel id (the channelSecrets dict is keyed by it). */
59
+ id: string
60
+ /** Preset provider id this channel was created from ('' = custom). */
61
+ preset: string
62
+ /** Display name shown in the list, panel, and Agent guidance. */
63
+ name: string
64
+ /** Provider base URL. */
65
+ apiUrl: string
66
+ /** The channel's model/voice catalog (alias → upstream id). */
67
+ models: ModelMapping[]
68
+ }
69
+
70
+ /** One built-in provider as the settings card consumes it. */
71
+ export interface PresetProviderView {
72
+ id: string
73
+ name: string
74
+ apiUrl: string
75
+ hint: string
76
+ models: ModelMapping[]
77
+ }
78
+
79
+ /** A client → host generate request. */
80
+ export interface GenerateAudioRequest {
81
+ mode: AudioMode
82
+ /** User-facing model/voice alias; host maps to upstream id. */
83
+ model: string
84
+ /** TTS text or music/sfx prompt. */
85
+ prompt: string
86
+ /** Optional voice alias for TTS. */
87
+ voice?: string
88
+ /** Optional speaking rate / speed multiplier. */
89
+ speed?: number
90
+ /** Requested duration in seconds (music/sfx). */
91
+ duration?: number
92
+ /** Output format, e.g. mp3, wav, pcm. */
93
+ format?: string
94
+ /** Channel this request targets (host falls back to default). */
95
+ channelId?: string
96
+ /** Channel display name snapshot (host-filled). */
97
+ channel?: string
98
+ /** Upstream model id actually sent (host-filled from alias mapping). */
99
+ upstream?: string
100
+ }
101
+
102
+ /** One generated audio, normalized host-side to base64. */
103
+ export interface GeneratedAudio {
104
+ /** Raw base64 payload (no data: prefix). */
105
+ b64: string
106
+ /** MIME type, e.g. audio/mpeg. */
107
+ mime: string
108
+ /** Exact encoded byte length. */
109
+ bytes: number
110
+ /** Optional duration in seconds when the API reports one. */
111
+ duration?: number
112
+ /** Same-origin URL served by the host ('' when unavailable). */
113
+ url: string
114
+ /** Stable audio id / file name. */
115
+ id: string
116
+ }
117
+
118
+ /** Successful generate outcome. */
119
+ export interface GenerateAudioResult {
120
+ outputs: GeneratedAudio[]
121
+ /** Updated host-persisted history, when returned. */
122
+ history?: HistoryEntry[]
123
+ /** Persistence failure after audio was generated. */
124
+ historyError?: string
125
+ }
126
+
127
+ /** One audio reference as the browser consumes it. */
128
+ export interface HistoryAudioRef {
129
+ /** Same-origin URL. */
130
+ url: string
131
+ /** MIME type, e.g. audio/mpeg. */
132
+ mime: string
133
+ /** Duration in seconds when known. */
134
+ duration?: number
135
+ }
136
+
137
+ /** A saved generation as the browser consumes it. */
138
+ export interface HistoryEntry {
139
+ id: string
140
+ createdAt: number
141
+ mode: AudioMode
142
+ model: string
143
+ prompt: string
144
+ voice?: string
145
+ speed?: number
146
+ duration?: number
147
+ format?: string
148
+ audio: HistoryAudioRef[]
149
+ channelId?: string
150
+ channel?: string
151
+ }
152
+
153
+ /** A history entry the client submits for persistence (audio still carries base64). */
154
+ export interface HistoryEntryInput {
155
+ id: string
156
+ createdAt: number
157
+ mode: AudioMode
158
+ model: string
159
+ prompt: string
160
+ voice?: string
161
+ speed?: number
162
+ duration?: number
163
+ format?: string
164
+ audio: GeneratedAudio[]
165
+ channelId?: string
166
+ channel?: string
167
+ }
168
+
169
+ /** The plugin settings fields edited by the settings card and panel. */
170
+ export interface AudiogenConfig {
171
+ enabled?: boolean
172
+ announceToAgent?: boolean
173
+ allowAgentAudioGeneration?: boolean
174
+ channels?: ChannelConfig[]
175
+ channelSecrets?: Record<string, string>
176
+ defaultChannelId?: string
177
+ /** Optional default voice/model alias for quick generation. */
178
+ defaultModel?: string
179
+ }