@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.
- package/LICENSE +201 -0
- package/README.md +126 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2413 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +896 -0
- package/package.json +66 -0
- package/src/client/ImageGenPanel.tsx +687 -0
- package/src/client/SettingsCard.tsx +373 -0
- package/src/client/api.ts +89 -0
- package/src/client/controller.ts +46 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/helpers.ts +33 -0
- package/src/client/index.ts +127 -0
- package/src/client/locales.ts +204 -0
- package/src/client/mount.tsx +119 -0
- package/src/client/panel.module.css +970 -0
- package/src/client/settings-card.module.css +288 -0
- package/src/client/settings-form.ts +324 -0
- package/src/client/settings-scope.ts +227 -0
- package/src/client/sidebar-entry.ts +144 -0
- package/src/engine.ts +284 -0
- package/src/history-store.ts +217 -0
- package/src/index.ts +139 -0
- package/src/protocol.ts +118 -0
- package/src/routes.ts +373 -0
|
@@ -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,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
|
+
}
|