@dickpy/dsh-imagegen 1.0.6 → 1.0.9

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,336 @@
1
+ /**
2
+ * Prompt-template library overlay: a searchable, category-filtered gallery of
3
+ * the bundled awesome-gpt-image-2 cases. The case list is served by the host
4
+ * (bundled snapshot, optionally refreshed online); reference images load
5
+ * lazily through the host's caching proxy, so browsing progressively mirrors
6
+ * the gallery onto the local disk. Picking a template hands its prompt back
7
+ * to the studio form.
8
+ */
9
+
10
+ import { useEffect, useMemo, useRef, useState } from 'react'
11
+ import { createPortal } from 'react-dom'
12
+ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
13
+ import type { ImageGenApi } from './api.ts'
14
+ import { errorMessage, tt } from './helpers.ts'
15
+ import { TEMPLATES_API, type TemplateCase, type TemplateListResult } from '../protocol.ts'
16
+ import css from './templates.module.css'
17
+
18
+ /** Concurrent image downloads while caching the whole gallery offline. */
19
+ const CACHE_ALL_CONCURRENCY = 4
20
+
21
+ /** Same-origin URL of one case's reference image (host caching proxy). */
22
+ function imageUrlOf(item: TemplateCase): string {
23
+ return `${TEMPLATES_API.image}/${encodeURIComponent(item.image)}`
24
+ }
25
+
26
+ /** A card thumbnail that falls back to a placeholder when the proxy 404s. */
27
+ function TemplateThumb(props: { item: TemplateCase }) {
28
+ const [failed, setFailed] = useState(false)
29
+ if (props.item.image === '' || failed) {
30
+ return (
31
+ <span className={css.thumbPlaceholder} aria-hidden="true">
32
+ <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
33
+ </span>
34
+ )
35
+ }
36
+ return (
37
+ <img
38
+ className={css.thumb}
39
+ src={imageUrlOf(props.item)}
40
+ alt={props.item.title}
41
+ loading="lazy"
42
+ onError={() => { setFailed(true) }}
43
+ />
44
+ )
45
+ }
46
+
47
+ /** The template-library modal. Rendered through a portal above the studio. */
48
+ export function TemplateLibrary(props: {
49
+ api: ImageGenApi
50
+ /** Hand a picked prompt back to the studio form and close the library. */
51
+ onUse: (prompt: string) => void
52
+ onClose: () => void
53
+ }) {
54
+ const { api, onUse, onClose } = props
55
+ const [list, setList] = useState<TemplateListResult | null>(null)
56
+ const [loadError, setLoadError] = useState<string | null>(null)
57
+ const [query, setQuery] = useState('')
58
+ const [category, setCategory] = useState('')
59
+ const [selected, setSelected] = useState<TemplateCase | null>(null)
60
+ const [copied, setCopied] = useState(false)
61
+ const [refreshing, setRefreshing] = useState(false)
62
+ const [notice, setNotice] = useState<string | null>(null)
63
+ const [cacheAll, setCacheAll] = useState<{ running: boolean; done: number; total: number }>({ running: false, done: 0, total: 0 })
64
+ const searchRef = useRef<HTMLInputElement>(null)
65
+ const load = (): void => {
66
+ api.templatesList()
67
+ .then(result => { setList(result); setLoadError(null) })
68
+ .catch(caught => { setLoadError(errorMessage(caught)) })
69
+ }
70
+
71
+ // Load once on open; focus the search box for immediate typing.
72
+ useEffect(() => {
73
+ load()
74
+ searchRef.current?.focus()
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [])
77
+
78
+ const categories = useMemo(() => {
79
+ if (list === null) return [] as Array<{ key: string; label: string; count: number }>
80
+ const counts = new Map<string, { label: string; count: number }>()
81
+ for (const item of list.cases) {
82
+ const entry = counts.get(item.category) ?? { label: item.categoryZh || item.category, count: 0 }
83
+ entry.count += 1
84
+ counts.set(item.category, entry)
85
+ }
86
+ return [...counts.entries()].map(([key, value]) => ({ key, label: value.label, count: value.count }))
87
+ }, [list])
88
+
89
+ const filtered = useMemo(() => {
90
+ if (list === null) return [] as TemplateCase[]
91
+ const needle = query.trim().toLowerCase()
92
+ return list.cases.filter(item => {
93
+ if (category !== '' && item.category !== category) return false
94
+ if (needle === '') return true
95
+ return item.title.toLowerCase().includes(needle)
96
+ || item.prompt.toLowerCase().includes(needle)
97
+ || item.sourceLabel.toLowerCase().includes(needle)
98
+ })
99
+ }, [list, query, category])
100
+
101
+ // Escape backs out of the detail view first, then closes the modal.
102
+ useEffect(() => {
103
+ const onKey = (event: KeyboardEvent): void => {
104
+ if (event.key !== 'Escape') return
105
+ event.stopPropagation()
106
+ if (selected !== null) setSelected(null)
107
+ else onClose()
108
+ }
109
+ window.addEventListener('keydown', onKey, true)
110
+ return () => window.removeEventListener('keydown', onKey, true)
111
+ }, [selected, onClose])
112
+
113
+ const refresh = async (): Promise<void> => {
114
+ if (refreshing) return
115
+ setRefreshing(true)
116
+ setNotice(null)
117
+ try {
118
+ const result = await api.templatesRefresh()
119
+ const reloaded = await api.templatesList()
120
+ setList(reloaded)
121
+ setLoadError(null)
122
+ setNotice(tt('templates.refreshed', { count: result.total }))
123
+ } catch (caught) {
124
+ setNotice(tt('templates.refreshFailed', { error: errorMessage(caught) }))
125
+ } finally {
126
+ setRefreshing(false)
127
+ }
128
+ }
129
+
130
+ /** Mirror every reference image through the host cache (offline browsing). */
131
+ const cacheAllImages = async (): Promise<void> => {
132
+ if (cacheAll.running || list === null) return
133
+ const files = [...new Set(list.cases.map(item => item.image).filter(name => name !== ''))]
134
+ setCacheAll({ running: true, done: 0, total: files.length })
135
+ let index = 0
136
+ const worker = async (): Promise<void> => {
137
+ while (index < files.length) {
138
+ const file = files[index]!
139
+ index += 1
140
+ try {
141
+ await fetch(`${TEMPLATES_API.image}/${encodeURIComponent(file)}`)
142
+ } catch { /* individual failures are retried on the next run */ }
143
+ setCacheAll(current => ({ ...current, done: current.done + 1 }))
144
+ }
145
+ }
146
+ await Promise.all(Array.from({ length: CACHE_ALL_CONCURRENCY }, () => worker()))
147
+ setCacheAll({ running: false, done: files.length, total: files.length })
148
+ }
149
+
150
+ const copyPrompt = async (text: string): Promise<void> => {
151
+ try {
152
+ if (navigator.clipboard?.writeText !== undefined) {
153
+ await navigator.clipboard.writeText(text)
154
+ } else {
155
+ const textarea = document.createElement('textarea')
156
+ textarea.value = text
157
+ textarea.style.position = 'fixed'
158
+ textarea.style.opacity = '0'
159
+ document.body.appendChild(textarea)
160
+ textarea.select()
161
+ const copiedOk = document.execCommand('copy')
162
+ textarea.remove()
163
+ if (!copiedOk) throw new Error('copy failed')
164
+ }
165
+ setCopied(true)
166
+ window.setTimeout(() => { setCopied(false) }, 1800)
167
+ } catch {
168
+ setCopied(false)
169
+ }
170
+ }
171
+
172
+ const originLabel = list === null ? '' : tt(list.origin === 'refreshed' ? 'templates.origin.refreshed' : 'templates.origin.bundled')
173
+
174
+ return createPortal(
175
+ <div className={css.overlay} role="dialog" aria-modal="true" aria-label={tt('templates.title')} onClick={onClose}>
176
+ <section className={css.shell} onClick={(event) => { event.stopPropagation() }}>
177
+ <header className={css.header}>
178
+ <span className={css.heading}>
179
+ <h3 className={css.title}>{tt('templates.title')}</h3>
180
+ {list !== null ? (
181
+ <span className={css.meta}>{tt('templates.meta', { count: list.total, origin: originLabel })}</span>
182
+ ) : null}
183
+ </span>
184
+ <span className={css.headerActions}>
185
+ <Button variant="outline" size="sm" disabled={refreshing || cacheAll.running} onClick={() => { void refresh() }}>
186
+ {refreshing ? tt('templates.refreshing') : tt('templates.refresh')}
187
+ </Button>
188
+ <Button
189
+ variant="outline"
190
+ size="sm"
191
+ disabled={list === null || cacheAll.running}
192
+ title={tt('templates.cacheAllHint')}
193
+ onClick={() => { void cacheAllImages() }}
194
+ >
195
+ {cacheAll.running
196
+ ? tt('templates.caching', { done: cacheAll.done, total: cacheAll.total })
197
+ : cacheAll.total > 0 && cacheAll.done === cacheAll.total
198
+ ? tt('templates.cached')
199
+ : tt('templates.cacheAll')}
200
+ </Button>
201
+ <button type="button" className={css.close} aria-label={tt('templates.close')} title={tt('templates.close')} onClick={onClose}>
202
+ <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>
203
+ </button>
204
+ </span>
205
+ </header>
206
+
207
+ <div className={css.toolbar}>
208
+ <input
209
+ ref={searchRef}
210
+ type="search"
211
+ className={css.search}
212
+ placeholder={tt('templates.search')}
213
+ value={query}
214
+ onChange={(event) => { setQuery(event.target.value) }}
215
+ />
216
+ <div className={css.categoryRow}>
217
+ <button
218
+ type="button"
219
+ className={css.categoryPill}
220
+ data-active={category === '' ? '' : undefined}
221
+ onClick={() => { setCategory('') }}
222
+ >
223
+ {tt('templates.all')}{list !== null ? ` ${list.total}` : ''}
224
+ </button>
225
+ {categories.map(entry => (
226
+ <button
227
+ key={entry.key}
228
+ type="button"
229
+ className={css.categoryPill}
230
+ data-active={category === entry.key ? '' : undefined}
231
+ onClick={() => { setCategory(entry.key) }}
232
+ >
233
+ {entry.label} {entry.count}
234
+ </button>
235
+ ))}
236
+ </div>
237
+ </div>
238
+
239
+ {notice !== null ? <div className={css.notice} role="status">{notice}</div> : null}
240
+
241
+ <div className={css.body}>
242
+ {list === null && loadError === null ? (
243
+ <div className={css.state} role="status">
244
+ <span className={css.spinner} />
245
+ <span>{tt('templates.loading')}</span>
246
+ </div>
247
+ ) : null}
248
+
249
+ {loadError !== null ? (
250
+ <div className={css.state} role="alert">
251
+ <span>{tt('templates.loadFailed', { error: loadError })}</span>
252
+ <Button variant="outline" size="sm" onClick={() => { setLoadError(null); setList(null); load() }}>
253
+ {tt('templates.retry')}
254
+ </Button>
255
+ </div>
256
+ ) : null}
257
+
258
+ {list !== null && filtered.length === 0 ? (
259
+ <div className={css.state}>{tt('templates.empty')}</div>
260
+ ) : null}
261
+
262
+ {list !== null && filtered.length > 0 ? (
263
+ <div className={css.grid}>
264
+ {filtered.map(item => (
265
+ <button
266
+ key={item.id}
267
+ type="button"
268
+ className={css.card}
269
+ onClick={() => { setSelected(item); setCopied(false) }}
270
+ >
271
+ <span className={css.thumbWrap}>
272
+ <TemplateThumb item={item} />
273
+ {item.featured ? <span className={css.featuredBadge}>{tt('templates.featured')}</span> : null}
274
+ </span>
275
+ <span className={css.cardBody}>
276
+ <span className={css.cardTitle}>{item.title}</span>
277
+ <span className={css.cardMeta}>
278
+ <span className={css.cardCategory}>{item.categoryZh || item.category}</span>
279
+ {item.sourceLabel !== '' ? <span className={css.cardSource}>{item.sourceLabel}</span> : null}
280
+ </span>
281
+ </span>
282
+ </button>
283
+ ))}
284
+ </div>
285
+ ) : null}
286
+ </div>
287
+
288
+ <footer className={css.footer}>
289
+ <span className={css.attribution}>{tt('templates.attribution')}</span>
290
+ <a className={css.sourceLink} href="https://vibeui.top/" target="_blank" rel="noreferrer">
291
+ {tt('templates.source')}
292
+ </a>
293
+ </footer>
294
+ </section>
295
+
296
+ {selected !== null ? (
297
+ <div className={css.detailOverlay} onClick={() => { setSelected(null) }}>
298
+ <section className={css.detail} onClick={(event) => { event.stopPropagation() }}>
299
+ <div className={css.detailMedia}>
300
+ {selected.image !== '' ? (
301
+ <img className={css.detailImage} src={imageUrlOf(selected)} alt={selected.title} />
302
+ ) : (
303
+ <span className={css.thumbPlaceholder} aria-hidden="true" />
304
+ )}
305
+ </div>
306
+ <div className={css.detailInfo}>
307
+ <h4 className={css.detailTitle}>{selected.title}</h4>
308
+ <div className={css.detailMeta}>
309
+ <span className={css.cardCategory}>{selected.categoryZh || selected.category}</span>
310
+ {selected.sourceUrl !== '' ? (
311
+ <a className={css.detailLink} href={selected.sourceUrl} target="_blank" rel="noreferrer">{selected.sourceLabel || selected.sourceUrl}</a>
312
+ ) : null}
313
+ {selected.githubUrl !== '' ? (
314
+ <a className={css.detailLink} href={selected.githubUrl} target="_blank" rel="noreferrer">GitHub</a>
315
+ ) : null}
316
+ </div>
317
+ <pre className={css.detailPrompt}>{selected.prompt}</pre>
318
+ <div className={css.detailActions}>
319
+ <Button variant="primary" size="md" onClick={() => { onUse(selected.prompt) }}>
320
+ {tt('templates.use')}
321
+ </Button>
322
+ <Button variant="outline" size="md" onClick={() => { void copyPrompt(selected.prompt) }}>
323
+ {copied ? tt('templates.copied') : tt('templates.copy')}
324
+ </Button>
325
+ <Button variant="outline" size="md" onClick={() => { setSelected(null) }}>
326
+ {tt('templates.back')}
327
+ </Button>
328
+ </div>
329
+ </div>
330
+ </section>
331
+ </div>
332
+ ) : null}
333
+ </div>,
334
+ document.body,
335
+ )
336
+ }
package/src/client/api.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * data access path the panel uses — plain fetch, same origin.
4
4
  */
5
5
 
6
- import { GENERATE_API, HISTORY_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type UpdateInfo } from '../protocol.ts'
6
+ import { GENERATE_API, HISTORY_API, TEMPLATES_API, UPDATE_API, type GenerateRequest, type GenerateResult, type HistoryEntry, type TemplateListResult, type TemplateRefreshResult, type UpdateInfo } from '../protocol.ts'
7
7
 
8
8
  /** Error carrying the route's JSON error message. */
9
9
  export class ImageGenApiError extends Error {
@@ -97,4 +97,24 @@ export class ImageGenApi {
97
97
  const body = await readEnvelope<{ ok: true; entries: HistoryEntry[] }>(response)
98
98
  return body.entries
99
99
  }
100
+
101
+ /** Fetch the prompt-template library (bundled snapshot or refreshed copy). */
102
+ async templatesList(): Promise<TemplateListResult> {
103
+ const response = await fetch(TEMPLATES_API.list, { method: 'POST' })
104
+ const body = await readEnvelope<TemplateListResult & { ok: true }>(response)
105
+ return {
106
+ cases: body.cases,
107
+ total: body.total,
108
+ origin: body.origin,
109
+ repository: body.repository,
110
+ fetchedAt: body.fetchedAt,
111
+ }
112
+ }
113
+
114
+ /** Re-download the template library from the upstream mirror (host-side). */
115
+ async templatesRefresh(): Promise<TemplateRefreshResult> {
116
+ const response = await fetch(TEMPLATES_API.refresh, { method: 'POST' })
117
+ const body = await readEnvelope<TemplateRefreshResult & { ok: true }>(response)
118
+ return { total: body.total, fetchedAt: body.fetchedAt }
119
+ }
100
120
  }
@@ -1,127 +1,126 @@
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
- }
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: 'keyed'; 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
+ key: 'dsh-imagegen',
91
+ locale: NS,
92
+ inject: () => settingsCard.inject(),
93
+ }, ImageGenSettingsCard))
94
+
95
+ // The sidebar entry and studio mount once the settings scope settles; while
96
+ // the scope is still loading, the composition default is unknown, so nothing
97
+ // mounts yet. Only an unavailable scope falls back to the default (enabled).
98
+ let uiDisposer: (() => void) | undefined
99
+ const mountUi = (): void => {
100
+ if (uiDisposer !== undefined) return
101
+ const controller = new ImageGenController()
102
+ const api = new ImageGenApi()
103
+ const disposers: Array<() => void> = []
104
+ try {
105
+ disposers.push(mountSidebarEntry(controller, tt('entry.label'), tt('entry.tooltip')))
106
+ disposers.push(mountPanel(controller, api, scope))
107
+ } catch (error) {
108
+ // DOM failures degrade the studio, never the GUI.
109
+ console.warn('[dsh-imagegen] mount failed:', error)
110
+ }
111
+ uiDisposer = () => {
112
+ for (const dispose of disposers.splice(0)) dispose()
113
+ uiDisposer = undefined
114
+ }
115
+ }
116
+ const syncEnabled = (): void => {
117
+ const snapshot = scope.getSnapshot()
118
+ const enabled = snapshot.status === 'ready'
119
+ ? snapshot.value?.enabled ?? true
120
+ : snapshot.status === 'unavailable'
121
+ if (enabled) mountUi()
122
+ else uiDisposer?.()
123
+ }
124
+ scope.subscribe(syncEnabled)
125
+ syncEnabled()
126
+ }