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/LICENSE +201 -0
- package/README.md +67 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2457 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1345 -0
- package/package.json +93 -0
- package/skills/design/SKILL.md +13 -0
- package/skills/music/SKILL.md +16 -0
- package/skills/sfx/SKILL.md +16 -0
- package/skills/tts/SKILL.md +18 -0
- package/src/agent-audio-tools.ts +190 -0
- package/src/audio-engine.ts +377 -0
- package/src/audio-presets.ts +80 -0
- package/src/audio-store.ts +131 -0
- package/src/client/AudioGenPanel.tsx +206 -0
- package/src/client/SettingsCard.tsx +337 -0
- package/src/client/api.ts +36 -0
- package/src/client/audio-panel.module.css +198 -0
- package/src/client/audio-toolview.module.css +69 -0
- package/src/client/audio-toolview.tsx +119 -0
- package/src/client/channels-form.ts +263 -0
- package/src/client/controller.ts +44 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/helpers.ts +27 -0
- package/src/client/index.ts +103 -0
- package/src/client/locales.ts +133 -0
- package/src/client/mount.tsx +96 -0
- package/src/client/panel.module.css +1566 -0
- package/src/client/settings-card.module.css +1023 -0
- package/src/client/settings-form.ts +336 -0
- package/src/client/settings-scope.ts +289 -0
- package/src/client/sidebar-entry.ts +115 -0
- package/src/index.ts +201 -0
- package/src/protocol.ts +179 -0
- package/src/routes.ts +386 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged form model behind the plugin settings card. A card stages what the
|
|
3
|
+
* user types and writes it only when they save — the settings write is a
|
|
4
|
+
* durable, revision-fenced document mutation, so staging keeps what is on
|
|
5
|
+
* screen exactly what a save would store. Self-contained slice of the same
|
|
6
|
+
* pattern the dsh-web-ui family cards use (this package must not depend on a
|
|
7
|
+
* sibling UI package).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createSnapshotStore, type SettingsScope, type SettingsScopeSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
|
+
|
|
12
|
+
/** The write one field's staged text performs when the card is saved. */
|
|
13
|
+
export type FieldWrite =
|
|
14
|
+
| { kind: 'set'; value: unknown }
|
|
15
|
+
| { kind: 'clear' }
|
|
16
|
+
|
|
17
|
+
/** How one field converts between its stored value and its draft text. */
|
|
18
|
+
export interface FieldSpec {
|
|
19
|
+
/** Field name inside the namespace section. */
|
|
20
|
+
field: string
|
|
21
|
+
/** Render a stored value as draft text; the empty string when the section carries none. */
|
|
22
|
+
format: (value: unknown) => string
|
|
23
|
+
/**
|
|
24
|
+
* The write this draft text stages, or undefined when the text is not a
|
|
25
|
+
* value this field accepts — which blocks the save rather than discarding it.
|
|
26
|
+
*/
|
|
27
|
+
parse: (text: string) => FieldWrite | undefined
|
|
28
|
+
/**
|
|
29
|
+
* True for secret fields (role('secret') in the namespace schema): the
|
|
30
|
+
* redacted wire view never returns the stored value, so the form treats an
|
|
31
|
+
* empty draft as "no change" and judges writes by the namespace's secrets
|
|
32
|
+
* sidecar instead of the user layer.
|
|
33
|
+
*/
|
|
34
|
+
secret?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One field as the card renders it. */
|
|
38
|
+
export interface FieldState {
|
|
39
|
+
/** Draft text the control renders. */
|
|
40
|
+
text: string
|
|
41
|
+
/** Whether saving would leave a user-layer entry for this field. */
|
|
42
|
+
overridden: boolean
|
|
43
|
+
/** Whether the draft is not a value this field accepts, which blocks saving. */
|
|
44
|
+
invalid: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Form state every plugin settings card shares. */
|
|
48
|
+
export interface CardShell {
|
|
49
|
+
/** False while the namespace is still loading; the card renders nothing. */
|
|
50
|
+
available: boolean
|
|
51
|
+
/** Whether the namespace is actually served (the bridge answered). */
|
|
52
|
+
exposed: boolean
|
|
53
|
+
/** Whether the Host document accepts writes. */
|
|
54
|
+
writable: boolean
|
|
55
|
+
/** Whether the form holds edits that a save would write. */
|
|
56
|
+
dirty: boolean
|
|
57
|
+
/** Whether any staged draft is invalid, which blocks the save. */
|
|
58
|
+
invalid: boolean
|
|
59
|
+
/** Whether a save is crossing the wire. */
|
|
60
|
+
saving: boolean
|
|
61
|
+
/** Whether the last save did not land as staged; cleared by the next edit or save. */
|
|
62
|
+
failed: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The write actions the card's slot entry injects. */
|
|
66
|
+
export interface CardActions {
|
|
67
|
+
/** Stage draft text for one field. */
|
|
68
|
+
edit: (field: string, text: string) => void
|
|
69
|
+
/** Stage a clear, so saving lets the field re-inherit the composition layer. */
|
|
70
|
+
resetField: (field: string) => void
|
|
71
|
+
/** Write every staged edit, then re-seed from what the Host accepted. */
|
|
72
|
+
save: () => void
|
|
73
|
+
/** Drop every staged edit. */
|
|
74
|
+
discard: () => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One field's staged edit. */
|
|
78
|
+
interface StagedEdit {
|
|
79
|
+
/** Draft text the control renders. */
|
|
80
|
+
text: string
|
|
81
|
+
/** True when this edit clears the field whatever text it shows. */
|
|
82
|
+
clear: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One staged edit resolved into the write a save performs. */
|
|
86
|
+
interface PlannedWrite {
|
|
87
|
+
/** Field this entry writes. */
|
|
88
|
+
field: string
|
|
89
|
+
/** Perform the write and report whether the Host holds the staged value afterwards. */
|
|
90
|
+
run: (() => Promise<boolean>) | undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A free-text field. An empty draft clears the field. */
|
|
94
|
+
export function textField(field: string): FieldSpec {
|
|
95
|
+
return {
|
|
96
|
+
field,
|
|
97
|
+
format: value => typeof value === 'string' ? value : '',
|
|
98
|
+
parse: (text) => {
|
|
99
|
+
const trimmed = text.trim()
|
|
100
|
+
return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed }
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A newline/comma-separated model list, persisted as a normalized string array. */
|
|
106
|
+
export function stringListField(field: string): FieldSpec {
|
|
107
|
+
return {
|
|
108
|
+
field,
|
|
109
|
+
format: value => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').join('\n') : '',
|
|
110
|
+
parse: (text) => {
|
|
111
|
+
const values = [...new Set(text.split(/[\n,]/).map(item => item.trim()).filter(Boolean))]
|
|
112
|
+
return values.length === 0 ? { kind: 'clear' } : { kind: 'set', value: values }
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A boolean field, edited through true/false draft text. */
|
|
118
|
+
export function booleanField(field: string): FieldSpec {
|
|
119
|
+
return {
|
|
120
|
+
field,
|
|
121
|
+
format: value => typeof value === 'boolean' ? String(value) : '',
|
|
122
|
+
parse: (text) => {
|
|
123
|
+
if (text === 'true') return { kind: 'set', value: true }
|
|
124
|
+
if (text === 'false') return { kind: 'set', value: false }
|
|
125
|
+
return undefined
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A secret field (role('secret') in the namespace schema). The stored value is
|
|
132
|
+
* never rendered or returned by the redacted wire view, so:
|
|
133
|
+
* - an empty draft means "no change" (typing nothing must never clear an
|
|
134
|
+
* invisible stored key); the dedicated clear action stages an explicit clear;
|
|
135
|
+
* - a write's outcome is judged by the namespace's secrets sidecar through
|
|
136
|
+
* the {@link CardForm} `secretSettled` hook, never by the user layer.
|
|
137
|
+
*/
|
|
138
|
+
export function secretField(field: string): FieldSpec {
|
|
139
|
+
return {
|
|
140
|
+
field,
|
|
141
|
+
secret: true,
|
|
142
|
+
format: () => '',
|
|
143
|
+
parse: (text) => {
|
|
144
|
+
const trimmed = text.trim()
|
|
145
|
+
if (trimmed === '') return undefined
|
|
146
|
+
return { kind: 'set', value: trimmed }
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stages one card's edits over one settings scope and writes them on save.
|
|
153
|
+
*
|
|
154
|
+
* The Host is the only authority on whether a value was accepted — its
|
|
155
|
+
* validators own the constraints no schema can express — so the outcome is
|
|
156
|
+
* read back from the section rather than predicted here. A save that did not
|
|
157
|
+
* land keeps its drafts, so the user can correct them instead of retyping.
|
|
158
|
+
*/
|
|
159
|
+
export class CardForm<T> {
|
|
160
|
+
private readonly specs: Map<string, FieldSpec>
|
|
161
|
+
private readonly staged = new Map<string, StagedEdit>()
|
|
162
|
+
private readonly listeners = new Set<() => void>()
|
|
163
|
+
private saving = false
|
|
164
|
+
private failed = false
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param scope - the bound settings scope for this card's namespace.
|
|
168
|
+
* @param specs - the fields this card edits.
|
|
169
|
+
* @param options.secretSettled - for secret fields, whether the namespace
|
|
170
|
+
* currently holds a stored secret (the redacted view never round-trips the
|
|
171
|
+
* value, so a write's outcome is read from the secrets sidecar instead).
|
|
172
|
+
*/
|
|
173
|
+
constructor(
|
|
174
|
+
private readonly scope: SettingsScope<T>,
|
|
175
|
+
specs: FieldSpec[],
|
|
176
|
+
private readonly options: { secretSettled?: (field: string) => boolean } = {},
|
|
177
|
+
) {
|
|
178
|
+
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
179
|
+
scope.subscribe(() => { this.publish() })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
|
|
183
|
+
bind<S>(project: () => S): SnapshotStore<S> {
|
|
184
|
+
const store = createSnapshotStore(project())
|
|
185
|
+
this.listeners.add(() => { store.set(project()) })
|
|
186
|
+
return store
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Read the card-level state: what the Host serves, and what a save would do. */
|
|
190
|
+
shell(): CardShell {
|
|
191
|
+
const snapshot = this.scope.getSnapshot()
|
|
192
|
+
const plan = this.plan()
|
|
193
|
+
return {
|
|
194
|
+
available: snapshot.status !== 'loading',
|
|
195
|
+
exposed: snapshot.status === 'ready',
|
|
196
|
+
writable: snapshot.writable,
|
|
197
|
+
dirty: plan.length > 0,
|
|
198
|
+
invalid: plan.some(item => item.run === undefined),
|
|
199
|
+
saving: this.saving,
|
|
200
|
+
failed: this.failed,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Read one field's state from the effective section and its staged draft. */
|
|
205
|
+
field(field: string): FieldState {
|
|
206
|
+
const spec = this.specOf(field)
|
|
207
|
+
const staged = this.staged.get(field)
|
|
208
|
+
if (staged === undefined) {
|
|
209
|
+
return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false }
|
|
210
|
+
}
|
|
211
|
+
const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text)
|
|
212
|
+
return {
|
|
213
|
+
text: staged.text,
|
|
214
|
+
overridden: write?.kind === 'set',
|
|
215
|
+
// A secret field's empty draft is "no change" (see secretField), never
|
|
216
|
+
// an invalid state — the stored value is invisible, so the user cannot
|
|
217
|
+
// be expected to type over it.
|
|
218
|
+
invalid: write === undefined && !(spec.secret === true && staged.text.trim() === ''),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The actions the card's slot registration injects. */
|
|
223
|
+
actions(): CardActions {
|
|
224
|
+
return {
|
|
225
|
+
edit: (field, text) => { this.stage(field, { text, clear: false }) },
|
|
226
|
+
resetField: (field) => {
|
|
227
|
+
this.stage(field, { text: this.specOf(field).format(this.baseValue(field)), clear: true })
|
|
228
|
+
},
|
|
229
|
+
save: () => { void this.save() },
|
|
230
|
+
discard: () => {
|
|
231
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
232
|
+
this.staged.clear()
|
|
233
|
+
this.failed = false
|
|
234
|
+
this.publish()
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Write every staged edit, then re-seed from what the Host accepted.
|
|
241
|
+
* @returns settlement after every write and the read-back.
|
|
242
|
+
*/
|
|
243
|
+
async save(): Promise<void> {
|
|
244
|
+
const plan = this.plan()
|
|
245
|
+
const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
|
|
246
|
+
if (plan.length === 0 || this.saving || writes.length !== plan.length) return
|
|
247
|
+
this.saving = true
|
|
248
|
+
this.failed = false
|
|
249
|
+
this.publish()
|
|
250
|
+
let landed = true
|
|
251
|
+
for (const write of writes) {
|
|
252
|
+
landed = await write() && landed
|
|
253
|
+
}
|
|
254
|
+
if (landed) this.staged.clear()
|
|
255
|
+
this.saving = false
|
|
256
|
+
this.failed = !landed
|
|
257
|
+
this.publish()
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Every staged edit a save would write. An entry whose draft is not a value
|
|
262
|
+
* its field accepts carries no write: the form is still dirty, and the save
|
|
263
|
+
* refuses rather than dropping the edit. A staged edit that matches the
|
|
264
|
+
* effective section is not a write at all.
|
|
265
|
+
*/
|
|
266
|
+
private plan(): PlannedWrite[] {
|
|
267
|
+
const plan: PlannedWrite[] = []
|
|
268
|
+
for (const [field, staged] of this.staged) {
|
|
269
|
+
const spec = this.specOf(field)
|
|
270
|
+
if (staged.clear) {
|
|
271
|
+
const present = spec.secret === true
|
|
272
|
+
? (this.options.secretSettled?.(field) ?? false)
|
|
273
|
+
: this.stored(field)
|
|
274
|
+
if (present) plan.push({ field, run: () => this.clear(field) })
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
if (staged.text === spec.format(this.sectionValue(field))) continue
|
|
278
|
+
const write = spec.parse(staged.text)
|
|
279
|
+
if (write === undefined) plan.push({ field, run: undefined })
|
|
280
|
+
else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
|
|
281
|
+
else plan.push({ field, run: () => this.store(field, write.value) })
|
|
282
|
+
}
|
|
283
|
+
return plan
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async clear(field: string): Promise<boolean> {
|
|
287
|
+
await this.scope.unset(field)
|
|
288
|
+
const spec = this.specOf(field)
|
|
289
|
+
if (spec.secret === true) return !(this.options.secretSettled?.(field) ?? false)
|
|
290
|
+
return !this.stored(field)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private async store(field: string, value: unknown): Promise<boolean> {
|
|
294
|
+
await this.scope.set(field, value)
|
|
295
|
+
const spec = this.specOf(field)
|
|
296
|
+
if (spec.secret === true) return this.options.secretSettled?.(field) ?? true
|
|
297
|
+
return this.userLayer()?.[field] === value
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private stage(field: string, edit: StagedEdit): void {
|
|
301
|
+
this.staged.set(field, edit)
|
|
302
|
+
this.failed = false
|
|
303
|
+
this.publish()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private specOf(field: string): FieldSpec {
|
|
307
|
+
const spec = this.specs.get(field)
|
|
308
|
+
if (spec === undefined) throw new Error(`settings card has no field ${field}`)
|
|
309
|
+
return spec
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private snapshotOf(): SettingsScopeSnapshot<T> {
|
|
313
|
+
return this.scope.getSnapshot()
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private sectionValue(field: string): unknown {
|
|
317
|
+
return (this.snapshotOf().value as Record<string, unknown> | undefined)?.[field]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private baseValue(field: string): unknown {
|
|
321
|
+
return (this.snapshotOf().base as Record<string, unknown> | undefined)?.[field]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private userLayer(): Record<string, unknown> | undefined {
|
|
325
|
+
return this.snapshotOf().user as Record<string, unknown> | undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private stored(field: string): boolean {
|
|
329
|
+
const user = this.userLayer()
|
|
330
|
+
return user !== undefined && Object.hasOwn(user, field)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private publish(): void {
|
|
334
|
+
for (const listener of [...this.listeners]) listener()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side settings scope for the dsh-audiogen namespace, served by the
|
|
3
|
+
* plugin's own loopback bridge routes (/api/dsh-audiogen/settings). The
|
|
4
|
+
* official rc.6 settings scope answers "unavailable" for every third-party
|
|
5
|
+
* namespace (the host-apiproxy allowlist is hard-coded), so this package
|
|
6
|
+
* re-serves its namespace through the host settings seam over a same-origin,
|
|
7
|
+
* loopback-only HTTP pair — the same pattern the dsh-web-ui family bridge
|
|
8
|
+
* uses, self-contained per plugin.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createSnapshotStore,
|
|
13
|
+
type SettingsScope,
|
|
14
|
+
type SettingsScopeSnapshot,
|
|
15
|
+
type SnapshotStore,
|
|
16
|
+
} from '@deepseek-ai/dsh-client-runtime/client'
|
|
17
|
+
import { SETTINGS_API, type ChannelConfig } from '../protocol.ts'
|
|
18
|
+
|
|
19
|
+
/** The fields this plugin's settings card edits. */
|
|
20
|
+
export interface AudiogenConfig {
|
|
21
|
+
enabled?: boolean
|
|
22
|
+
announceToAgent?: boolean
|
|
23
|
+
allowAgentAudioGeneration?: boolean
|
|
24
|
+
/** Configured channels (each: name, endpoint, model catalog). */
|
|
25
|
+
channels?: ChannelConfig[]
|
|
26
|
+
/** Per-channel API keys, keyed by channel id. The redacted wire view returns
|
|
27
|
+
* this as an empty object; key presence comes from the secrets sidecar. */
|
|
28
|
+
channelSecrets?: Record<string, string>
|
|
29
|
+
/** Channel used when a request does not name one. */
|
|
30
|
+
defaultChannelId?: string
|
|
31
|
+
defaultModel?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One settings path-op as the bridge consumes it. */
|
|
35
|
+
export type SettingsOp = { op: 'set'; path: string[]; value: unknown } | { op: 'unset'; path: string[] }
|
|
36
|
+
|
|
37
|
+
/** Wire shape of one namespace view from the bridge. */
|
|
38
|
+
interface BridgeView {
|
|
39
|
+
ns: string
|
|
40
|
+
value: unknown
|
|
41
|
+
base?: unknown
|
|
42
|
+
user?: unknown
|
|
43
|
+
revision: number
|
|
44
|
+
secrets?: Array<{ path: string[]; set: boolean }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The bridge response envelope ({ ok: true, value } | { ok: false, code, message }). */
|
|
48
|
+
type BridgeEnvelope =
|
|
49
|
+
| { ok: true; value: { namespaces?: BridgeView[]; writable?: boolean } | BridgeView }
|
|
50
|
+
| { ok: false; code: string; message: string }
|
|
51
|
+
|
|
52
|
+
/** Settings wire face over the bridge routes (fetch-backed). */
|
|
53
|
+
function createBridgeApi(fetchFn: typeof fetch): {
|
|
54
|
+
settings: {
|
|
55
|
+
describe(payload: Record<string, never>): Promise<{ result: BridgeEnvelope }>
|
|
56
|
+
mutate(payload: { ns: string; ops: unknown[]; expectedRevision?: number }): Promise<{ result: BridgeEnvelope }>
|
|
57
|
+
}
|
|
58
|
+
} {
|
|
59
|
+
const post = async (path: string, body: unknown): Promise<{ result: BridgeEnvelope }> => {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetchFn(path, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify(body),
|
|
65
|
+
})
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
return { result: { ok: false, code: 'internal', message: `bridge HTTP ${response.status}` } }
|
|
68
|
+
}
|
|
69
|
+
return { result: await response.json() as BridgeEnvelope }
|
|
70
|
+
} catch {
|
|
71
|
+
return { result: { ok: false, code: 'internal', message: 'settings bridge unreachable' } }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
settings: {
|
|
76
|
+
describe: async payload => post(SETTINGS_API.describe, payload),
|
|
77
|
+
mutate: async payload => post(SETTINGS_API.mutate, payload),
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A SettingsScope over the bridge face: serialized queue, revision-fenced
|
|
84
|
+
* writes, recovery read after a refusal. Mirrors the official controller's
|
|
85
|
+
* ordering but trusts the Host-seam value without re-running the wire-schema
|
|
86
|
+
* validation — the seam already validated it.
|
|
87
|
+
*/
|
|
88
|
+
class BridgeScopeController<T> implements SettingsScope<T> {
|
|
89
|
+
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
|
90
|
+
/** Whether the namespace currently holds a stored secret (e.g. apiKey). */
|
|
91
|
+
private readonly keySet: SnapshotStore<boolean>
|
|
92
|
+
/** Individual secret presence bits, keyed by the settings field name. */
|
|
93
|
+
private readonly secretSets: SnapshotStore<Record<string, boolean>>
|
|
94
|
+
private tail: Promise<void> = Promise.resolve()
|
|
95
|
+
private disposed = false
|
|
96
|
+
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly api: ReturnType<typeof createBridgeApi>['settings'],
|
|
99
|
+
private readonly spec: { namespace: string },
|
|
100
|
+
) {
|
|
101
|
+
this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
|
|
102
|
+
status: 'loading',
|
|
103
|
+
value: undefined,
|
|
104
|
+
base: undefined,
|
|
105
|
+
user: undefined,
|
|
106
|
+
revision: undefined,
|
|
107
|
+
writable: false,
|
|
108
|
+
mode: 'host',
|
|
109
|
+
})
|
|
110
|
+
this.keySet = createSnapshotStore(false)
|
|
111
|
+
this.secretSets = createSnapshotStore({})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
getSnapshot(): SettingsScopeSnapshot<T> {
|
|
115
|
+
return this.store.getSnapshot()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether a stored secret exists (from the redacted view's secrets list). */
|
|
119
|
+
getKeySetSnapshot(): boolean {
|
|
120
|
+
return this.keySet.getSnapshot()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Observe the secret-set flag. */
|
|
124
|
+
subscribeKeySet(listener: () => void): () => void {
|
|
125
|
+
return this.keySet.subscribe(listener)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a specific secret field currently has a stored value. */
|
|
129
|
+
getSecretSetSnapshot(field: string): boolean {
|
|
130
|
+
return this.secretSets.getSnapshot()[field] === true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Observe changes to individual secret-field presence bits. */
|
|
134
|
+
subscribeSecretSets(listener: () => void): () => void {
|
|
135
|
+
return this.secretSets.subscribe(listener)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
subscribe(listener: () => void): () => void {
|
|
139
|
+
return this.store.subscribe(listener)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Queue a bridge refresh. */
|
|
143
|
+
load(): Promise<void> {
|
|
144
|
+
return this.enqueue(() => this.read())
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set(field: string, value: unknown): Promise<void> {
|
|
148
|
+
return this.enqueue(() => this.writeOps([{ op: 'set', path: [field], value }]))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
unset(field: string): Promise<void> {
|
|
152
|
+
return this.enqueue(() => this.writeOps([{ op: 'unset', path: [field] }]))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Apply several path ops in one revision-fenced mutate call (atomic save).
|
|
156
|
+
* Path ops may address plain-object fields (e.g. `channelSecrets.<id>`),
|
|
157
|
+
* but never navigate *inside* arrays — write array fields wholesale. */
|
|
158
|
+
mutateOps(ops: SettingsOp[]): Promise<void> {
|
|
159
|
+
return this.enqueue(() => this.writeOps(ops))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async dispose(): Promise<void> {
|
|
163
|
+
this.disposed = true
|
|
164
|
+
await this.tail
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private enqueue(operation: () => Promise<void>): Promise<void> {
|
|
168
|
+
if (this.disposed) return Promise.resolve()
|
|
169
|
+
const task = this.tail.then(async () => {
|
|
170
|
+
if (this.disposed) return
|
|
171
|
+
await operation()
|
|
172
|
+
})
|
|
173
|
+
this.tail = task.catch(() => {})
|
|
174
|
+
return task
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private async read(): Promise<void> {
|
|
178
|
+
let response
|
|
179
|
+
try {
|
|
180
|
+
response = await this.api.describe({})
|
|
181
|
+
} catch {
|
|
182
|
+
if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
if (!response.result.ok || this.disposed) {
|
|
186
|
+
if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const { namespaces, writable } = response.result.value as { namespaces?: BridgeView[]; writable?: boolean }
|
|
190
|
+
const view = namespaces?.find(candidate => candidate.ns === this.spec.namespace)
|
|
191
|
+
if (view === undefined) {
|
|
192
|
+
this.store.update(draft => {
|
|
193
|
+
draft.status = 'unavailable'
|
|
194
|
+
draft.writable = writable === true
|
|
195
|
+
})
|
|
196
|
+
this.keySet.set(false)
|
|
197
|
+
this.secretSets.set({})
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
this.accept(view, writable)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async writeOps(ops: SettingsOp[]): Promise<void> {
|
|
204
|
+
const revision = this.getSnapshot().revision
|
|
205
|
+
let response
|
|
206
|
+
try {
|
|
207
|
+
response = await this.api.mutate({
|
|
208
|
+
ns: this.spec.namespace,
|
|
209
|
+
ops,
|
|
210
|
+
...revision === undefined ? {} : { expectedRevision: revision },
|
|
211
|
+
})
|
|
212
|
+
} catch {
|
|
213
|
+
await this.read()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (!response.result.ok || this.disposed) {
|
|
217
|
+
await this.read()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
this.accept(response.result.value as BridgeView, undefined)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private accept(view: BridgeView, writable: boolean | undefined): void {
|
|
224
|
+
this.store.update(draft => {
|
|
225
|
+
draft.revision = view.revision
|
|
226
|
+
draft.base = view.base
|
|
227
|
+
draft.user = view.user
|
|
228
|
+
if (writable !== undefined) draft.writable = writable
|
|
229
|
+
draft.status = 'ready'
|
|
230
|
+
// Trust the Host-seam value: the seam already validated it, and the
|
|
231
|
+
// card binds without a narrowing decoder.
|
|
232
|
+
draft.value = view.value as T
|
|
233
|
+
})
|
|
234
|
+
const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
|
|
235
|
+
this.keySet.set(Object.values(secretSets).some(Boolean))
|
|
236
|
+
this.secretSets.set(secretSets)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The bound scope plus the secret-set flag, as the card and panel consume it. */
|
|
241
|
+
export interface AudiogenScope extends SettingsScope<AudiogenConfig> {
|
|
242
|
+
/** Queue a bridge refresh (the invalidation path re-reads the namespace). */
|
|
243
|
+
load(): Promise<void>
|
|
244
|
+
/** Apply several path ops in one revision-fenced mutate call. */
|
|
245
|
+
mutateOps(ops: SettingsOp[]): Promise<void>
|
|
246
|
+
getKeySetSnapshot(): boolean
|
|
247
|
+
subscribeKeySet(listener: () => void): () => void
|
|
248
|
+
getSecretSetSnapshot(field: string): boolean
|
|
249
|
+
subscribeSecretSets(listener: () => void): () => void
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Bind the dsh-audiogen settings scope over the bridge routes and start its
|
|
254
|
+
* initial read (the caller mounts nothing until the scope settles).
|
|
255
|
+
* @param fetchFn - the fetch implementation (the global fetch on loopback).
|
|
256
|
+
* @returns the scope; unavailable when the bridge is unreachable.
|
|
257
|
+
*/
|
|
258
|
+
export function bindAudiogenScope(fetchFn: typeof fetch = fetch): AudiogenScope {
|
|
259
|
+
const controller = new BridgeScopeController<AudiogenConfig>(createBridgeApi(fetchFn).settings, {
|
|
260
|
+
namespace: 'dsh-audiogen',
|
|
261
|
+
})
|
|
262
|
+
void controller.load()
|
|
263
|
+
return controller
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Flatten the configured channels into the model options the panel lists
|
|
268
|
+
* (aliases; the default channel's models first) plus the default channel id.
|
|
269
|
+
* Falls back to the legacy flat allow-list while no channels exist (upgrade
|
|
270
|
+
* path). Pure projection — no host calls.
|
|
271
|
+
*/
|
|
272
|
+
export function audioModelOptions(config: AudiogenConfig | undefined): { models: string[]; defaultChannelId?: string } {
|
|
273
|
+
const channels = config?.channels ?? []
|
|
274
|
+
if (channels.length === 0) {
|
|
275
|
+
return { models: [] }
|
|
276
|
+
}
|
|
277
|
+
const defaultId = config?.defaultChannelId !== undefined && channels.some(channel => channel.id === config.defaultChannelId)
|
|
278
|
+
? config.defaultChannelId
|
|
279
|
+
: channels[0]!.id
|
|
280
|
+
const ordered = [defaultId, ...channels.filter(channel => channel.id !== defaultId).map(channel => channel.id)]
|
|
281
|
+
const models: string[] = []
|
|
282
|
+
for (const id of ordered) {
|
|
283
|
+
const channel = channels.find(candidate => candidate.id === id)!
|
|
284
|
+
for (const model of channel.models) {
|
|
285
|
+
if (model.alias !== '' && !models.includes(model.alias)) models.push(model.alias)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return models.length > 0 ? { models, defaultChannelId: defaultId } : { models: [], defaultChannelId: defaultId }
|
|
289
|
+
}
|