@dickpy/dsh-imagegen 1.4.0 → 1.5.1

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.
Files changed (54) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +270 -124
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/ecommerce-mode.png +0 -0
  5. package/docs/images/image-generation-studio-three-column.png +0 -0
  6. package/docs/images/imagegen-overview.png +0 -0
  7. package/docs/videos/agent-chat-edit.gif +0 -0
  8. package/docs/videos/agent-chat-edit.mp4 +0 -0
  9. package/lib/client.js +1873 -431
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +355 -116
  12. package/package.json +77 -70
  13. package/src/agent-image-tools.ts +447 -418
  14. package/src/client/ImageGenPanel.tsx +1243 -348
  15. package/src/client/SettingsCard.tsx +936 -936
  16. package/src/client/TemplateLibrary.tsx +336 -336
  17. package/src/client/api.ts +203 -193
  18. package/src/client/channels-form.ts +263 -263
  19. package/src/client/controller.ts +46 -46
  20. package/src/client/conversation-sync.ts +14 -14
  21. package/src/client/css-modules.d.ts +5 -5
  22. package/src/client/helpers.ts +33 -33
  23. package/src/client/image-toolview.module.css +73 -73
  24. package/src/client/image-toolview.tsx +34 -28
  25. package/src/client/index.ts +25 -24
  26. package/src/client/locales.ts +156 -28
  27. package/src/client/mount.tsx +117 -117
  28. package/src/client/panel.module.css +1243 -455
  29. package/src/client/settings-card.module.css +1023 -1023
  30. package/src/client/settings-form.ts +337 -336
  31. package/src/client/settings-scope.ts +302 -298
  32. package/src/client/sidebar-entry.ts +190 -190
  33. package/src/client/templates.module.css +453 -453
  34. package/src/edit-image-command.ts +110 -0
  35. package/src/engine.ts +520 -520
  36. package/src/gallery-store.ts +306 -286
  37. package/src/generation-runtime.ts +84 -79
  38. package/src/history-store.ts +270 -250
  39. package/src/image-format.ts +11 -11
  40. package/src/image-models.ts +19 -19
  41. package/src/index.ts +337 -318
  42. package/src/model-catalog.ts +115 -115
  43. package/src/presets.ts +71 -71
  44. package/src/prompt-enhancer.ts +137 -137
  45. package/src/protocol.ts +380 -338
  46. package/src/routes.ts +966 -916
  47. package/src/settings-compat.ts +60 -0
  48. package/src/task-queue.ts +113 -113
  49. package/src/templates/cases.json +10196 -10196
  50. package/src/templates-store.ts +278 -278
  51. package/src/updater.ts +117 -117
  52. package/docs/images/agent-chat-edit.png +0 -0
  53. package/docs/images/agent-chat-generate.png +0 -0
  54. package/docs/images/agent-chat-poster-workflow.png +0 -0
