@dickpy/dsh-imagegen 1.3.0 → 1.4.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 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
|
@@ -1,957 +1,936 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The dsh-imagegen settings card: channel management (list rows with status
|
|
3
|
-
* dots, an editor dialog with the model-catalog alias → upstream mapping, and
|
|
4
|
-
* built-in provider presets), plus the prompt-enhancement model and the plugin
|
|
5
|
-
* switches. Registers into the official `settings.plugin.item` slot (the
|
|
6
|
-
* Settings → Plugins → Configurable tab), independent of the dsh-web-ui family
|
|
7
|
-
* group, bound to the plugin's own bridge settings scope.
|
|
8
|
-
*
|
|
9
|
-
* The interaction mirrors the host's model-provider page: one row per channel
|
|
10
|
-
* (status dot + edit/delete), two add buttons (built-in provider / custom),
|
|
11
|
-
* and an editor holding API key, display name, API URL, and the model catalog
|
|
12
|
-
* with detection.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { useEffect, useRef, useState } from 'react'
|
|
16
|
-
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
|
-
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
18
|
-
import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
19
|
-
import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
|
|
20
|
-
import type { ImageGenScope } from './settings-scope.ts'
|
|
21
|
-
import { describeModel } from '../model-catalog.ts'
|
|
22
|
-
import { IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, USAGE_API, type ModelMapping, type PresetProviderView } from '../protocol.ts'
|
|
23
|
-
import type { ImageGenKey } from './locales.ts'
|
|
24
|
-
import css from './settings-card.module.css'
|
|
25
|
-
|
|
26
|
-
/** The global (non-channel) fields this card's staged form edits. */
|
|
27
|
-
export interface ImageGenSettings {
|
|
28
|
-
enabled?: boolean
|
|
29
|
-
announceToAgent?: boolean
|
|
30
|
-
allowAgentImageGeneration?: boolean
|
|
31
|
-
promptApiUrl?: string
|
|
32
|
-
promptApiKey?: string
|
|
33
|
-
promptModel?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** What the card renders. */
|
|
37
|
-
export interface ImageGenSettingsCardState extends CardShell {
|
|
38
|
-
/** Channel list staging (channels + per-channel key edits + default). */
|
|
39
|
-
channels: ChannelsFormState
|
|
40
|
-
/** Master switch. */
|
|
41
|
-
enabled: CardFieldState
|
|
42
|
-
/** System-prompt announcement flag. */
|
|
43
|
-
announceToAgent: CardFieldState
|
|
44
|
-
allowAgentImageGeneration: CardFieldState
|
|
45
|
-
promptApiUrl: CardFieldState
|
|
46
|
-
promptApiKey: CardFieldState
|
|
47
|
-
promptModel: CardFieldState
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The registration-side face the card's slot entry injects. */
|
|
51
|
-
export interface ImageGenSettingsCardFace extends CardActions {
|
|
52
|
-
/** Channel staging actions (committed together with the card's save). */
|
|
53
|
-
channels: ChannelsFormActions
|
|
54
|
-
hooks: {
|
|
55
|
-
/** Card snapshot bound by the renderer as useImageGenSettingsCard. */
|
|
56
|
-
imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Bridges the imagegen scope onto the card's staged forms. */
|
|
61
|
-
export class ImageGenSettingsCardController {
|
|
62
|
-
private readonly form: CardForm<ImageGenSettings>
|
|
63
|
-
private readonly channelsForm: ChannelsForm
|
|
64
|
-
|
|
65
|
-
/** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
|
|
66
|
-
constructor(private readonly scope: ImageGenScope) {
|
|
67
|
-
this.form = new CardForm(scope, [
|
|
68
|
-
booleanField('enabled'),
|
|
69
|
-
booleanField('announceToAgent'),
|
|
70
|
-
booleanField('allowAgentImageGeneration'),
|
|
71
|
-
textField('promptApiUrl'),
|
|
72
|
-
secretField('promptApiKey'),
|
|
73
|
-
textField('promptModel'),
|
|
74
|
-
], {
|
|
75
|
-
secretSettled: () => this.scope.getSecretSetSnapshot('promptApiKey'),
|
|
76
|
-
})
|
|
77
|
-
this.channelsForm = new ChannelsForm(scope)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
private projection(): ImageGenSettingsCardState {
|
|
81
|
-
const shell = this.form.shell()
|
|
82
|
-
return {
|
|
83
|
-
...shell,
|
|
84
|
-
dirty: shell.dirty || this.channelsForm.snapshot().dirty,
|
|
85
|
-
channels: this.channelsForm.snapshot(),
|
|
86
|
-
enabled: this.form.field('enabled'),
|
|
87
|
-
announceToAgent: this.form.field('announceToAgent'),
|
|
88
|
-
allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
|
|
89
|
-
promptApiUrl: this.form.field('promptApiUrl'),
|
|
90
|
-
promptApiKey: this.form.field('promptApiKey'),
|
|
91
|
-
promptModel: this.form.field('promptModel'),
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Build the face the card's slot registration injects.
|
|
97
|
-
* @returns the card's snapshot and the form/channel actions.
|
|
98
|
-
*/
|
|
99
|
-
inject(): ImageGenSettingsCardFace {
|
|
100
|
-
const cardStore = this.form.bind(() => this.projection())
|
|
101
|
-
this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
|
|
102
|
-
return {
|
|
103
|
-
hooks: {
|
|
104
|
-
imageGenSettingsCard: cardStore,
|
|
105
|
-
},
|
|
106
|
-
channels: this.channelsForm.actions(),
|
|
107
|
-
...this.form.actions(),
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Props the renderer binds for this card. */
|
|
113
|
-
export type ImageGenSettingsCardProps =
|
|
114
|
-
PropsRuntime<'settings.plugin.item'>
|
|
115
|
-
& PropsLocale<'dsh-imagegen'>
|
|
116
|
-
& InjectFace<ImageGenSettingsCardFace>
|
|
117
|
-
|
|
118
|
-
/** Host-computed usage counters (generation-count badges). */
|
|
119
|
-
interface UsageCounters {
|
|
120
|
-
byChannel: Record<string, Record<string, number>>
|
|
121
|
-
totals: Record<string, number>
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Render the card.
|
|
126
|
-
* @param props - locale copy, the card snapshot, and the form actions.
|
|
127
|
-
* @returns the card, or nothing while the namespace is still loading.
|
|
128
|
-
*/
|
|
129
|
-
export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
130
|
-
const { t } = props
|
|
131
|
-
const state = props.useImageGenSettingsCard(snapshot => snapshot)
|
|
132
|
-
const [open, setOpen] = useState(false)
|
|
133
|
-
// Global-section local states (prompt enhancement etc.).
|
|
134
|
-
const [promptModels, setPromptModels] = useState<string[]>([])
|
|
135
|
-
const [loadingPromptModels, setLoadingPromptModels] = useState(false)
|
|
136
|
-
const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
|
|
137
|
-
const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
|
|
138
|
-
const [manualPromptModel, setManualPromptModel] = useState('')
|
|
139
|
-
const [enhancementOpen, setEnhancementOpen] = useState(false)
|
|
140
|
-
const [promptApiOpen, setPromptApiOpen] = useState(false)
|
|
141
|
-
const [moreOpen, setMoreOpen] = useState(false)
|
|
142
|
-
// Channel list local states.
|
|
143
|
-
const [editingId, setEditingId] = useState<string | null>(null)
|
|
144
|
-
const [presetPickerOpen, setPresetPickerOpen] = useState(false)
|
|
145
|
-
const [presets, setPresets] = useState<PresetProviderView[]>([])
|
|
146
|
-
const [presetError, setPresetError] = useState<string | null>(null)
|
|
147
|
-
const [usage, setUsage] = useState<UsageCounters | null>(null)
|
|
148
|
-
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
149
|
-
|
|
150
|
-
// Usage counters: refreshed once per card open (and after a successful save).
|
|
151
|
-
useEffect(() => {
|
|
152
|
-
if (!state.exposed) return
|
|
153
|
-
let alive = true
|
|
154
|
-
void fetch(USAGE_API, { method: 'POST' })
|
|
155
|
-
.then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) })
|
|
156
|
-
.catch(() => { /* counters are best-effort */ })
|
|
157
|
-
return () => { alive = false }
|
|
158
|
-
}, [state.exposed])
|
|
159
|
-
|
|
160
|
-
if (!state.available) return null
|
|
161
|
-
const title = t('settings.title')
|
|
162
|
-
const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
|
|
163
|
-
const disabled = !state.writable
|
|
164
|
-
const fieldProps = {
|
|
165
|
-
overriddenLabel: t('settings.overridden'),
|
|
166
|
-
resetLabel: t('settings.reset'),
|
|
167
|
-
invalidLabel: t('settings.invalidNumber'),
|
|
168
|
-
disabled,
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (!state.exposed) {
|
|
172
|
-
return (
|
|
173
|
-
<li className={css.card}>
|
|
174
|
-
<button
|
|
175
|
-
type="button"
|
|
176
|
-
className={css.header}
|
|
177
|
-
aria-expanded={open}
|
|
178
|
-
aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
|
|
179
|
-
onClick={() => { setOpen(!open) }}
|
|
180
|
-
>
|
|
181
|
-
<span className={css.headText}>
|
|
182
|
-
<span className={css.name}>{title}</span>
|
|
183
|
-
<span className={css.description}>{t('settings.description')}</span>
|
|
184
|
-
</span>
|
|
185
|
-
<span className={open ? css.chevronOpen : css.chevron}>▾</span>
|
|
186
|
-
</button>
|
|
187
|
-
{open
|
|
188
|
-
? (
|
|
189
|
-
<div className={css.body}>
|
|
190
|
-
<p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
|
|
191
|
-
</div>
|
|
192
|
-
)
|
|
193
|
-
: null}
|
|
194
|
-
</li>
|
|
195
|
-
)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const channels = state.channels.channels
|
|
199
|
-
const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
|
|
200
|
-
|
|
201
|
-
return (
|
|
202
|
-
<li className={css.card}>
|
|
203
|
-
<button
|
|
204
|
-
type="button"
|
|
205
|
-
className={css.header}
|
|
206
|
-
aria-expanded={open}
|
|
207
|
-
aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
|
|
208
|
-
onClick={() => { setOpen(!open) }}
|
|
209
|
-
>
|
|
210
|
-
<span className={css.headText}>
|
|
211
|
-
<span className={css.name}>{title}</span>
|
|
212
|
-
<span className={css.description}>{t('settings.description')}</span>
|
|
213
|
-
</span>
|
|
214
|
-
{state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
|
|
215
|
-
<span className={open ? css.chevronOpen : css.chevron}>▾</span>
|
|
216
|
-
</button>
|
|
217
|
-
{open
|
|
218
|
-
? (
|
|
219
|
-
<div className={css.body}>
|
|
220
|
-
{!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
|
|
221
|
-
|
|
222
|
-
<section className={css.channelSection} aria-label={t('channels.title')}>
|
|
223
|
-
<div className={css.sectionHeader}>
|
|
224
|
-
<div>
|
|
225
|
-
<h3 className={css.sectionTitle}>{t('channels.title')}</h3>
|
|
226
|
-
<p className={css.sectionHint}>{t('channels.hint')}</p>
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
{channels.length === 0
|
|
230
|
-
? <p className={css.channelEmpty}>{t('channels.empty')}</p>
|
|
231
|
-
: (
|
|
232
|
-
<ul className={css.channelList}>
|
|
233
|
-
{channels.map(channel => {
|
|
234
|
-
const keyHeld = state.channels.keySet[channel.id] === true
|
|
235
|
-
const ready = keyHeld && channel.models.length > 0
|
|
236
|
-
const isDefault = channel.id === state.channels.defaultChannelId
|
|
237
|
-
if (confirmDeleteId === channel.id) {
|
|
238
|
-
return (
|
|
239
|
-
<li key={channel.id} className={css.channelRow} data-action>
|
|
240
|
-
<span className={css.deleteConfirmText}>{t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}</span>
|
|
241
|
-
<button type="button" className={css.channelDanger} disabled={disabled} onClick={() => { props.channels.setChannels(channels.filter(candidate => candidate.id !== channel.id)); if (isDefault && channels.length > 1) { const next = channels.find(candidate => candidate.id !== channel.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setConfirmDeleteId(null); if (editingId === channel.id) setEditingId(null) }}>{t('channels.confirm')}</button>
|
|
242
|
-
<button type="button" className={css.channelAction} disabled={disabled} onClick={() => { setConfirmDeleteId(null) }}>{t('channels.cancel')}</button>
|
|
243
|
-
</li>
|
|
244
|
-
)
|
|
245
|
-
}
|
|
246
|
-
return (
|
|
247
|
-
<li key={channel.id} className={css.channelRow}>
|
|
248
|
-
<span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
|
|
249
|
-
<button type="button" className={css.channelMain} disabled={disabled} onClick={() => { setEditingId(channel.id) }}>
|
|
250
|
-
<span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
|
|
251
|
-
<span className={css.channelMeta}>
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<button type="button" className={css.channelAction}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
{
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
<button type="button" className={css.channelAdd} disabled={disabled} onClick={() => {
|
|
301
|
-
|
|
302
|
-
</div>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
<span>{t('settings.
|
|
313
|
-
<span
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
<
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
<
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
<
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
{
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
<
|
|
380
|
-
|
|
381
|
-
<span
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
<
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
{...
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
{...
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
</
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
<span
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
<
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
{
|
|
422
|
-
{...
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
{
|
|
434
|
-
{...
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
{
|
|
446
|
-
{...
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
<
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
})
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
<
|
|
554
|
-
|
|
555
|
-
<
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
{props.
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
{props.
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
{props.resetLabel}
|
|
938
|
-
</button>
|
|
939
|
-
</span>
|
|
940
|
-
)
|
|
941
|
-
: null}
|
|
942
|
-
</div>
|
|
943
|
-
<select
|
|
944
|
-
id={props.id}
|
|
945
|
-
className={css.select}
|
|
946
|
-
value={props.text}
|
|
947
|
-
disabled={props.disabled}
|
|
948
|
-
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
949
|
-
>
|
|
950
|
-
<option value="">{props.inheritLabel}</option>
|
|
951
|
-
<option value="true">{props.onLabel}</option>
|
|
952
|
-
<option value="false">{props.offLabel}</option>
|
|
953
|
-
</select>
|
|
954
|
-
<p className={css.hint}>{props.hint}</p>
|
|
955
|
-
</div>
|
|
956
|
-
)
|
|
957
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The dsh-imagegen settings card: channel management (list rows with status
|
|
3
|
+
* dots, an editor dialog with the model-catalog alias → upstream mapping, and
|
|
4
|
+
* built-in provider presets), plus the prompt-enhancement model and the plugin
|
|
5
|
+
* switches. Registers into the official `settings.plugin.item` slot (the
|
|
6
|
+
* Settings → Plugins → Configurable tab), independent of the dsh-web-ui family
|
|
7
|
+
* group, bound to the plugin's own bridge settings scope.
|
|
8
|
+
*
|
|
9
|
+
* The interaction mirrors the host's model-provider page: one row per channel
|
|
10
|
+
* (status dot + edit/delete), two add buttons (built-in provider / custom),
|
|
11
|
+
* and an editor holding API key, display name, API URL, and the model catalog
|
|
12
|
+
* with detection.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useEffect, useRef, useState } from 'react'
|
|
16
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
|
+
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
18
|
+
import { CardForm, booleanField, secretField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
|
|
19
|
+
import { ChannelsForm, type ChannelDraft, type ChannelsFormActions, type ChannelsFormState } from './channels-form.ts'
|
|
20
|
+
import type { ImageGenScope } from './settings-scope.ts'
|
|
21
|
+
import { describeModel } from '../model-catalog.ts'
|
|
22
|
+
import { IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, USAGE_API, type ModelMapping, type PresetProviderView } from '../protocol.ts'
|
|
23
|
+
import type { ImageGenKey } from './locales.ts'
|
|
24
|
+
import css from './settings-card.module.css'
|
|
25
|
+
|
|
26
|
+
/** The global (non-channel) fields this card's staged form edits. */
|
|
27
|
+
export interface ImageGenSettings {
|
|
28
|
+
enabled?: boolean
|
|
29
|
+
announceToAgent?: boolean
|
|
30
|
+
allowAgentImageGeneration?: boolean
|
|
31
|
+
promptApiUrl?: string
|
|
32
|
+
promptApiKey?: string
|
|
33
|
+
promptModel?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What the card renders. */
|
|
37
|
+
export interface ImageGenSettingsCardState extends CardShell {
|
|
38
|
+
/** Channel list staging (channels + per-channel key edits + default). */
|
|
39
|
+
channels: ChannelsFormState
|
|
40
|
+
/** Master switch. */
|
|
41
|
+
enabled: CardFieldState
|
|
42
|
+
/** System-prompt announcement flag. */
|
|
43
|
+
announceToAgent: CardFieldState
|
|
44
|
+
allowAgentImageGeneration: CardFieldState
|
|
45
|
+
promptApiUrl: CardFieldState
|
|
46
|
+
promptApiKey: CardFieldState
|
|
47
|
+
promptModel: CardFieldState
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The registration-side face the card's slot entry injects. */
|
|
51
|
+
export interface ImageGenSettingsCardFace extends CardActions {
|
|
52
|
+
/** Channel staging actions (committed together with the card's save). */
|
|
53
|
+
channels: ChannelsFormActions
|
|
54
|
+
hooks: {
|
|
55
|
+
/** Card snapshot bound by the renderer as useImageGenSettingsCard. */
|
|
56
|
+
imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bridges the imagegen scope onto the card's staged forms. */
|
|
61
|
+
export class ImageGenSettingsCardController {
|
|
62
|
+
private readonly form: CardForm<ImageGenSettings>
|
|
63
|
+
private readonly channelsForm: ChannelsForm
|
|
64
|
+
|
|
65
|
+
/** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
|
|
66
|
+
constructor(private readonly scope: ImageGenScope) {
|
|
67
|
+
this.form = new CardForm(scope, [
|
|
68
|
+
booleanField('enabled'),
|
|
69
|
+
booleanField('announceToAgent'),
|
|
70
|
+
booleanField('allowAgentImageGeneration'),
|
|
71
|
+
textField('promptApiUrl'),
|
|
72
|
+
secretField('promptApiKey'),
|
|
73
|
+
textField('promptModel'),
|
|
74
|
+
], {
|
|
75
|
+
secretSettled: () => this.scope.getSecretSetSnapshot('promptApiKey'),
|
|
76
|
+
})
|
|
77
|
+
this.channelsForm = new ChannelsForm(scope)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private projection(): ImageGenSettingsCardState {
|
|
81
|
+
const shell = this.form.shell()
|
|
82
|
+
return {
|
|
83
|
+
...shell,
|
|
84
|
+
dirty: shell.dirty || this.channelsForm.snapshot().dirty,
|
|
85
|
+
channels: this.channelsForm.snapshot(),
|
|
86
|
+
enabled: this.form.field('enabled'),
|
|
87
|
+
announceToAgent: this.form.field('announceToAgent'),
|
|
88
|
+
allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
|
|
89
|
+
promptApiUrl: this.form.field('promptApiUrl'),
|
|
90
|
+
promptApiKey: this.form.field('promptApiKey'),
|
|
91
|
+
promptModel: this.form.field('promptModel'),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the face the card's slot registration injects.
|
|
97
|
+
* @returns the card's snapshot and the form/channel actions.
|
|
98
|
+
*/
|
|
99
|
+
inject(): ImageGenSettingsCardFace {
|
|
100
|
+
const cardStore = this.form.bind(() => this.projection())
|
|
101
|
+
this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
|
|
102
|
+
return {
|
|
103
|
+
hooks: {
|
|
104
|
+
imageGenSettingsCard: cardStore,
|
|
105
|
+
},
|
|
106
|
+
channels: this.channelsForm.actions(),
|
|
107
|
+
...this.form.actions(),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Props the renderer binds for this card. */
|
|
113
|
+
export type ImageGenSettingsCardProps =
|
|
114
|
+
PropsRuntime<'settings.plugin.item'>
|
|
115
|
+
& PropsLocale<'dsh-imagegen'>
|
|
116
|
+
& InjectFace<ImageGenSettingsCardFace>
|
|
117
|
+
|
|
118
|
+
/** Host-computed usage counters (generation-count badges). */
|
|
119
|
+
interface UsageCounters {
|
|
120
|
+
byChannel: Record<string, Record<string, number>>
|
|
121
|
+
totals: Record<string, number>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render the card.
|
|
126
|
+
* @param props - locale copy, the card snapshot, and the form actions.
|
|
127
|
+
* @returns the card, or nothing while the namespace is still loading.
|
|
128
|
+
*/
|
|
129
|
+
export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
130
|
+
const { t } = props
|
|
131
|
+
const state = props.useImageGenSettingsCard(snapshot => snapshot)
|
|
132
|
+
const [open, setOpen] = useState(false)
|
|
133
|
+
// Global-section local states (prompt enhancement etc.).
|
|
134
|
+
const [promptModels, setPromptModels] = useState<string[]>([])
|
|
135
|
+
const [loadingPromptModels, setLoadingPromptModels] = useState(false)
|
|
136
|
+
const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
|
|
137
|
+
const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
|
|
138
|
+
const [manualPromptModel, setManualPromptModel] = useState('')
|
|
139
|
+
const [enhancementOpen, setEnhancementOpen] = useState(false)
|
|
140
|
+
const [promptApiOpen, setPromptApiOpen] = useState(false)
|
|
141
|
+
const [moreOpen, setMoreOpen] = useState(false)
|
|
142
|
+
// Channel list local states.
|
|
143
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
144
|
+
const [presetPickerOpen, setPresetPickerOpen] = useState(false)
|
|
145
|
+
const [presets, setPresets] = useState<PresetProviderView[]>([])
|
|
146
|
+
const [presetError, setPresetError] = useState<string | null>(null)
|
|
147
|
+
const [usage, setUsage] = useState<UsageCounters | null>(null)
|
|
148
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
|
|
149
|
+
|
|
150
|
+
// Usage counters: refreshed once per card open (and after a successful save).
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
if (!state.exposed) return
|
|
153
|
+
let alive = true
|
|
154
|
+
void fetch(USAGE_API, { method: 'POST' })
|
|
155
|
+
.then(async response => { const body = await response.json() as { ok?: boolean; usage?: UsageCounters }; if (alive && body.ok === true && body.usage !== undefined) setUsage(body.usage) })
|
|
156
|
+
.catch(() => { /* counters are best-effort */ })
|
|
157
|
+
return () => { alive = false }
|
|
158
|
+
}, [state.exposed])
|
|
159
|
+
|
|
160
|
+
if (!state.available) return null
|
|
161
|
+
const title = t('settings.title')
|
|
162
|
+
const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
|
|
163
|
+
const disabled = !state.writable
|
|
164
|
+
const fieldProps = {
|
|
165
|
+
overriddenLabel: t('settings.overridden'),
|
|
166
|
+
resetLabel: t('settings.reset'),
|
|
167
|
+
invalidLabel: t('settings.invalidNumber'),
|
|
168
|
+
disabled,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!state.exposed) {
|
|
172
|
+
return (
|
|
173
|
+
<li className={css.card}>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={css.header}
|
|
177
|
+
aria-expanded={open}
|
|
178
|
+
aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
|
|
179
|
+
onClick={() => { setOpen(!open) }}
|
|
180
|
+
>
|
|
181
|
+
<span className={css.headText}>
|
|
182
|
+
<span className={css.name}>{title}</span>
|
|
183
|
+
<span className={css.description}>{t('settings.description')}</span>
|
|
184
|
+
</span>
|
|
185
|
+
<span className={open ? css.chevronOpen : css.chevron}>▾</span>
|
|
186
|
+
</button>
|
|
187
|
+
{open
|
|
188
|
+
? (
|
|
189
|
+
<div className={css.body}>
|
|
190
|
+
<p className={css.notExposed} role="status">{t('settings.notExposed')}</p>
|
|
191
|
+
</div>
|
|
192
|
+
)
|
|
193
|
+
: null}
|
|
194
|
+
</li>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const channels = state.channels.channels
|
|
199
|
+
const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<li className={css.card}>
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
className={css.header}
|
|
206
|
+
aria-expanded={open}
|
|
207
|
+
aria-label={`${t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`}
|
|
208
|
+
onClick={() => { setOpen(!open) }}
|
|
209
|
+
>
|
|
210
|
+
<span className={css.headText}>
|
|
211
|
+
<span className={css.name}>{title}</span>
|
|
212
|
+
<span className={css.description}>{t('settings.description')}</span>
|
|
213
|
+
</span>
|
|
214
|
+
{state.dirty ? <span className={css.pending}>{t('settings.unsaved')}</span> : null}
|
|
215
|
+
<span className={open ? css.chevronOpen : css.chevron}>▾</span>
|
|
216
|
+
</button>
|
|
217
|
+
{open
|
|
218
|
+
? (
|
|
219
|
+
<div className={css.body}>
|
|
220
|
+
{!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
|
|
221
|
+
|
|
222
|
+
<section className={css.channelSection} aria-label={t('channels.title')}>
|
|
223
|
+
<div className={css.sectionHeader}>
|
|
224
|
+
<div>
|
|
225
|
+
<h3 className={css.sectionTitle}>{t('channels.title')}</h3>
|
|
226
|
+
<p className={css.sectionHint}>{t('channels.hint')}</p>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
{channels.length === 0
|
|
230
|
+
? <p className={css.channelEmpty}>{t('channels.empty')}</p>
|
|
231
|
+
: (
|
|
232
|
+
<ul className={css.channelList}>
|
|
233
|
+
{channels.map(channel => {
|
|
234
|
+
const keyHeld = state.channels.keySet[channel.id] === true
|
|
235
|
+
const ready = keyHeld && channel.models.length > 0
|
|
236
|
+
const isDefault = channel.id === state.channels.defaultChannelId
|
|
237
|
+
if (confirmDeleteId === channel.id) {
|
|
238
|
+
return (
|
|
239
|
+
<li key={channel.id} className={css.channelRow} data-action>
|
|
240
|
+
<span className={css.deleteConfirmText}>{t('channels.deleteConfirmTitle', { name: channel.name || t('channels.untitled') })}</span>
|
|
241
|
+
<button type="button" className={css.channelDanger} disabled={disabled} onClick={() => { props.channels.setChannels(channels.filter(candidate => candidate.id !== channel.id)); if (isDefault && channels.length > 1) { const next = channels.find(candidate => candidate.id !== channel.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setConfirmDeleteId(null); if (editingId === channel.id) setEditingId(null) }}>{t('channels.confirm')}</button>
|
|
242
|
+
<button type="button" className={css.channelAction} disabled={disabled} onClick={() => { setConfirmDeleteId(null) }}>{t('channels.cancel')}</button>
|
|
243
|
+
</li>
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
return (
|
|
247
|
+
<li key={channel.id} className={css.channelRow}>
|
|
248
|
+
<span className={ready ? css.channelDotReady : css.channelDotWarn} aria-hidden="true" title={t(ready ? 'channels.statusReady' : 'channels.statusIncomplete')} />
|
|
249
|
+
<button type="button" className={css.channelMain} disabled={disabled} onClick={() => { setEditingId(channel.id) }}>
|
|
250
|
+
<span className={css.channelName}>{isDefault ? `★ ${channel.name || t('channels.untitled')}` : (channel.name || t('channels.untitled'))}</span>
|
|
251
|
+
<span className={css.channelMeta}>
|
|
252
|
+
<span className={css.channelBadge} data-warn={!keyHeld || channel.models.length === 0 ? '' : undefined}>
|
|
253
|
+
{keyHeld ? t('channels.keySet') : t('channels.keyMissing')}
|
|
254
|
+
{' · '}
|
|
255
|
+
{channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}
|
|
256
|
+
</span>
|
|
257
|
+
</span>
|
|
258
|
+
</button>
|
|
259
|
+
<button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
|
|
260
|
+
<button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
|
|
261
|
+
</li>
|
|
262
|
+
)
|
|
263
|
+
})}
|
|
264
|
+
</ul>
|
|
265
|
+
)}
|
|
266
|
+
<div className={css.channelControls}>
|
|
267
|
+
{open && presetPickerOpen ? (
|
|
268
|
+
<PresetPicker
|
|
269
|
+
t={t}
|
|
270
|
+
presets={presets}
|
|
271
|
+
error={presetError}
|
|
272
|
+
disabled={state.writable === false}
|
|
273
|
+
onLoad={() => {
|
|
274
|
+
setPresetError(null)
|
|
275
|
+
void fetch(PRESETS_API, { method: 'POST' })
|
|
276
|
+
.then(async response => {
|
|
277
|
+
const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
|
|
278
|
+
if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
279
|
+
setPresets(body.presets)
|
|
280
|
+
})
|
|
281
|
+
.catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
|
|
282
|
+
}}
|
|
283
|
+
onPick={(preset) => {
|
|
284
|
+
const draft = newChannelDraft(preset)
|
|
285
|
+
props.channels.setChannels([...channels, draft])
|
|
286
|
+
setPresetPickerOpen(false)
|
|
287
|
+
setEditingId(draft.id)
|
|
288
|
+
}}
|
|
289
|
+
onCustom={() => {
|
|
290
|
+
const draft = newChannelDraft(undefined)
|
|
291
|
+
props.channels.setChannels([...channels, draft])
|
|
292
|
+
setPresetPickerOpen(false)
|
|
293
|
+
setEditingId(draft.id)
|
|
294
|
+
}}
|
|
295
|
+
onClose={() => { setPresetPickerOpen(false) }}
|
|
296
|
+
/>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
299
|
+
<div className={css.channelAddRow}>
|
|
300
|
+
<button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
|
|
301
|
+
<button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
</section>
|
|
305
|
+
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
className={css.disclosure}
|
|
309
|
+
aria-expanded={enhancementOpen}
|
|
310
|
+
onClick={() => { setEnhancementOpen(open => !open) }}
|
|
311
|
+
>
|
|
312
|
+
<span>{t('settings.promptEnhanceTitle')}</span>
|
|
313
|
+
<span>{t('settings.optional')}</span>
|
|
314
|
+
<span aria-hidden="true">{enhancementOpen ? '⌃' : '⌄'}</span>
|
|
315
|
+
</button>
|
|
316
|
+
{enhancementOpen ? <section className={css.optionalContent} aria-label={t('settings.promptEnhanceTitle')}>
|
|
317
|
+
<p className={css.sectionHint}>{t('settings.promptEnhanceHint')}</p>
|
|
318
|
+
<div className={css.sectionHeader}>
|
|
319
|
+
<div>
|
|
320
|
+
<h3 className={css.sectionTitle}>{t('settings.promptModel')}</h3>
|
|
321
|
+
<p className={css.sectionHint}>{t('settings.promptModelDetectionHint')}</p>
|
|
322
|
+
</div>
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
className={css.modelFetch}
|
|
326
|
+
disabled={disabled || loadingPromptModels}
|
|
327
|
+
onClick={() => {
|
|
328
|
+
setLoadingPromptModels(true)
|
|
329
|
+
setPromptModelsError(null)
|
|
330
|
+
void fetch(PROMPT_ENHANCE_API.models, { method: 'POST' })
|
|
331
|
+
.then(async response => {
|
|
332
|
+
const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
|
|
333
|
+
if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
334
|
+
setPromptModels(body.models ?? [])
|
|
335
|
+
})
|
|
336
|
+
.catch(error => { setPromptModelsError(error instanceof Error ? error.message : String(error)) })
|
|
337
|
+
.finally(() => { setLoadingPromptModels(false) })
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
{loadingPromptModels ? t('settings.promptModelsLoading') : t('settings.promptModelsFetch')}
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
<div className={css.modelSummary}>
|
|
344
|
+
{state.promptModel.text.trim() !== '' ? (
|
|
345
|
+
<span className={css.modelChip}>
|
|
346
|
+
<span>{state.promptModel.text}</span>
|
|
347
|
+
<button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
|
|
348
|
+
</span>
|
|
349
|
+
) : null}
|
|
350
|
+
<button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
|
|
351
|
+
{manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
|
|
352
|
+
</button>
|
|
353
|
+
</div>
|
|
354
|
+
{manualPromptModelOpen ? (
|
|
355
|
+
<div className={css.manualModelRow}>
|
|
356
|
+
<input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
|
|
357
|
+
<button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
|
|
358
|
+
</div>
|
|
359
|
+
) : null}
|
|
360
|
+
{promptModels.length > 0 ? (
|
|
361
|
+
<div className={css.modelCandidateList} role="radiogroup" aria-label={t('settings.promptModelsCandidates')}>
|
|
362
|
+
<span className={css.modelCandidateLabel}>{t('settings.promptModelsCandidates')}</span>
|
|
363
|
+
{promptModels.map(candidate => (
|
|
364
|
+
<button
|
|
365
|
+
key={candidate}
|
|
366
|
+
type="button"
|
|
367
|
+
role="radio"
|
|
368
|
+
className={css.modelCandidate}
|
|
369
|
+
aria-checked={state.promptModel.text === candidate}
|
|
370
|
+
data-selected={state.promptModel.text === candidate ? '' : undefined}
|
|
371
|
+
disabled={disabled}
|
|
372
|
+
onClick={() => { props.edit('promptModel', candidate) }}
|
|
373
|
+
>
|
|
374
|
+
{candidate}
|
|
375
|
+
</button>
|
|
376
|
+
))}
|
|
377
|
+
</div>
|
|
378
|
+
) : null}
|
|
379
|
+
{promptModelsError !== null ? <p className={css.failed} role="status">{promptModelsError}</p> : null}
|
|
380
|
+
<button type="button" className={css.inlineDisclosure} aria-expanded={promptApiOpen} onClick={() => { setPromptApiOpen(open => !open) }}>
|
|
381
|
+
<span>{t('settings.promptApiAdvanced')}</span>
|
|
382
|
+
<span aria-hidden="true">{promptApiOpen ? '⌃' : '⌄'}</span>
|
|
383
|
+
</button>
|
|
384
|
+
{promptApiOpen ? <div className={css.optionalContent}>
|
|
385
|
+
<ValueField
|
|
386
|
+
id="dsh-imagegen-settings-prompt-apiurl"
|
|
387
|
+
label={t('settings.promptApiUrl')}
|
|
388
|
+
hint={t('settings.promptApiUrlHint')}
|
|
389
|
+
placeholder="https://api.openai.com/v1"
|
|
390
|
+
{...fieldProps}
|
|
391
|
+
{...state.promptApiUrl}
|
|
392
|
+
onEdit={(text) => { props.edit('promptApiUrl', text) }}
|
|
393
|
+
onReset={() => { props.resetField('promptApiUrl') }}
|
|
394
|
+
/>
|
|
395
|
+
<ValueField
|
|
396
|
+
id="dsh-imagegen-settings-prompt-apikey"
|
|
397
|
+
label={t('settings.promptApiKey')}
|
|
398
|
+
hint={t('settings.promptApiKeyHint')}
|
|
399
|
+
placeholder="sk-…"
|
|
400
|
+
secret
|
|
401
|
+
{...fieldProps}
|
|
402
|
+
{...state.promptApiKey}
|
|
403
|
+
overridden={false}
|
|
404
|
+
onEdit={(text) => { props.edit('promptApiKey', text) }}
|
|
405
|
+
onReset={() => { props.resetField('promptApiKey') }}
|
|
406
|
+
/>
|
|
407
|
+
</div> : null}
|
|
408
|
+
</section> : null}
|
|
409
|
+
|
|
410
|
+
<button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
|
|
411
|
+
<span>{t('settings.moreOptions')}</span>
|
|
412
|
+
<span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
|
|
413
|
+
</button>
|
|
414
|
+
{moreOpen ? <div className={css.optionalContent}>
|
|
415
|
+
<BooleanField
|
|
416
|
+
id="dsh-imagegen-settings-enabled"
|
|
417
|
+
label={t('settings.enabled')}
|
|
418
|
+
hint={t('settings.enabledHint')}
|
|
419
|
+
inheritLabel={t('settings.inherit')}
|
|
420
|
+
onLabel={t('settings.on')}
|
|
421
|
+
offLabel={t('settings.off')}
|
|
422
|
+
{...fieldProps}
|
|
423
|
+
{...state.enabled}
|
|
424
|
+
onEdit={(text) => { props.edit('enabled', text) }}
|
|
425
|
+
onReset={() => { props.resetField('enabled') }}
|
|
426
|
+
/>
|
|
427
|
+
<BooleanField
|
|
428
|
+
id="dsh-imagegen-settings-announce"
|
|
429
|
+
label={t('settings.announceToAgent')}
|
|
430
|
+
hint={t('settings.announceToAgentHint')}
|
|
431
|
+
inheritLabel={t('settings.inherit')}
|
|
432
|
+
onLabel={t('settings.on')}
|
|
433
|
+
offLabel={t('settings.off')}
|
|
434
|
+
{...fieldProps}
|
|
435
|
+
{...state.announceToAgent}
|
|
436
|
+
onEdit={(text) => { props.edit('announceToAgent', text) }}
|
|
437
|
+
onReset={() => { props.resetField('announceToAgent') }}
|
|
438
|
+
/>
|
|
439
|
+
<BooleanField
|
|
440
|
+
id="dsh-imagegen-settings-agent-generation"
|
|
441
|
+
label={t('settings.allowAgentImageGeneration')}
|
|
442
|
+
hint={t('settings.allowAgentImageGenerationHint')}
|
|
443
|
+
inheritLabel={t('settings.inherit')}
|
|
444
|
+
onLabel={t('settings.on')}
|
|
445
|
+
offLabel={t('settings.off')}
|
|
446
|
+
{...fieldProps}
|
|
447
|
+
{...state.allowAgentImageGeneration}
|
|
448
|
+
onEdit={(text) => { props.edit('allowAgentImageGeneration', text) }}
|
|
449
|
+
onReset={() => { props.resetField('allowAgentImageGeneration') }}
|
|
450
|
+
/>
|
|
451
|
+
</div> : null}
|
|
452
|
+
<div className={css.footer}>
|
|
453
|
+
{(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className={css.discard}
|
|
457
|
+
disabled={!state.dirty || state.saving || state.channels.saving}
|
|
458
|
+
onClick={() => { props.discard(); props.channels.discard() }}
|
|
459
|
+
>
|
|
460
|
+
{t('settings.discard')}
|
|
461
|
+
</button>
|
|
462
|
+
<button
|
|
463
|
+
type="button"
|
|
464
|
+
className={css.save}
|
|
465
|
+
disabled={blocked}
|
|
466
|
+
onClick={() => { void props.channels.commit(); void props.save() }}
|
|
467
|
+
>
|
|
468
|
+
{t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
)
|
|
473
|
+
: null}
|
|
474
|
+
|
|
475
|
+
{open && editing !== undefined ? (
|
|
476
|
+
<ChannelEditor
|
|
477
|
+
key={editing.id}
|
|
478
|
+
t={t}
|
|
479
|
+
channel={editing}
|
|
480
|
+
keyHeld={state.channels.keySet[editing.id] === true}
|
|
481
|
+
usage={usage}
|
|
482
|
+
otherChannels={channels.filter(channel => channel.id !== editing.id)}
|
|
483
|
+
isDefault={editing.id === state.channels.defaultChannelId}
|
|
484
|
+
writable={state.writable}
|
|
485
|
+
onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
|
|
486
|
+
onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
|
|
487
|
+
onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
|
|
488
|
+
onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
|
|
489
|
+
onRemove={() => { props.channels.setChannels(channels.filter(channel => channel.id !== editing.id)); if (editing.id === state.channels.defaultChannelId && channels.length > 1) { const next = channels.find(channel => channel.id !== editing.id); if (next !== undefined) props.channels.setDefaultChannel(next.id) } setEditingId(null) }}
|
|
490
|
+
onClose={() => { setEditingId(null) }}
|
|
491
|
+
/>
|
|
492
|
+
) : null}
|
|
493
|
+
|
|
494
|
+
</li>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Channel row + dialog helpers -------------------------------------------------- */
|
|
499
|
+
|
|
500
|
+
function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
|
|
501
|
+
return {
|
|
502
|
+
id: clientId(),
|
|
503
|
+
preset: preset?.id ?? '',
|
|
504
|
+
name: preset?.name ?? '',
|
|
505
|
+
apiUrl: preset?.apiUrl ?? '',
|
|
506
|
+
models: (preset?.models ?? []).map(model => ({ ...model })),
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
|
|
511
|
+
const draft = newChannelDraft(undefined)
|
|
512
|
+
form.setChannels([...channels, draft])
|
|
513
|
+
openEditor(draft.id)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Patch one field (or models) of one staged channel. */
|
|
517
|
+
function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
|
|
518
|
+
form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function clientId(): string {
|
|
522
|
+
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
|
|
523
|
+
return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Built-in provider picker, expanded inside the settings card. */
|
|
527
|
+
function PresetPicker(props: {
|
|
528
|
+
t: (key: ImageGenKey, params?: Record<string, string | number>) => string
|
|
529
|
+
presets: PresetProviderView[]
|
|
530
|
+
error: string | null
|
|
531
|
+
disabled: boolean
|
|
532
|
+
onLoad: () => void
|
|
533
|
+
onPick: (preset: PresetProviderView) => void
|
|
534
|
+
onCustom: () => void
|
|
535
|
+
onClose: () => void
|
|
536
|
+
}) {
|
|
537
|
+
const { t } = props
|
|
538
|
+
const loadedRef = useRef(false)
|
|
539
|
+
useEffect(() => {
|
|
540
|
+
if (loadedRef.current) return
|
|
541
|
+
loadedRef.current = true
|
|
542
|
+
props.onLoad()
|
|
543
|
+
}, [])
|
|
544
|
+
return (
|
|
545
|
+
<section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
|
|
546
|
+
<header className={css.presetInlineHeader}>
|
|
547
|
+
<div>
|
|
548
|
+
<h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
|
|
549
|
+
<p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
|
|
550
|
+
</div>
|
|
551
|
+
<button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
|
|
552
|
+
</header>
|
|
553
|
+
<div className={css.presetList}>
|
|
554
|
+
{props.presets.map(preset => (
|
|
555
|
+
<button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
|
|
556
|
+
<span className={css.presetName}>{preset.name}</span>
|
|
557
|
+
<span className={css.presetMeta}>{preset.models.map(model => model.alias).join(' · ')}</span>
|
|
558
|
+
</button>
|
|
559
|
+
))}
|
|
560
|
+
<button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
|
|
561
|
+
<span className={css.presetName}>+ {t('channels.addCustom')}</span>
|
|
562
|
+
<span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
|
|
563
|
+
</button>
|
|
564
|
+
{props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
|
|
565
|
+
</div>
|
|
566
|
+
</section>
|
|
567
|
+
)
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Channel editor (modal): key, display name, API URL, model catalog. */
|
|
571
|
+
function ChannelEditor(props: {
|
|
572
|
+
t: (key: ImageGenKey, params?: Record<string, string | number>) => string
|
|
573
|
+
channel: ChannelDraft
|
|
574
|
+
keyHeld: boolean
|
|
575
|
+
usage: UsageCounters | null
|
|
576
|
+
otherChannels: ChannelDraft[]
|
|
577
|
+
isDefault: boolean
|
|
578
|
+
writable: boolean
|
|
579
|
+
onPatch: (patch: Partial<ChannelDraft>) => void
|
|
580
|
+
onSetModels: (models: ModelMapping[]) => void
|
|
581
|
+
onSetKey: (value: string | undefined) => void
|
|
582
|
+
onSetDefault: () => void
|
|
583
|
+
onRemove: () => void
|
|
584
|
+
onClose: () => void
|
|
585
|
+
}) {
|
|
586
|
+
const { t, channel } = props
|
|
587
|
+
const [keyDraft, setKeyDraft] = useState('')
|
|
588
|
+
const [candidates, setCandidates] = useState<string[] | null>(null)
|
|
589
|
+
const [detecting, setDetecting] = useState(false)
|
|
590
|
+
const [detectError, setDetectError] = useState<string | null>(null)
|
|
591
|
+
const [manualId, setManualId] = useState('')
|
|
592
|
+
const [removeOpen, setRemoveOpen] = useState(false)
|
|
593
|
+
const [copyFrom, setCopyFrom] = useState('')
|
|
594
|
+
|
|
595
|
+
const generatedCount = (alias: string): number => {
|
|
596
|
+
if (props.usage === null) return 0
|
|
597
|
+
const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
|
|
598
|
+
return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const detect = (): void => {
|
|
602
|
+
setDetecting(true)
|
|
603
|
+
setDetectError(null)
|
|
604
|
+
const payload: Record<string, unknown> = { channelId: channel.id }
|
|
605
|
+
if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
|
|
606
|
+
if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
|
|
607
|
+
void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
608
|
+
.then(async response => {
|
|
609
|
+
const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
|
|
610
|
+
if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
611
|
+
setCandidates(body.models ?? [])
|
|
612
|
+
})
|
|
613
|
+
.catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
|
|
614
|
+
.finally(() => { setDetecting(false) })
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Auto-detect once when the dialog opens with a complete endpoint.
|
|
618
|
+
const autoDetected = useRef(false)
|
|
619
|
+
useEffect(() => {
|
|
620
|
+
if (autoDetected.current) return
|
|
621
|
+
autoDetected.current = true
|
|
622
|
+
if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
|
|
623
|
+
}, [])
|
|
624
|
+
|
|
625
|
+
const addManual = (): void => {
|
|
626
|
+
const id = manualId.trim()
|
|
627
|
+
if (id === '') return
|
|
628
|
+
const next = [...channel.models]
|
|
629
|
+
const alias = id
|
|
630
|
+
if (!next.some(model => model.alias === alias)) next.push({ alias, id })
|
|
631
|
+
props.onSetModels(next)
|
|
632
|
+
setManualId('')
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const copyFromChannel = (): void => {
|
|
636
|
+
const source = props.otherChannels.find(chance => chance.id === copyFrom)
|
|
637
|
+
if (source === undefined) return
|
|
638
|
+
const merged = [...channel.models]
|
|
639
|
+
for (const model of source.models) {
|
|
640
|
+
const alias = model.alias
|
|
641
|
+
// Copy with a collision suffix so both sources stay selectable.
|
|
642
|
+
let unique = alias
|
|
643
|
+
let suffix = 2
|
|
644
|
+
while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
|
|
645
|
+
merged.push({ alias: unique, id: model.id })
|
|
646
|
+
}
|
|
647
|
+
props.onSetModels(merged)
|
|
648
|
+
setCopyFrom('')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return (
|
|
652
|
+
<div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
|
|
653
|
+
<div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
|
|
654
|
+
<header className={css.editorHeader}>
|
|
655
|
+
<div>
|
|
656
|
+
<h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
|
|
657
|
+
<p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
|
|
658
|
+
</div>
|
|
659
|
+
<button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
|
|
660
|
+
</header>
|
|
661
|
+
|
|
662
|
+
<div className={css.editorField}>
|
|
663
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
|
|
664
|
+
<input id="dsh-imagegen-channel-name" className={css.input} value={channel.name} placeholder={t('channels.untitled')} disabled={!props.writable} onChange={event => { props.onPatch({ name: event.target.value }) }} />
|
|
665
|
+
</div>
|
|
666
|
+
<div className={css.editorField}>
|
|
667
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
|
|
668
|
+
<input id="dsh-imagegen-channel-url" className={css.input} value={channel.apiUrl} placeholder="https://api.example.com/v1" disabled={!props.writable} onChange={event => { props.onPatch({ apiUrl: event.target.value }) }} />
|
|
669
|
+
</div>
|
|
670
|
+
<div className={css.editorField}>
|
|
671
|
+
<div className={css.head}>
|
|
672
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
|
|
673
|
+
{props.keyHeld || keyDraft !== ''
|
|
674
|
+
? (
|
|
675
|
+
<button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
|
|
676
|
+
{t('channels.keyClear')}
|
|
677
|
+
</button>
|
|
678
|
+
)
|
|
679
|
+
: null}
|
|
680
|
+
</div>
|
|
681
|
+
<input
|
|
682
|
+
id="dsh-imagegen-channel-key"
|
|
683
|
+
className={css.input}
|
|
684
|
+
type="password"
|
|
685
|
+
autoComplete="off"
|
|
686
|
+
value={keyDraft}
|
|
687
|
+
placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
|
|
688
|
+
disabled={!props.writable}
|
|
689
|
+
onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
|
|
690
|
+
/>
|
|
691
|
+
</div>
|
|
692
|
+
|
|
693
|
+
<div className={css.editorDivider} />
|
|
694
|
+
|
|
695
|
+
<div className={css.editorSectionHeader}>
|
|
696
|
+
<h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
|
|
697
|
+
<button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
|
|
698
|
+
{detecting ? t('channels.detecting') : t('channels.detect')}
|
|
699
|
+
</button>
|
|
700
|
+
</div>
|
|
701
|
+
{detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
|
|
702
|
+
{candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
|
|
703
|
+
|
|
704
|
+
{channel.models.length === 0
|
|
705
|
+
? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
|
|
706
|
+
: (
|
|
707
|
+
<ul className={css.modelRows}>
|
|
708
|
+
{channel.models.map((model, index) => {
|
|
709
|
+
const entry = describeModel(model.id || model.alias)
|
|
710
|
+
const generated = generatedCount(model.alias)
|
|
711
|
+
return (
|
|
712
|
+
<li key={`${model.alias}-${index}`} className={css.modelRow}>
|
|
713
|
+
<div className={css.modelRowInputs}>
|
|
714
|
+
<input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
|
|
715
|
+
const next = [...channel.models]
|
|
716
|
+
next[index] = { ...model, alias: event.target.value }
|
|
717
|
+
props.onSetModels(next)
|
|
718
|
+
}} />
|
|
719
|
+
<span className={css.modelArrow}>→</span>
|
|
720
|
+
<input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
|
|
721
|
+
const next = [...channel.models]
|
|
722
|
+
next[index] = { ...model, id: event.target.value }
|
|
723
|
+
props.onSetModels(next)
|
|
724
|
+
}} />
|
|
725
|
+
</div>
|
|
726
|
+
<div className={css.modelRowBadges}>
|
|
727
|
+
<span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
|
|
728
|
+
{generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
|
|
729
|
+
<button type="button" className={css.modelRowRemove} disabled={!props.writable} aria-label={`${t('channels.removeModel')}: ${model.alias}`} onClick={() => { props.onSetModels(channel.models.filter((_, i) => i !== index)) }}>×</button>
|
|
730
|
+
</div>
|
|
731
|
+
</li>
|
|
732
|
+
)
|
|
733
|
+
})}
|
|
734
|
+
</ul>
|
|
735
|
+
)}
|
|
736
|
+
|
|
737
|
+
<div className={css.editorTools}>
|
|
738
|
+
<div className={css.manualModelRow}>
|
|
739
|
+
<input className={css.input} value={manualId} placeholder={t('channels.manualAddPlaceholder')} disabled={!props.writable} onChange={event => { setManualId(event.target.value) }} onKeyDown={event => { if (event.key === 'Enter') { event.preventDefault(); addManual() } }} />
|
|
740
|
+
<button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
|
|
741
|
+
</div>
|
|
742
|
+
{props.otherChannels.length > 0 ? (
|
|
743
|
+
<div className={css.manualModelRow}>
|
|
744
|
+
<select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
|
|
745
|
+
<option value="">{t('channels.copyFrom')}</option>
|
|
746
|
+
{props.otherChannels.map(other => (
|
|
747
|
+
<option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
|
|
748
|
+
))}
|
|
749
|
+
</select>
|
|
750
|
+
<button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
|
|
751
|
+
</div>
|
|
752
|
+
) : null}
|
|
753
|
+
</div>
|
|
754
|
+
|
|
755
|
+
{candidates !== null && candidates.length > 0 ? (
|
|
756
|
+
<div className={css.modelCandidateList}>
|
|
757
|
+
<span className={css.modelCandidateLabel}>
|
|
758
|
+
{t('channels.candidatesTitle')}
|
|
759
|
+
</span>
|
|
760
|
+
{candidates.map(candidate => {
|
|
761
|
+
const selected = channel.models.some(model => model.alias === candidate)
|
|
762
|
+
const entry = describeModel(candidate)
|
|
763
|
+
return (
|
|
764
|
+
<label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
|
|
765
|
+
<input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
|
|
766
|
+
const merged = selected
|
|
767
|
+
? channel.models.filter(model => model.alias !== candidate)
|
|
768
|
+
: [...channel.models, { alias: candidate, id: candidate }]
|
|
769
|
+
props.onSetModels(merged)
|
|
770
|
+
}} />
|
|
771
|
+
<span>{candidate}</span>
|
|
772
|
+
{!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
|
|
773
|
+
</label>
|
|
774
|
+
)
|
|
775
|
+
})}
|
|
776
|
+
</div>
|
|
777
|
+
) : null}
|
|
778
|
+
|
|
779
|
+
<div className={css.editorDivider} />
|
|
780
|
+
|
|
781
|
+
<div className={css.editorFooter}>
|
|
782
|
+
{props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
|
|
783
|
+
<button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
|
|
784
|
+
)}
|
|
785
|
+
<span className={css.spacer} />
|
|
786
|
+
{removeOpen
|
|
787
|
+
? (
|
|
788
|
+
<>
|
|
789
|
+
<button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
|
|
790
|
+
<button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
|
|
791
|
+
</>
|
|
792
|
+
)
|
|
793
|
+
: (
|
|
794
|
+
<button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
|
|
795
|
+
)}
|
|
796
|
+
</div>
|
|
797
|
+
</div>
|
|
798
|
+
</div>
|
|
799
|
+
)
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Props every field control needs regardless of its value type. */
|
|
803
|
+
interface FieldProps {
|
|
804
|
+
/** Stable id associating the label with its control. */
|
|
805
|
+
id: string
|
|
806
|
+
/** Visible label. */
|
|
807
|
+
label: string
|
|
808
|
+
/** One-line explanation rendered under the control. */
|
|
809
|
+
hint: string
|
|
810
|
+
/** Draft text this control renders. */
|
|
811
|
+
text: string
|
|
812
|
+
/** True when saving would leave a user-layer entry for this field. */
|
|
813
|
+
overridden: boolean
|
|
814
|
+
/** True when the draft is not a value this field accepts. */
|
|
815
|
+
invalid: boolean
|
|
816
|
+
/** Copy for the overridden badge. */
|
|
817
|
+
overriddenLabel: string
|
|
818
|
+
/** Copy for the reset control. */
|
|
819
|
+
resetLabel: string
|
|
820
|
+
/** Copy shown in place of the hint while the draft is invalid. */
|
|
821
|
+
invalidLabel: string
|
|
822
|
+
/** Disables every control (read-only document, or an unavailable namespace). */
|
|
823
|
+
disabled: boolean
|
|
824
|
+
/** Stage draft text. */
|
|
825
|
+
onEdit: (text: string) => void
|
|
826
|
+
/** Stage a clear so the field re-inherits the composition layer. */
|
|
827
|
+
onReset: () => void
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** A staged value field; `secret` renders a password control. */
|
|
831
|
+
function ValueField(props: FieldProps & {
|
|
832
|
+
/** Render a password control. */
|
|
833
|
+
secret?: boolean
|
|
834
|
+
/** Placeholder shown while the draft is empty. */
|
|
835
|
+
placeholder?: string
|
|
836
|
+
/** Label of the dedicated clear control (secret fields). */
|
|
837
|
+
clearLabel?: string
|
|
838
|
+
/** Stage a clear of the stored secret. */
|
|
839
|
+
onClear?: () => void
|
|
840
|
+
/** Whether a stored secret exists (enables the clear control). */
|
|
841
|
+
canClear?: boolean
|
|
842
|
+
}) {
|
|
843
|
+
return (
|
|
844
|
+
<div className={css.field}>
|
|
845
|
+
<div className={css.head}>
|
|
846
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
847
|
+
{props.overridden
|
|
848
|
+
? (
|
|
849
|
+
<span className={css.badges}>
|
|
850
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
851
|
+
<button
|
|
852
|
+
type="button"
|
|
853
|
+
className={css.reset}
|
|
854
|
+
disabled={props.disabled}
|
|
855
|
+
onClick={props.onReset}
|
|
856
|
+
>
|
|
857
|
+
{props.resetLabel}
|
|
858
|
+
</button>
|
|
859
|
+
</span>
|
|
860
|
+
)
|
|
861
|
+
: null}
|
|
862
|
+
{props.secret === true && props.canClear === true
|
|
863
|
+
? (
|
|
864
|
+
<button
|
|
865
|
+
type="button"
|
|
866
|
+
className={css.reset}
|
|
867
|
+
disabled={props.disabled}
|
|
868
|
+
onClick={props.onClear}
|
|
869
|
+
>
|
|
870
|
+
{props.clearLabel ?? props.resetLabel}
|
|
871
|
+
</button>
|
|
872
|
+
)
|
|
873
|
+
: null}
|
|
874
|
+
</div>
|
|
875
|
+
<input
|
|
876
|
+
id={props.id}
|
|
877
|
+
className={props.invalid ? css.inputInvalid : css.input}
|
|
878
|
+
type={props.secret === true ? 'password' : 'text'}
|
|
879
|
+
autoComplete={props.secret === true ? 'off' : undefined}
|
|
880
|
+
{...props.invalid ? { 'aria-invalid': true } : {}}
|
|
881
|
+
value={props.text}
|
|
882
|
+
placeholder={props.placeholder ?? ''}
|
|
883
|
+
disabled={props.disabled}
|
|
884
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
885
|
+
/>
|
|
886
|
+
<p className={props.invalid ? css.invalid : css.hint}>
|
|
887
|
+
{props.invalid ? props.invalidLabel : props.hint}
|
|
888
|
+
</p>
|
|
889
|
+
</div>
|
|
890
|
+
)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** A staged boolean field: 继承 / 开 / 关. */
|
|
894
|
+
function BooleanField(props: FieldProps & {
|
|
895
|
+
/** Copy for the inherit option. */
|
|
896
|
+
inheritLabel: string
|
|
897
|
+
/** Copy for the on option. */
|
|
898
|
+
onLabel: string
|
|
899
|
+
/** Copy for the off option. */
|
|
900
|
+
offLabel: string
|
|
901
|
+
}) {
|
|
902
|
+
return (
|
|
903
|
+
<div className={css.field}>
|
|
904
|
+
<div className={css.head}>
|
|
905
|
+
<label className={css.label} htmlFor={props.id}>{props.label}</label>
|
|
906
|
+
{props.overridden
|
|
907
|
+
? (
|
|
908
|
+
<span className={css.badges}>
|
|
909
|
+
<span className={css.badge}>{props.overriddenLabel}</span>
|
|
910
|
+
<button
|
|
911
|
+
type="button"
|
|
912
|
+
className={css.reset}
|
|
913
|
+
disabled={props.disabled}
|
|
914
|
+
onClick={props.onReset}
|
|
915
|
+
>
|
|
916
|
+
{props.resetLabel}
|
|
917
|
+
</button>
|
|
918
|
+
</span>
|
|
919
|
+
)
|
|
920
|
+
: null}
|
|
921
|
+
</div>
|
|
922
|
+
<select
|
|
923
|
+
id={props.id}
|
|
924
|
+
className={css.select}
|
|
925
|
+
value={props.text}
|
|
926
|
+
disabled={props.disabled}
|
|
927
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
928
|
+
>
|
|
929
|
+
<option value="">{props.inheritLabel}</option>
|
|
930
|
+
<option value="true">{props.onLabel}</option>
|
|
931
|
+
<option value="false">{props.offLabel}</option>
|
|
932
|
+
</select>
|
|
933
|
+
<p className={css.hint}>{props.hint}</p>
|
|
934
|
+
</div>
|
|
935
|
+
)
|
|
936
|
+
}
|