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/src/routes.ts ADDED
@@ -0,0 +1,386 @@
1
+ /**
2
+ * The /api/dsh-audiogen route family:
3
+ * - a loopback-only settings bridge for the plugin's own namespace,
4
+ * - a presets route for the settings card,
5
+ * - the audio-generation proxy that keeps API keys host-side,
6
+ * - same-origin audio file serving and history persistence.
7
+ */
8
+
9
+ import type { IncomingMessage, ServerResponse } from 'node:http'
10
+ import { randomUUID } from 'node:crypto'
11
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
12
+ import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
+ import { generateAudio, AudioGenError, type AudioChannel } from './audio-engine.ts'
14
+ import { AUDIO_PRESETS } from './audio-presets.ts'
15
+ import { appendHistory, clearHistory, listHistory, readAudioFile, removeHistory, saveAudioFile } from './audio-store.ts'
16
+ import {
17
+ AUDIO_API, AUDIOGEN_SETTINGS_NAMESPACE, GENERATE_API, HISTORY_API, PRESETS_API, SETTINGS_API,
18
+ type GenerateAudioRequest, type GeneratedAudio, type HistoryEntryInput,
19
+ } from './protocol.ts'
20
+
21
+ const MAX_JSON_BODY_BYTES = 16 * 1024 * 1024
22
+
23
+ /** Settings seam face the bridge needs. */
24
+ export interface SettingsSeam {
25
+ describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
26
+ mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
27
+ readonly writable?: boolean
28
+ }
29
+
30
+ /** The channels view used by routes and the host plugin. */
31
+ export interface ChannelsView {
32
+ channels: AudioChannel[]
33
+ defaultChannelId: string
34
+ }
35
+
36
+ /** Route dependencies. */
37
+ export interface AudiogenRoutesDeps {
38
+ settings: SettingsSeam
39
+ resolveChannels: () => ChannelsView
40
+ }
41
+
42
+ function isLoopbackRequest(request: IncomingMessage): boolean {
43
+ const address = request.socket.remoteAddress
44
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
45
+ const host = request.headers.host
46
+ if (typeof host !== 'string') return false
47
+ let hostUrl: URL
48
+ try {
49
+ hostUrl = new URL(`http://${host}`)
50
+ } catch {
51
+ return false
52
+ }
53
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
54
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
55
+ const origin = request.headers.origin
56
+ if (origin === undefined) return true
57
+ try {
58
+ return new URL(origin).host === hostUrl.host
59
+ } catch {
60
+ return false
61
+ }
62
+ }
63
+
64
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
65
+ const payload = JSON.stringify(body)
66
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
67
+ res.end(payload)
68
+ }
69
+
70
+ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
71
+ const chunks: Buffer[] = []
72
+ let size = 0
73
+ for await (const chunk of req) {
74
+ const buffer = chunk as Buffer
75
+ size += buffer.length
76
+ if (size > maxBytes) return undefined
77
+ chunks.push(buffer)
78
+ }
79
+ try {
80
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
81
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+
87
+ function messageOf(error: unknown): string {
88
+ return error instanceof Error ? error.message : String(error)
89
+ }
90
+
91
+ function parseGenerateRequest(body: Record<string, unknown>): GenerateAudioRequest | undefined {
92
+ const mode = body.mode === 'music' ? 'music' : body.mode === 'sfx' ? 'sfx' : 'tts'
93
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
94
+ if (prompt === '') return undefined
95
+ return {
96
+ mode,
97
+ model: typeof body.model === 'string' ? body.model.trim() : '',
98
+ prompt,
99
+ ...(typeof body.voice === 'string' && body.voice.trim() !== '' ? { voice: body.voice.trim() } : {}),
100
+ ...(typeof body.speed === 'number' ? { speed: body.speed } : {}),
101
+ ...(typeof body.duration === 'number' ? { duration: body.duration } : {}),
102
+ ...(typeof body.format === 'string' && body.format.trim() !== '' ? { format: body.format.trim() } : {}),
103
+ ...(typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {}),
104
+ }
105
+ }
106
+
107
+ function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
108
+ return {
109
+ ns: String(descriptor.ns),
110
+ schema: descriptor.schema,
111
+ value: descriptor.value,
112
+ ...(descriptor.base === undefined ? {} : { base: descriptor.base }),
113
+ ...(descriptor.user === undefined ? {} : { user: descriptor.user }),
114
+ ...(descriptor.secrets === undefined ? {} : {
115
+ secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
116
+ }),
117
+ revision: descriptor.revision,
118
+ }
119
+ }
120
+
121
+ function failureOf(error: unknown): { ok: false; code: string; message: string } {
122
+ if (error instanceof SettingsConflictError) {
123
+ return { ok: false, code: 'settings-conflict', message: error.message }
124
+ }
125
+ return { ok: false, code: 'settings-rejected', message: error instanceof Error ? error.message : String(error) }
126
+ }
127
+
128
+ /**
129
+ * Resolve a requested model alias onto a concrete channel/upstream id.
130
+ */
131
+ function resolveChannelRequest(
132
+ request: GenerateAudioRequest,
133
+ view: ChannelsView,
134
+ ): { ok: true; request: GenerateAudioRequest } | { ok: false; code: string; message: string } {
135
+ if (view.channels.length === 0) {
136
+ return { ok: false, code: 'no-channels', message: '尚未配置任何渠道:请先在「设置 → 插件 → AI 音频」添加渠道并填写 API 地址与密钥' }
137
+ }
138
+ const explicit = view.channels.find(candidate => candidate.id === request.channelId)
139
+ const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
140
+ const target = explicit ?? defaults
141
+ const asked = request.model.trim()
142
+ if (asked === '') {
143
+ const alias = target?.models[0]?.alias ?? ''
144
+ if (alias === '') {
145
+ return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型/音色,请先在设置中添加` }
146
+ }
147
+ const mapping = target!.models.find(model => model.alias === alias)!
148
+ return { ok: true, request: { ...request, model: alias, upstream: mapping.id, channelId: target!.id, channel: target!.name } }
149
+ }
150
+ const hosting = view.channels.filter(channel => channel.models.some(model => model.alias === asked))
151
+ if (hosting.length === 0) {
152
+ const available = [...new Set(view.channels.flatMap(channel => channel.models.map(model => model.alias)))]
153
+ return { ok: false, code: 'audio-model-not-configured', message: `模型/音色「${asked}」未在任一渠道配置;可用:${available.join('、') || '(无)'}` }
154
+ }
155
+ const picked = target !== undefined && target.models.some(model => model.alias === asked) ? target : hosting[0]!
156
+ const mapping = picked.models.find(model => model.alias === asked)!
157
+ return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
158
+ }
159
+
160
+ /** Build every /api/dsh-audiogen route. */
161
+ export function makeRoutes(deps: AudiogenRoutesDeps): WebRoute[] {
162
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
163
+ if (!isLoopbackRequest(req)) {
164
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
165
+ return false
166
+ }
167
+ if (req.method !== method) {
168
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
169
+ return false
170
+ }
171
+ return true
172
+ }
173
+
174
+ const audioFileFrom = (rawUrl: string | undefined, basePath: string): string | undefined => {
175
+ if (rawUrl === undefined) return undefined
176
+ let pathname: string
177
+ try {
178
+ pathname = new URL(rawUrl, 'http://localhost').pathname
179
+ } catch {
180
+ return undefined
181
+ }
182
+ if (!pathname.startsWith(`${basePath}/`)) return undefined
183
+ return decodeURIComponent(pathname.slice(basePath.length + 1))
184
+ }
185
+
186
+ return [
187
+ // ---------------------------------------------------------- presets
188
+ {
189
+ kind: 'exact',
190
+ path: PRESETS_API,
191
+ handler: async (req, res) => {
192
+ if (!guard(req, res, 'POST')) return
193
+ writeJson(res, 200, { ok: true, presets: AUDIO_PRESETS })
194
+ },
195
+ },
196
+ // -------------------------------------------------- settings describe
197
+ {
198
+ kind: 'exact',
199
+ path: SETTINGS_API.describe,
200
+ handler: async (req, res) => {
201
+ if (!guard(req, res, 'POST')) return
202
+ const descriptor = deps.settings.describe({ redactSecrets: true })
203
+ .find(candidate => String(candidate.ns) === AUDIOGEN_SETTINGS_NAMESPACE)
204
+ writeJson(res, 200, {
205
+ ok: true,
206
+ value: {
207
+ namespaces: descriptor === undefined ? [] : [toView(descriptor)],
208
+ writable: deps.settings.writable !== false,
209
+ },
210
+ })
211
+ },
212
+ },
213
+ // ----------------------------------------------------- settings mutate
214
+ {
215
+ kind: 'exact',
216
+ path: SETTINGS_API.mutate,
217
+ handler: async (req, res) => {
218
+ if (!guard(req, res, 'POST')) return
219
+ const body = await readJsonBody(req)
220
+ if (body === undefined) {
221
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
222
+ return
223
+ }
224
+ const ns = typeof body.ns === 'string' ? body.ns : ''
225
+ if (ns !== AUDIOGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
226
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
227
+ return
228
+ }
229
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
230
+ try {
231
+ await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
232
+ } catch (error) {
233
+ writeJson(res, 200, failureOf(error))
234
+ return
235
+ }
236
+ const descriptor = deps.settings.describe({ redactSecrets: true })
237
+ .find(candidate => String(candidate.ns) === ns)
238
+ if (descriptor === undefined) {
239
+ writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
240
+ return
241
+ }
242
+ writeJson(res, 200, { ok: true, value: toView(descriptor) })
243
+ },
244
+ },
245
+ // ----------------------------------------------------------- generate
246
+ {
247
+ kind: 'exact',
248
+ path: GENERATE_API,
249
+ handler: async (req, res) => {
250
+ if (!guard(req, res, 'POST')) return
251
+ const body = await readJsonBody(req)
252
+ const parsed = body === undefined ? undefined : parseGenerateRequest(body)
253
+ if (parsed === undefined) {
254
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt/text is required' })
255
+ return
256
+ }
257
+ const view = deps.resolveChannels()
258
+ const resolved = resolveChannelRequest(parsed, view)
259
+ if (!resolved.ok) {
260
+ writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
261
+ return
262
+ }
263
+ const request = resolved.request
264
+ const channel = view.channels.find(candidate => candidate.id === request.channelId)!
265
+ try {
266
+ const outputs = await generateAudio(channel, request)
267
+ const generated: GeneratedAudio[] = []
268
+ for (const [index, output] of outputs.entries()) {
269
+ const saved = await saveAudioFile(output.data, output.mime, `generated-${index + 1}`)
270
+ generated.push({
271
+ id: saved.id,
272
+ b64: Buffer.from(output.data).toString('base64'),
273
+ mime: saved.mime,
274
+ bytes: saved.bytes,
275
+ url: `${AUDIO_API.file}/${encodeURIComponent(saved.file)}`,
276
+ })
277
+ }
278
+ let history
279
+ try {
280
+ history = await appendHistory({
281
+ id: randomUUID(),
282
+ createdAt: Date.now(),
283
+ mode: request.mode,
284
+ model: request.model,
285
+ prompt: request.prompt,
286
+ ...(request.voice === undefined ? {} : { voice: request.voice }),
287
+ ...(request.speed === undefined ? {} : { speed: request.speed }),
288
+ ...(request.duration === undefined ? {} : { duration: request.duration }),
289
+ ...(request.format === undefined ? {} : { format: request.format }),
290
+ audio: generated,
291
+ ...(request.channelId === undefined ? {} : { channelId: request.channelId }),
292
+ ...(request.channel === undefined ? {} : { channel: request.channel }),
293
+ })
294
+ } catch (error) {
295
+ writeJson(res, 200, { ok: true, outputs: generated, historyError: messageOf(error) })
296
+ return
297
+ }
298
+ writeJson(res, 200, { ok: true, outputs: generated, history })
299
+ } catch (error) {
300
+ const code = error instanceof AudioGenError ? error.code : 'generate-failed'
301
+ writeJson(res, 200, { ok: false, code, message: messageOf(error) })
302
+ }
303
+ },
304
+ },
305
+ // ----------------------------------------------------------- audio file
306
+ {
307
+ kind: 'prefix',
308
+ path: AUDIO_API.file,
309
+ handler: async (req, res) => {
310
+ if (!isLoopbackRequest(req)) {
311
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
312
+ return
313
+ }
314
+ if (req.method !== 'GET') {
315
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
316
+ return
317
+ }
318
+ const file = audioFileFrom(req.url, AUDIO_API.file)
319
+ if (file === undefined) {
320
+ writeJson(res, 400, { error: 'invalid audio file' })
321
+ return
322
+ }
323
+ const stored = await readAudioFile(file)
324
+ if (stored === undefined) {
325
+ writeJson(res, 404, { error: 'audio not found' })
326
+ return
327
+ }
328
+ res.writeHead(200, {
329
+ 'content-type': stored.mime,
330
+ 'content-length': stored.bytes,
331
+ 'cache-control': 'private, max-age=3600',
332
+ })
333
+ res.end(stored.data)
334
+ },
335
+ },
336
+ // ------------------------------------------------------- history
337
+ {
338
+ kind: 'exact', path: HISTORY_API.list,
339
+ handler: async (req, res) => {
340
+ if (!guard(req, res, 'POST')) return
341
+ writeJson(res, 200, { ok: true, history: await listHistory() })
342
+ },
343
+ },
344
+ {
345
+ kind: 'exact', path: HISTORY_API.clear,
346
+ handler: async (req, res) => {
347
+ if (!guard(req, res, 'POST')) return
348
+ writeJson(res, 200, { ok: true, history: await clearHistory() })
349
+ },
350
+ },
351
+ {
352
+ kind: 'exact', path: HISTORY_API.remove,
353
+ handler: async (req, res) => {
354
+ if (!guard(req, res, 'POST')) return
355
+ const body = await readJsonBody(req)
356
+ const id = typeof body?.id === 'string' ? body.id : ''
357
+ writeJson(res, 200, { ok: true, history: await removeHistory(id) })
358
+ },
359
+ },
360
+ {
361
+ kind: 'prefix', path: HISTORY_API.audio,
362
+ handler: async (req, res) => {
363
+ if (!isLoopbackRequest(req)) {
364
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
365
+ return
366
+ }
367
+ if (req.method !== 'GET') {
368
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
369
+ return
370
+ }
371
+ const file = audioFileFrom(req.url, HISTORY_API.audio)
372
+ if (file === undefined) {
373
+ writeJson(res, 400, { error: 'invalid audio file' })
374
+ return
375
+ }
376
+ const stored = await readAudioFile(file)
377
+ if (stored === undefined) {
378
+ writeJson(res, 404, { error: 'audio not found' })
379
+ return
380
+ }
381
+ res.writeHead(200, { 'content-type': stored.mime, 'content-length': stored.bytes, 'cache-control': 'private, max-age=3600' })
382
+ res.end(stored.data)
383
+ },
384
+ },
385
+ ]
386
+ }