@dickpy/dsh-imagegen 1.0.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,373 @@
1
+ /**
2
+ * The dsh-imagegen settings card: api_url, api_key (secret, display-only
3
+ * "set" state), and the plugin switches. Registers into the official
4
+ * `settings.plugin.item` slot (the Settings → Plugins → Configurable tab),
5
+ * independent of the dsh-web-ui family group, bound to the plugin's own
6
+ * bridge settings scope.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
+ import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
13
+ import type { ImageGenScope } from './settings-scope.ts'
14
+ import css from './settings-card.module.css'
15
+
16
+ /** The fields this card edits (the namespace's full schema). */
17
+ export interface ImageGenSettings {
18
+ enabled?: boolean
19
+ announceToAgent?: boolean
20
+ apiUrl?: string
21
+ apiKey?: string
22
+ }
23
+
24
+ /** What the card renders. */
25
+ export interface ImageGenSettingsCardState extends CardShell {
26
+ /** Master switch. */
27
+ enabled: CardFieldState
28
+ /** System-prompt announcement flag. */
29
+ announceToAgent: CardFieldState
30
+ /** API base URL. */
31
+ apiUrl: CardFieldState
32
+ /** API key draft (the stored value is never rendered). */
33
+ apiKey: CardFieldState
34
+ }
35
+
36
+ /** The registration-side face the card's slot entry injects. */
37
+ export interface ImageGenSettingsCardFace extends CardActions {
38
+ hooks: {
39
+ /** Card snapshot bound by the renderer as useImageGenSettingsCard. */
40
+ imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
41
+ /** Whether a secret (apiKey) is currently stored. */
42
+ imageGenKeySet: SnapshotStore<boolean>
43
+ }
44
+ }
45
+
46
+ /** Bridges the imagegen scope onto the card's staged form. */
47
+ export class ImageGenSettingsCardController {
48
+ private readonly form: CardForm<ImageGenSettings>
49
+
50
+ /** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
51
+ constructor(private readonly scope: ImageGenScope) {
52
+ this.form = new CardForm(scope, [
53
+ booleanField('enabled'),
54
+ booleanField('announceToAgent'),
55
+ textField('apiUrl'),
56
+ secretField('apiKey'),
57
+ ], {
58
+ // The redacted wire view never returns the key; a save's outcome is
59
+ // judged by the namespace's secrets sidecar instead.
60
+ secretSettled: () => this.scope.getKeySetSnapshot(),
61
+ })
62
+ }
63
+
64
+ private projection(): ImageGenSettingsCardState {
65
+ return {
66
+ ...this.form.shell(),
67
+ enabled: this.form.field('enabled'),
68
+ announceToAgent: this.form.field('announceToAgent'),
69
+ apiUrl: this.form.field('apiUrl'),
70
+ apiKey: this.form.field('apiKey'),
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Build the face the card's slot registration injects.
76
+ * @returns the card's snapshot, the key-set flag, and the form actions.
77
+ */
78
+ inject(): ImageGenSettingsCardFace {
79
+ const cardStore = this.form.bind(() => this.projection())
80
+ const keySetStore = createSnapshotStore(this.scope.getKeySetSnapshot())
81
+ this.scope.subscribeKeySet(() => { keySetStore.set(this.scope.getKeySetSnapshot()) })
82
+ return {
83
+ hooks: {
84
+ imageGenSettingsCard: cardStore,
85
+ imageGenKeySet: keySetStore,
86
+ },
87
+ ...this.form.actions(),
88
+ }
89
+ }
90
+ }
91
+
92
+ /** Props the renderer binds for this card. */
93
+ export type ImageGenSettingsCardProps =
94
+ PropsRuntime<'settings.plugin.item'>
95
+ & PropsLocale<'dsh-imagegen'>
96
+ & InjectFace<ImageGenSettingsCardFace>
97
+
98
+ /**
99
+ * Render the card.
100
+ * @param props - locale copy, the card snapshot, and the form actions.
101
+ * @returns the card, or nothing while the namespace is still loading.
102
+ */
103
+ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
104
+ const { t } = props
105
+ const state = props.useImageGenSettingsCard(snapshot => snapshot)
106
+ const keySet = props.useImageGenKeySet(snapshot => snapshot)
107
+ const [open, setOpen] = useState(false)
108
+ if (!state.available) return null
109
+ const title = t('settings.title')
110
+ const blocked = !state.dirty || state.invalid || state.saving
111
+ const disabled = !state.writable
112
+ const fieldProps = {
113
+ overriddenLabel: t('settings.overridden'),
114
+ resetLabel: t('settings.reset'),
115
+ invalidLabel: t('settings.invalidNumber'),
116
+ disabled,
117
+ }
118
+ if (!state.exposed) {
119
+ return (
120
+ <li className={css.card}>
121
+ <button
122
+ type="button"
123
+ className={css.header}
124
+ aria-expanded={open}
125
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
126
+ onClick={() => { setOpen(!open) }}
127
+ >
128
+ <span className={css.headText}>
129
+ <span className={css.name}>{title}</span>
130
+ <span className={css.description}>{t('settings.description')}</span>
131
+ </span>
132
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
133
+ </button>
134
+ {open
135
+ ? (
136
+ <div className={css.body}>
137
+ <p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
138
+ </div>
139
+ )
140
+ : null}
141
+ </li>
142
+ )
143
+ }
144
+ return (
145
+ <li className={css.card}>
146
+ <button
147
+ type="button"
148
+ className={css.header}
149
+ aria-expanded={open}
150
+ aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
151
+ onClick={() => { setOpen(!open) }}
152
+ >
153
+ <span className={css.headText}>
154
+ <span className={css.name}>{title}</span>
155
+ <span className={css.description}>{t('settings.description')}</span>
156
+ </span>
157
+ {state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
158
+ <span className={open ? css.chevronOpen : css.chevron}>▾</span>
159
+ </button>
160
+ {open
161
+ ? (
162
+ <div className={css.body}>
163
+ {!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
164
+ <ValueField
165
+ id="dsh-imagegen-settings-apikey"
166
+ label={t('settings.apiKey')}
167
+ hint={keySet ? t('settings.apiKeySet') : t('settings.apiKeyHint')}
168
+ placeholder="sk-…"
169
+ secret
170
+ {...fieldProps}
171
+ {...state.apiKey}
172
+ overridden={false}
173
+ onEdit={(text) => { props.edit('apiKey', text) }}
174
+ onReset={() => { props.resetField('apiKey') }}
175
+ clearLabel={t('settings.apiKeyClear')}
176
+ onClear={() => { props.resetField('apiKey') }}
177
+ canClear={keySet}
178
+ />
179
+ <ValueField
180
+ id="dsh-imagegen-settings-apiurl"
181
+ label={t('settings.apiUrl')}
182
+ hint={t('settings.apiUrlHint')}
183
+ placeholder="https://api.openai.com/v1"
184
+ {...fieldProps}
185
+ {...state.apiUrl}
186
+ onEdit={(text) => { props.edit('apiUrl', text) }}
187
+ onReset={() => { props.resetField('apiUrl') }}
188
+ />
189
+ <BooleanField
190
+ id="dsh-imagegen-settings-enabled"
191
+ label={t('settings.enabled')}
192
+ hint={t('settings.enabledHint')}
193
+ inheritLabel={t('settings.inherit')}
194
+ onLabel={t('settings.on')}
195
+ offLabel={t('settings.off')}
196
+ {...fieldProps}
197
+ {...state.enabled}
198
+ onEdit={(text) => { props.edit('enabled', text) }}
199
+ onReset={() => { props.resetField('enabled') }}
200
+ />
201
+ <BooleanField
202
+ id="dsh-imagegen-settings-announce"
203
+ label={t('settings.announceToAgent')}
204
+ hint={t('settings.announceToAgentHint')}
205
+ inheritLabel={t('settings.inherit')}
206
+ onLabel={t('settings.on')}
207
+ offLabel={t('settings.off')}
208
+ {...fieldProps}
209
+ {...state.announceToAgent}
210
+ onEdit={(text) => { props.edit('announceToAgent', text) }}
211
+ onReset={() => { props.resetField('announceToAgent') }}
212
+ />
213
+ <div className={css.footer}>
214
+ {state.failed ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
215
+ <button
216
+ type="button"
217
+ className={css.discard}
218
+ disabled={!state.dirty || state.saving}
219
+ onClick={props.discard}
220
+ >
221
+ {t('settings.discard')}
222
+ </button>
223
+ <button
224
+ type="button"
225
+ className={css.save}
226
+ disabled={blocked}
227
+ onClick={props.save}
228
+ >
229
+ {t(!state.saving ? 'settings.save' : 'settings.saving')}
230
+ </button>
231
+ </div>
232
+ </div>
233
+ )
234
+ : null}
235
+ </li>
236
+ )
237
+ }
238
+
239
+ /** Props every field control needs regardless of its value type. */
240
+ interface FieldProps {
241
+ /** Stable id associating the label with its control. */
242
+ id: string
243
+ /** Visible label. */
244
+ label: string
245
+ /** One-line explanation rendered under the control. */
246
+ hint: string
247
+ /** Draft text this control renders. */
248
+ text: string
249
+ /** True when saving would leave a user-layer entry for this field. */
250
+ overridden: boolean
251
+ /** True when the draft is not a value this field accepts. */
252
+ invalid: boolean
253
+ /** Copy for the overridden badge. */
254
+ overriddenLabel: string
255
+ /** Copy for the reset control. */
256
+ resetLabel: string
257
+ /** Copy shown in place of the hint while the draft is invalid. */
258
+ invalidLabel: string
259
+ /** Disables every control (read-only document, or an unavailable namespace). */
260
+ disabled: boolean
261
+ /** Stage draft text. */
262
+ onEdit: (text: string) => void
263
+ /** Stage a clear so the field re-inherits the composition layer. */
264
+ onReset: () => void
265
+ }
266
+
267
+ /** A staged value field; `secret` renders a password control. */
268
+ function ValueField(props: FieldProps & {
269
+ /** Render a password control. */
270
+ secret?: boolean
271
+ /** Placeholder shown while the draft is empty. */
272
+ placeholder?: string
273
+ /** Label of the dedicated clear control (secret fields). */
274
+ clearLabel?: string
275
+ /** Stage a clear of the stored secret. */
276
+ onClear?: () => void
277
+ /** Whether a stored secret exists (enables the clear control). */
278
+ canClear?: boolean
279
+ }) {
280
+ return (
281
+ <div className={css.field}>
282
+ <div className={css.head}>
283
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
284
+ {props.overridden
285
+ ? (
286
+ <span className={css.badges}>
287
+ <span className={css.badge}>{props.overriddenLabel}</span>
288
+ <button
289
+ type="button"
290
+ className={css.reset}
291
+ disabled={props.disabled}
292
+ onClick={props.onReset}
293
+ >
294
+ {props.resetLabel}
295
+ </button>
296
+ </span>
297
+ )
298
+ : null}
299
+ {props.secret === true && props.canClear === true
300
+ ? (
301
+ <button
302
+ type="button"
303
+ className={css.reset}
304
+ disabled={props.disabled}
305
+ onClick={props.onClear}
306
+ >
307
+ {props.clearLabel ?? props.resetLabel}
308
+ </button>
309
+ )
310
+ : null}
311
+ </div>
312
+ <input
313
+ id={props.id}
314
+ className={props.invalid ? css.inputInvalid : css.input}
315
+ type={props.secret === true ? 'password' : 'text'}
316
+ autoComplete={props.secret === true ? 'off' : undefined}
317
+ {...props.invalid ? { 'aria-invalid': true } : {}}
318
+ value={props.text}
319
+ placeholder={props.placeholder ?? ''}
320
+ disabled={props.disabled}
321
+ onChange={(event) => { props.onEdit(event.target.value) }}
322
+ />
323
+ <p className={props.invalid ? css.invalid : css.hint}>
324
+ {props.invalid ? props.invalidLabel : props.hint}
325
+ </p>
326
+ </div>
327
+ )
328
+ }
329
+
330
+ /** A staged boolean field: 继承 / 开 / 关. */
331
+ function BooleanField(props: FieldProps & {
332
+ /** Copy for the inherit option. */
333
+ inheritLabel: string
334
+ /** Copy for the on option. */
335
+ onLabel: string
336
+ /** Copy for the off option. */
337
+ offLabel: string
338
+ }) {
339
+ return (
340
+ <div className={css.field}>
341
+ <div className={css.head}>
342
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
343
+ {props.overridden
344
+ ? (
345
+ <span className={css.badges}>
346
+ <span className={css.badge}>{props.overriddenLabel}</span>
347
+ <button
348
+ type="button"
349
+ className={css.reset}
350
+ disabled={props.disabled}
351
+ onClick={props.onReset}
352
+ >
353
+ {props.resetLabel}
354
+ </button>
355
+ </span>
356
+ )
357
+ : null}
358
+ </div>
359
+ <select
360
+ id={props.id}
361
+ className={css.select}
362
+ value={props.text}
363
+ disabled={props.disabled}
364
+ onChange={(event) => { props.onEdit(event.target.value) }}
365
+ >
366
+ <option value="">{props.inheritLabel}</option>
367
+ <option value="true">{props.onLabel}</option>
368
+ <option value="false">{props.offLabel}</option>
369
+ </select>
370
+ <p className={css.hint}>{props.hint}</p>
371
+ </div>
372
+ )
373
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Browser-side API client for the /api/dsh-imagegen route family. The only
3
+ * data access path the panel uses — plain fetch, same origin.
4
+ */
5
+
6
+ import { GENERATE_API, HISTORY_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type HistoryEntryInput } from '../protocol.ts'
7
+
8
+ /** Error carrying the route's JSON error message. */
9
+ export class ImageGenApiError extends Error {
10
+ /** Stable wire code from the host. */
11
+ readonly code: string
12
+
13
+ constructor(message: string, code = 'generate-failed') {
14
+ super(message)
15
+ this.name = 'ImageGenApiError'
16
+ this.code = code
17
+ }
18
+ }
19
+
20
+ /** Parse the { ok, ... } envelope or throw an ImageGenApiError. */
21
+ async function readEnvelope<T>(response: Response): Promise<T> {
22
+ let body: unknown
23
+ try {
24
+ body = await response.json()
25
+ } catch {
26
+ throw new ImageGenApiError(`HTTP ${response.status}: invalid JSON response`)
27
+ }
28
+ if (body === null || typeof body !== 'object') {
29
+ throw new ImageGenApiError(`HTTP ${response.status}: malformed response`)
30
+ }
31
+ const record = body as { ok?: unknown; message?: unknown; code?: unknown }
32
+ if (record.ok !== true) {
33
+ throw new ImageGenApiError(
34
+ typeof record.message === 'string' ? record.message : `HTTP ${response.status}`,
35
+ typeof record.code === 'string' ? record.code : 'generate-failed',
36
+ )
37
+ }
38
+ return body as T
39
+ }
40
+
41
+ /** The browser half's data entry point. */
42
+ export class ImageGenApi {
43
+ /** Forward one generate request to the host proxy. */
44
+ async generate(request: GenerateRequest): Promise<GenerateResult> {
45
+ const response = await fetch(GENERATE_API, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify(request),
49
+ })
50
+ const body = await readEnvelope<{ ok: true; images: GenerateResult['images'] }>(response)
51
+ return { images: body.images }
52
+ }
53
+
54
+ /** List the host-persisted history (newest first). */
55
+ async historyList(): Promise<HistoryEntry[]> {
56
+ const response = await fetch(HISTORY_API.list, { method: 'POST' })
57
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
58
+ return body.entries
59
+ }
60
+
61
+ /** Append one generation to the host-persisted history. */
62
+ async historyAppend(entry: HistoryEntryInput): Promise<HistoryEntry[]> {
63
+ const response = await fetch(HISTORY_API.append, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ entry }),
67
+ })
68
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
69
+ return body.entries
70
+ }
71
+
72
+ /** Remove one history entry by id. */
73
+ async historyRemove(id: string): Promise<HistoryEntry[]> {
74
+ const response = await fetch(HISTORY_API.remove, {
75
+ method: 'POST',
76
+ headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ id }),
78
+ })
79
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
80
+ return body.entries
81
+ }
82
+
83
+ /** Clear the entire history. */
84
+ async historyClear(): Promise<HistoryEntry[]> {
85
+ const response = await fetch(HISTORY_API.clear, { method: 'POST' })
86
+ const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
87
+ return body.entries
88
+ }
89
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Image-gen panel controller: the single owner of the panel's open/closed
3
+ * state. Framework-free so the DOM mounts and the React panel share one tiny
4
+ * subscription surface. The state lives only for the browser session.
5
+ */
6
+
7
+ /** Immutable controller snapshot for UI subscriptions. */
8
+ export interface ImageGenControllerSnapshot {
9
+ panelOpen: boolean
10
+ }
11
+
12
+ /** The panel state owner the sidebar entry toggles and the view renders from. */
13
+ export class ImageGenController {
14
+ private panelOpen = false
15
+ private listeners = new Set<() => void>()
16
+
17
+ getSnapshot(): ImageGenControllerSnapshot {
18
+ return { panelOpen: this.panelOpen }
19
+ }
20
+
21
+ subscribe(fn: () => void): () => void {
22
+ this.listeners.add(fn)
23
+ return () => { this.listeners.delete(fn) }
24
+ }
25
+
26
+ open(): void {
27
+ if (this.panelOpen) return
28
+ this.panelOpen = true
29
+ this.notify()
30
+ }
31
+
32
+ close(): void {
33
+ if (!this.panelOpen) return
34
+ this.panelOpen = false
35
+ this.notify()
36
+ }
37
+
38
+ toggle(): void {
39
+ if (this.panelOpen) this.close()
40
+ else this.open()
41
+ }
42
+
43
+ private notify(): void {
44
+ for (const fn of [...this.listeners]) fn()
45
+ }
46
+ }
@@ -0,0 +1,5 @@
1
+ /** CSS Modules type shim (the bundle inlines the compiled class map). */
2
+ declare module '*.module.css' {
3
+ const classes: Record<string, string>
4
+ export default classes
5
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Shared panel helpers: the active-dictionary pick (document-language based,
3
+ * dsh-ssh precedent) bound to the dsh-imagegen interpolator, plus a small
4
+ * error-message extractor. All copy stays in the locale dictionaries.
5
+ */
6
+
7
+ import { en, zh, type ImageGenKey } from './locales.ts'
8
+
9
+ /** Template values accepted by the interpolator. */
10
+ export type TranslateValues = Record<string, string | number>
11
+
12
+ /** Active dictionary, picked by the document language at call time. */
13
+ export function dictionary(): Record<string, string> {
14
+ const lang = typeof document !== 'undefined' ? document.documentElement.lang : 'zh'
15
+ return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
16
+ }
17
+
18
+ /** Translate a key with optional {name} template params (current language). */
19
+ export function tt(key: ImageGenKey, values?: TranslateValues): string {
20
+ const text = dictionary()[key] ?? key
21
+ if (values === undefined) return text
22
+ let rendered = text
23
+ for (const [name, value] of Object.entries(values)) {
24
+ rendered = rendered.replaceAll(`{${name}}`, String(value))
25
+ }
26
+ return rendered
27
+ }
28
+
29
+ /** Human-readable error text from an unknown thrown value. */
30
+ export function errorMessage(error: unknown): string {
31
+ if (error instanceof Error) return error.message
32
+ return String(error)
33
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Browser-half entry for the dsh-imagegen plugin — runs inside the dsh web
3
+ * GUI.
4
+ *
5
+ * Registers the dsh-imagegen locale dictionaries, binds the plugin's own
6
+ * settings scope (its bridge routes serve the namespace the official rc.6
7
+ * allowlist would refuse), registers the settings card into the Web UI plugin
8
+ * group slot, and mounts the two DOM surfaces: the sidebar entry row (toggles
9
+ * the panel) and the generation studio in the center column. Failure policy:
10
+ * DOM mounting problems are logged, never thrown — the web shell fails the
11
+ * whole boot when a plugin apply throws, and an external plugin must not take
12
+ * the GUI down.
13
+ */
14
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
15
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
16
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
17
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
18
+ // Type-only: pulls the LocaleNamespaceMap merge table.
19
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
20
+ import { ImageGenApi } from './api.ts'
21
+ import { ImageGenController } from './controller.ts'
22
+ import { tt } from './helpers.ts'
23
+ import { en, zh, type ImageGenKey } from './locales.ts'
24
+ import { mountPanel } from './mount.tsx'
25
+ import { mountSidebarEntry } from './sidebar-entry.ts'
26
+ import { ImageGenSettingsCard, ImageGenSettingsCardController } from './SettingsCard.tsx'
27
+ import { bindImageGenScope, type ImageGenScope } from './settings-scope.ts'
28
+
29
+ /** Locale namespace this plugin owns. */
30
+ const NS = 'dsh-imagegen'
31
+
32
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
33
+ interface LocaleNamespaceMap {
34
+ /** dsh-imagegen surface copy. */
35
+ 'dsh-imagegen': ImageGenKey
36
+ }
37
+
38
+ interface SlotMap {
39
+ /**
40
+ * The official plugin-configuration slot the Settings → Plugins →
41
+ * Configurable tab declares and renders. This card registers there as its
42
+ * own standalone card — independent of the dsh-web-ui family group — so
43
+ * this plugin never reads as part of that family. Spelled here with the
44
+ * same shape so this package can register without depending on the
45
+ * sibling UI package.
46
+ */
47
+ 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: ImageGenPluginItemOwnerProps }
48
+ }
49
+ }
50
+
51
+ /** Owner share of a plugin card (the section supplies nothing). */
52
+ export interface ImageGenPluginItemOwnerProps {
53
+ /** Marker field: card owner props are intentionally empty. */
54
+ children?: never
55
+ }
56
+
57
+ /** Required services (fiber inject waiting — the runtime must be up first). */
58
+ export const inject = ['slots', 'locale', 'connection']
59
+
60
+ /**
61
+ * Mount the studio, its sidebar entry, and the settings card.
62
+ * @param ctx - client root context (services: slots, locale, connection).
63
+ */
64
+ export function apply(ctx: ClientContext): void {
65
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-imagegen: dictionaries')
66
+
67
+ const connection = ctx.get('connection') as ConnectionHandle | undefined
68
+ const loopback = connection?.isLoopback === true
69
+ // The bridge routes are loopback-fenced; remote browsers get an unavailable
70
+ // scope (the card explains the gap) instead of failing fetches.
71
+ const scope: ImageGenScope = bindImageGenScope(loopback
72
+ ? (input, init) => fetch(input, init)
73
+ : () => { throw new Error('settings bridge is loopback-only') })
74
+
75
+ // Re-read the scope whenever the connection resets (same invalidation the
76
+ // official settings binder wires).
77
+ ctx.effect(() => {
78
+ const disposers = [
79
+ ctx.on('connection/reset', () => { void scope.load() }),
80
+ ]
81
+ return () => { for (const dispose of disposers) dispose() }
82
+ }, 'dsh-imagegen: settings scope invalidation')
83
+
84
+ // Plugin configuration card: one staged form over the `dsh-imagegen` scope,
85
+ // registered into the official plugin-configuration slot (Settings →
86
+ // Plugins → Configurable) as a standalone card.
87
+ const settingsCard = new ImageGenSettingsCardController(scope)
88
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
89
+ name: 'settings.plugin.item',
90
+ id: 'imagegen',
91
+ order: 30,
92
+ locale: NS,
93
+ inject: () => settingsCard.inject(),
94
+ }, ImageGenSettingsCard))
95
+
96
+ // The sidebar entry and studio mount once the settings scope settles; while
97
+ // the scope is still loading, the composition default is unknown, so nothing
98
+ // mounts yet. Only an unavailable scope falls back to the default (enabled).
99
+ let uiDisposer: (() => void) | undefined
100
+ const mountUi = (): void => {
101
+ if (uiDisposer !== undefined) return
102
+ const controller = new ImageGenController()
103
+ const api = new ImageGenApi()
104
+ const disposers: Array<() => void> = []
105
+ try {
106
+ disposers.push(mountSidebarEntry(controller, tt('entry.label'), tt('entry.tooltip')))
107
+ disposers.push(mountPanel(controller, api, scope))
108
+ } catch (error) {
109
+ // DOM failures degrade the studio, never the GUI.
110
+ console.warn('[dsh-imagegen] mount failed:', error)
111
+ }
112
+ uiDisposer = () => {
113
+ for (const dispose of disposers.splice(0)) dispose()
114
+ uiDisposer = undefined
115
+ }
116
+ }
117
+ const syncEnabled = (): void => {
118
+ const snapshot = scope.getSnapshot()
119
+ const enabled = snapshot.status === 'ready'
120
+ ? snapshot.value?.enabled ?? true
121
+ : snapshot.status === 'unavailable'
122
+ if (enabled) mountUi()
123
+ else uiDisposer?.()
124
+ }
125
+ scope.subscribe(syncEnabled)
126
+ syncEnabled()
127
+ }