@@ -1,298 +1,302 @@
1
- /**
2
- * Browser-side settings scope for the dsh-imagegen namespace, served by the
3
- * plugin's own loopback bridge routes (/api/dsh-imagegen/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 ImageGenConfig {
21
- enabled?: boolean
22
- announceToAgent?: boolean
23
- allowAgentImageGeneration?: 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
- promptApiUrl?: string
32
- promptApiKey?: string
33
- promptModel?: string
34
- /* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
35
- apiUrl?: string
36
- apiKey?: string
37
- imageModels?: string[]
38
- }
39
-
40
- /** One settings path-op as the bridge consumes it. */
41
- export type SettingsOp = { op: 'set'; path: string[]; value: unknown } | { op: 'unset'; path: string[] }
42
-
43
- /** Wire shape of one namespace view from the bridge. */
44
- interface BridgeView {
45
- ns: string
46
- value: unknown
47
- base?: unknown
48
- user?: unknown
49
- revision: number
50
- secrets?: Array<{ path: string[]; set: boolean }>
51
- }
52
-
53
- /** The bridge response envelope ({ ok: true, value } | { ok: false, code, message }). */
54
- type BridgeEnvelope =
55
- | { ok: true; value: { namespaces?: BridgeView[]; writable?: boolean } | BridgeView }
56
- | { ok: false; code: string; message: string }
57
-
58
- /** Settings wire face over the bridge routes (fetch-backed). */
59
- function createBridgeApi(fetchFn: typeof fetch): {
60
- settings: {
61
- describe(payload: Record<string, never>): Promise<{ result: BridgeEnvelope }>
62
- mutate(payload: { ns: string; ops: unknown[]; expectedRevision?: number }): Promise<{ result: BridgeEnvelope }>
63
- }
64
- } {
65
- const post = async (path: string, body: unknown): Promise<{ result: BridgeEnvelope }> => {
66
- try {
67
- const response = await fetchFn(path, {
68
- method: 'POST',
69
- headers: { 'content-type': 'application/json' },
70
- body: JSON.stringify(body),
71
- })
72
- if (!response.ok) {
73
- return { result: { ok: false, code: 'internal', message: `bridge HTTP ${response.status}` } }
74
- }
75
- return { result: await response.json() as BridgeEnvelope }
76
- } catch {
77
- return { result: { ok: false, code: 'internal', message: 'settings bridge unreachable' } }
78
- }
79
- }
80
- return {
81
- settings: {
82
- describe: async payload => post(SETTINGS_API.describe, payload),
83
- mutate: async payload => post(SETTINGS_API.mutate, payload),
84
- },
85
- }
86
- }
87
-
88
- /**
89
- * A SettingsScope over the bridge face: serialized queue, revision-fenced
90
- * writes, recovery read after a refusal. Mirrors the official controller's
91
- * ordering but trusts the Host-seam value without re-running the wire-schema
92
- * validation — the seam already validated it.
93
- */
94
- class BridgeScopeController<T> implements SettingsScope<T> {
95
- private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
96
- /** Whether the namespace currently holds a stored secret (e.g. apiKey). */
97
- private readonly keySet: SnapshotStore<boolean>
98
- /** Individual secret presence bits, keyed by the settings field name. */
99
- private readonly secretSets: SnapshotStore<Record<string, boolean>>
100
- private tail: Promise<void> = Promise.resolve()
101
- private disposed = false
102
-
103
- constructor(
104
- private readonly api: ReturnType<typeof createBridgeApi>['settings'],
105
- private readonly spec: { namespace: string },
106
- ) {
107
- this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
108
- status: 'loading',
109
- value: undefined,
110
- base: undefined,
111
- user: undefined,
112
- revision: undefined,
113
- writable: false,
114
- mode: 'host',
115
- })
116
- this.keySet = createSnapshotStore(false)
117
- this.secretSets = createSnapshotStore({})
118
- }
119
-
120
- getSnapshot(): SettingsScopeSnapshot<T> {
121
- return this.store.getSnapshot()
122
- }
123
-
124
- /** Whether a stored secret exists (from the redacted view's secrets list). */
125
- getKeySetSnapshot(): boolean {
126
- return this.keySet.getSnapshot()
127
- }
128
-
129
- /** Observe the secret-set flag. */
130
- subscribeKeySet(listener: () => void): () => void {
131
- return this.keySet.subscribe(listener)
132
- }
133
-
134
- /** Whether a specific secret field currently has a stored value. */
135
- getSecretSetSnapshot(field: string): boolean {
136
- return this.secretSets.getSnapshot()[field] === true
137
- }
138
-
139
- /** Observe changes to individual secret-field presence bits. */
140
- subscribeSecretSets(listener: () => void): () => void {
141
- return this.secretSets.subscribe(listener)
142
- }
143
-
144
- subscribe(listener: () => void): () => void {
145
- return this.store.subscribe(listener)
146
- }
147
-
148
- /** Queue a bridge refresh. */
149
- load(): Promise<void> {
150
- return this.enqueue(() => this.read())
151
- }
152
-
153
- set(field: string, value: unknown): Promise<void> {
154
- return this.enqueue(() => this.writeOps([{ op: 'set', path: [field], value }]))
155
- }
156
-
157
- unset(field: string): Promise<void> {
158
- return this.enqueue(() => this.writeOps([{ op: 'unset', path: [field] }]))
159
- }
160
-
161
- /** Apply several path ops in one revision-fenced mutate call (atomic save).
162
- * Path ops may address plain-object fields (e.g. `channelSecrets.<id>`),
163
- * but never navigate *inside* arrays — write array fields wholesale. */
164
- mutateOps(ops: SettingsOp[]): Promise<void> {
165
- return this.enqueue(() => this.writeOps(ops))
166
- }
167
-
168
- async dispose(): Promise<void> {
169
- this.disposed = true
170
- await this.tail
171
- }
172
-
173
- private enqueue(operation: () => Promise<void>): Promise<void> {
174
- if (this.disposed) return Promise.resolve()
175
- const task = this.tail.then(async () => {
176
- if (this.disposed) return
177
- await operation()
178
- })
179
- this.tail = task.catch(() => {})
180
- return task
181
- }
182
-
183
- private async read(): Promise<void> {
184
- let response
185
- try {
186
- response = await this.api.describe({})
187
- } catch {
188
- if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
189
- return
190
- }
191
- if (!response.result.ok || this.disposed) {
192
- if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
193
- return
194
- }
195
- const { namespaces, writable } = response.result.value as { namespaces?: BridgeView[]; writable?: boolean }
196
- const view = namespaces?.find(candidate => candidate.ns === this.spec.namespace)
197
- if (view === undefined) {
198
- this.store.update(draft => {
199
- draft.status = 'unavailable'
200
- draft.writable = writable === true
201
- })
202
- this.keySet.set(false)
203
- this.secretSets.set({})
204
- return
205
- }
206
- this.accept(view, writable)
207
- }
208
-
209
- private async writeOps(ops: SettingsOp[]): Promise<void> {
210
- const revision = this.getSnapshot().revision
211
- let response
212
- try {
213
- response = await this.api.mutate({
214
- ns: this.spec.namespace,
215
- ops,
216
- ...revision === undefined ? {} : { expectedRevision: revision },
217
- })
218
- } catch {
219
- await this.read()
220
- return
221
- }
222
- if (!response.result.ok || this.disposed) {
223
- await this.read()
224
- return
225
- }
226
- this.accept(response.result.value as BridgeView, undefined)
227
- }
228
-
229
- private accept(view: BridgeView, writable: boolean | undefined): void {
230
- this.store.update(draft => {
231
- draft.revision = view.revision
232
- draft.base = view.base
233
- draft.user = view.user
234
- if (writable !== undefined) draft.writable = writable
235
- draft.status = 'ready'
236
- // Trust the Host-seam value: the seam already validated it, and the
237
- // card binds without a narrowing decoder.
238
- draft.value = view.value as T
239
- })
240
- const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
241
- this.keySet.set(Object.values(secretSets).some(Boolean))
242
- this.secretSets.set(secretSets)
243
- }
244
- }
245
-
246
- /** The bound scope plus the secret-set flag, as the card and panel consume it. */
247
- export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
248
- /** Queue a bridge refresh (the invalidation path re-reads the namespace). */
249
- load(): Promise<void>
250
- /** Apply several path ops in one revision-fenced mutate call. */
251
- mutateOps(ops: SettingsOp[]): Promise<void>
252
- getKeySetSnapshot(): boolean
253
- subscribeKeySet(listener: () => void): () => void
254
- getSecretSetSnapshot(field: string): boolean
255
- subscribeSecretSets(listener: () => void): () => void
256
- }
257
-
258
- /**
259
- * Bind the dsh-imagegen settings scope over the bridge routes and start its
260
- * initial read (the caller mounts nothing until the scope settles).
261
- * @param fetchFn - the fetch implementation (the global fetch on loopback).
262
- * @returns the scope; unavailable when the bridge is unreachable.
263
- */
264
- export function bindImageGenScope(fetchFn: typeof fetch = fetch): ImageGenScope {
265
- const controller = new BridgeScopeController<ImageGenConfig>(createBridgeApi(fetchFn).settings, {
266
- namespace: 'dsh-imagegen',
267
- })
268
- void controller.load()
269
- return controller
270
- }
271
-
272
- /**
273
- * Flatten the configured channels into the model options the panel lists
274
- * (aliases; the default channel's models first) plus the default channel id.
275
- * Falls back to the legacy flat allow-list while no channels exist (upgrade
276
- * path). Pure projection — no host calls.
277
- */
278
- export function imageModelOptions(config: ImageGenConfig | undefined): { models: string[]; defaultChannelId?: string } {
279
- const channels = config?.channels ?? []
280
- if (channels.length === 0) {
281
- const legacy = Array.isArray(config?.imageModels)
282
- ? config.imageModels.filter((model): model is string => typeof model === 'string' && model.trim() !== '')
283
- : []
284
- return { models: legacy }
285
- }
286
- const defaultId = config?.defaultChannelId !== undefined && channels.some(channel => channel.id === config.defaultChannelId)
287
- ? config.defaultChannelId
288
- : channels[0]!.id
289
- const ordered = [defaultId, ...channels.filter(channel => channel.id !== defaultId).map(channel => channel.id)]
290
- const models: string[] = []
291
- for (const id of ordered) {
292
- const channel = channels.find(candidate => candidate.id === id)!
293
- for (const model of channel.models) {
294
- if (model.alias !== '' && !models.includes(model.alias)) models.push(model.alias)
295
- }
296
- }
297
- return models.length > 0 ? { models, defaultChannelId: defaultId } : { models: [], defaultChannelId: defaultId }
298
- }
1
+ /**
2
+ * Browser-side settings scope for the dsh-imagegen namespace, served by the
3
+ * plugin's own loopback bridge routes (/api/dsh-imagegen/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 SnapshotStore,
14
+ } from '@deepseek-ai/dsh-client-store'
15
+ import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client'
16
+ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
17
+ import { SETTINGS_API, type ChannelConfig } from '../protocol.ts'
18
+
19
+ /** The fields this plugin's settings card edits. */
20
+ export interface ImageGenConfig {
21
+ enabled?: boolean
22
+ announceToAgent?: boolean
23
+ allowAgentImageGeneration?: 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
+ promptApiUrl?: string
32
+ promptApiKey?: string
33
+ promptModel?: string
34
+ /* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
35
+ apiUrl?: string
36
+ apiKey?: string
37
+ imageModels?: string[]
38
+ }
39
+
40
+ /** One settings path-op as the bridge consumes it. */
41
+ export type SettingsOp = { op: 'set'; path: string[]; value: unknown } | { op: 'unset'; path: string[] }
42
+
43
+ /** Wire shape of one namespace view from the bridge. */
44
+ interface BridgeView {
45
+ ns: string
46
+ value: unknown
47
+ base?: unknown
48
+ user?: unknown
49
+ revision: number
50
+ secrets?: Array<{ path: string[]; set: boolean }>
51
+ }
52
+
53
+ /** The bridge response envelope ({ ok: true, value } | { ok: false, code, message }). */
54
+ type BridgeEnvelope =
55
+ | { ok: true; value: { namespaces?: BridgeView[]; writable?: boolean } | BridgeView }
56
+ | { ok: false; code: string; message: string }
57
+
58
+ /** Settings wire face over the bridge routes (fetch-backed). */
59
+ function createBridgeApi(fetchFn: typeof fetch): {
60
+ settings: {
61
+ describe(payload: Record<string, never>): Promise<{ result: BridgeEnvelope }>
62
+ mutate(payload: { ns: string; ops: unknown[]; expectedRevision?: number }): Promise<{ result: BridgeEnvelope }>
63
+ }
64
+ } {
65
+ const post = async (path: string, body: unknown): Promise<{ result: BridgeEnvelope }> => {
66
+ try {
67
+ const response = await fetchFn(path, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify(body),
71
+ })
72
+ if (!response.ok) {
73
+ return { result: { ok: false, code: 'internal', message: `bridge HTTP ${response.status}` } }
74
+ }
75
+ return { result: await response.json() as BridgeEnvelope }
76
+ } catch {
77
+ return { result: { ok: false, code: 'internal', message: 'settings bridge unreachable' } }
78
+ }
79
+ }
80
+ return {
81
+ settings: {
82
+ describe: async payload => post(SETTINGS_API.describe, payload),
83
+ mutate: async payload => post(SETTINGS_API.mutate, payload),
84
+ },
85
+ }
86
+ }
87
+
88
+ /**
89
+ * A SettingsScope over the bridge face: serialized queue, revision-fenced
90
+ * writes, recovery read after a refusal. Mirrors the official controller's
91
+ * ordering but trusts the Host-seam value without re-running the wire-schema
92
+ * validation — the seam already validated it.
93
+ */
94
+ class BridgeScopeController<T> implements SettingsScope<T> {
95
+ private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
96
+ /** Whether the namespace currently holds a stored secret (e.g. apiKey). */
97
+ private readonly keySet: SnapshotStore<boolean>
98
+ /** Individual secret presence bits, keyed by the settings field name. */
99
+ private readonly secretSets: SnapshotStore<Record<string, boolean>>
100
+ private tail: Promise<void> = Promise.resolve()
101
+ private disposed = false
102
+
103
+ constructor(
104
+ private readonly api: ReturnType<typeof createBridgeApi>['settings'],
105
+ private readonly spec: { namespace: string },
106
+ ) {
107
+ this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
108
+ status: 'loading',
109
+ value: undefined,
110
+ base: undefined,
111
+ user: undefined,
112
+ revision: undefined,
113
+ writable: false,
114
+ mode: 'host',
115
+ })
116
+ this.keySet = createSnapshotStore(false)
117
+ this.secretSets = createSnapshotStore({})
118
+ }
119
+
120
+ getSnapshot(): SettingsScopeSnapshot<T> {
121
+ return this.store.getSnapshot()
122
+ }
123
+
124
+ /** Whether a stored secret exists (from the redacted view's secrets list). */
125
+ getKeySetSnapshot(): boolean {
126
+ return this.keySet.getSnapshot()
127
+ }
128
+
129
+ /** Observe the secret-set flag. */
130
+ subscribeKeySet(listener: () => void): () => void {
131
+ return this.keySet.subscribe(listener)
132
+ }
133
+
134
+ /** Whether a specific secret field currently has a stored value. */
135
+ getSecretSetSnapshot(field: string): boolean {
136
+ return this.secretSets.getSnapshot()[field] === true
137
+ }
138
+
139
+ /** Observe changes to individual secret-field presence bits. */
140
+ subscribeSecretSets(listener: () => void): () => void {
141
+ return this.secretSets.subscribe(listener)
142
+ }
143
+
144
+ subscribe(listener: () => void): () => void {
145
+ return this.store.subscribe(listener)
146
+ }
147
+
148
+ /** Queue a bridge refresh. */
149
+ load(): Promise<void> {
150
+ return this.enqueue(() => this.read())
151
+ }
152
+
153
+ set(field: string, value: unknown): Promise<void> {
154
+ return this.enqueue(() => this.writeOps([{ op: 'set', path: [field], value }]))
155
+ }
156
+
157
+ unset(field: string): Promise<void> {
158
+ return this.enqueue(() => this.writeOps([{ op: 'unset', path: [field] }]))
159
+ }
160
+
161
+ mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void> {
162
+ return this.enqueue(() => this.writeOps([...ops] as SettingsOp[], expectedRevision))
163
+ }
164
+
165
+ /** Apply several path ops in one revision-fenced mutate call (atomic save).
166
+ * Path ops may address plain-object fields (e.g. `channelSecrets.<id>`),
167
+ * but never navigate *inside* arrays — write array fields wholesale. */
168
+ mutateOps(ops: SettingsOp[]): Promise<void> {
169
+ return this.enqueue(() => this.writeOps(ops))
170
+ }
171
+
172
+ async dispose(): Promise<void> {
173
+ this.disposed = true
174
+ await this.tail
175
+ }
176
+
177
+ private enqueue(operation: () => Promise<void>): Promise<void> {
178
+ if (this.disposed) return Promise.resolve()
179
+ const task = this.tail.then(async () => {
180
+ if (this.disposed) return
181
+ await operation()
182
+ })
183
+ this.tail = task.catch(() => {})
184
+ return task
185
+ }
186
+
187
+ private async read(): Promise<void> {
188
+ let response
189
+ try {
190
+ response = await this.api.describe({})
191
+ } catch {
192
+ if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
193
+ return
194
+ }
195
+ if (!response.result.ok || this.disposed) {
196
+ if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
197
+ return
198
+ }
199
+ const { namespaces, writable } = response.result.value as { namespaces?: BridgeView[]; writable?: boolean }
200
+ const view = namespaces?.find(candidate => candidate.ns === this.spec.namespace)
201
+ if (view === undefined) {
202
+ this.store.update(draft => {
203
+ draft.status = 'unavailable'
204
+ draft.writable = writable === true
205
+ })
206
+ this.keySet.set(false)
207
+ this.secretSets.set({})
208
+ return
209
+ }
210
+ this.accept(view, writable)
211
+ }
212
+
213
+ private async writeOps(ops: SettingsOp[], expectedRevision?: number): Promise<void> {
214
+ const revision = expectedRevision ?? this.getSnapshot().revision
215
+ let response
216
+ try {
217
+ response = await this.api.mutate({
218
+ ns: this.spec.namespace,
219
+ ops,
220
+ ...revision === undefined ? {} : { expectedRevision: revision },
221
+ })
222
+ } catch {
223
+ await this.read()
224
+ return
225
+ }
226
+ if (!response.result.ok || this.disposed) {
227
+ await this.read()
228
+ return
229
+ }
230
+ this.accept(response.result.value as BridgeView, undefined)
231
+ }
232
+
233
+ private accept(view: BridgeView, writable: boolean | undefined): void {
234
+ this.store.update(draft => {
235
+ draft.revision = view.revision
236
+ draft.base = view.base
237
+ draft.user = view.user
238
+ if (writable !== undefined) draft.writable = writable
239
+ draft.status = 'ready'
240
+ // Trust the Host-seam value: the seam already validated it, and the
241
+ // card binds without a narrowing decoder.
242
+ draft.value = view.value as T
243
+ })
244
+ const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
245
+ this.keySet.set(Object.values(secretSets).some(Boolean))
246
+ this.secretSets.set(secretSets)
247
+ }
248
+ }
249
+
250
+ /** The bound scope plus the secret-set flag, as the card and panel consume it. */
251
+ export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
252
+ /** Queue a bridge refresh (the invalidation path re-reads the namespace). */
253
+ load(): Promise<void>
254
+ /** Apply several path ops in one revision-fenced mutate call. */
255
+ mutateOps(ops: SettingsOp[]): Promise<void>
256
+ getKeySetSnapshot(): boolean
257
+ subscribeKeySet(listener: () => void): () => void
258
+ getSecretSetSnapshot(field: string): boolean
259
+ subscribeSecretSets(listener: () => void): () => void
260
+ }
261
+
262
+ /**
263
+ * Bind the dsh-imagegen settings scope over the bridge routes and start its
264
+ * initial read (the caller mounts nothing until the scope settles).
265
+ * @param fetchFn - the fetch implementation (the global fetch on loopback).
266
+ * @returns the scope; unavailable when the bridge is unreachable.
267
+ */
268
+ export function bindImageGenScope(fetchFn: typeof fetch = fetch): ImageGenScope {
269
+ const controller = new BridgeScopeController<ImageGenConfig>(createBridgeApi(fetchFn).settings, {
270
+ namespace: 'dsh-imagegen',
271
+ })
272
+ void controller.load()
273
+ return controller
274
+ }
275
+
276
+ /**
277
+ * Flatten the configured channels into the model options the panel lists
278
+ * (aliases; the default channel's models first) plus the default channel id.
279
+ * Falls back to the legacy flat allow-list while no channels exist (upgrade
280
+ * path). Pure projection — no host calls.
281
+ */
282
+ export function imageModelOptions(config: ImageGenConfig | undefined): { models: string[]; defaultChannelId?: string } {
283
+ const channels = config?.channels ?? []
284
+ if (channels.length === 0) {
285
+ const legacy = Array.isArray(config?.imageModels)
286
+ ? config.imageModels.filter((model): model is string => typeof model === 'string' && model.trim() !== '')
287
+ : []
288
+ return { models: legacy }
289
+ }
290
+ const defaultId = config?.defaultChannelId !== undefined && channels.some(channel => channel.id === config.defaultChannelId)
291
+ ? config.defaultChannelId
292
+ : channels[0]!.id
293
+ const ordered = [defaultId, ...channels.filter(channel => channel.id !== defaultId).map(channel => channel.id)]
294
+ const models: string[] = []
295
+ for (const id of ordered) {
296
+ const channel = channels.find(candidate => candidate.id === id)!
297
+ for (const model of channel.models) {
298
+ if (model.alias !== '' && !models.includes(model.alias)) models.push(model.alias)
299
+ }
300
+ }
301
+ return models.length > 0 ? { models, defaultChannelId: defaultId } : { models: [], defaultChannelId: defaultId }
302
+ }