@dickpy/dsh-imagegen 1.2.2 → 1.3.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/README.md +24 -11
- package/docs/images/community-qq.png +0 -0
- package/lib/client.js +2040 -906
- package/lib/client.js.map +1 -1
- package/lib/index.js +710 -109
- package/package.json +2 -2
- package/src/agent-image-tools.ts +132 -30
- package/src/client/ImageGenPanel.tsx +56 -20
- package/src/client/SettingsCard.tsx +505 -196
- package/src/client/channels-form.ts +263 -0
- package/src/client/image-toolview.tsx +19 -12
- package/src/client/locales.ts +112 -2
- package/src/client/settings-card.module.css +487 -0
- package/src/client/settings-scope.ts +56 -8
- package/src/engine.ts +107 -8
- package/src/gallery-store.ts +6 -0
- package/src/generation-runtime.ts +32 -5
- package/src/history-store.ts +6 -0
- package/src/image-format.ts +11 -0
- package/src/image-models.ts +1 -1
- package/src/index.ts +153 -47
- package/src/model-catalog.ts +98 -0
- package/src/presets.ts +63 -0
- package/src/protocol.ts +81 -6
- package/src/routes.ts +192 -24
|
@@ -1,27 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The dsh-imagegen settings card:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
|
-
import { useState } from 'react'
|
|
15
|
+
import { useEffect, useRef, useState } from 'react'
|
|
10
16
|
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
11
17
|
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
|
-
import { CardForm, booleanField, secretField,
|
|
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'
|
|
13
20
|
import type { ImageGenScope } from './settings-scope.ts'
|
|
14
|
-
import {
|
|
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'
|
|
15
24
|
import css from './settings-card.module.css'
|
|
16
25
|
|
|
17
|
-
/** The fields this card
|
|
26
|
+
/** The global (non-channel) fields this card's staged form edits. */
|
|
18
27
|
export interface ImageGenSettings {
|
|
19
28
|
enabled?: boolean
|
|
20
29
|
announceToAgent?: boolean
|
|
21
30
|
allowAgentImageGeneration?: boolean
|
|
22
|
-
apiUrl?: string
|
|
23
|
-
apiKey?: string
|
|
24
|
-
imageModels?: string[]
|
|
25
31
|
promptApiUrl?: string
|
|
26
32
|
promptApiKey?: string
|
|
27
33
|
promptModel?: string
|
|
@@ -29,16 +35,13 @@ export interface ImageGenSettings {
|
|
|
29
35
|
|
|
30
36
|
/** What the card renders. */
|
|
31
37
|
export interface ImageGenSettingsCardState extends CardShell {
|
|
38
|
+
/** Channel list staging (channels + per-channel key edits + default). */
|
|
39
|
+
channels: ChannelsFormState
|
|
32
40
|
/** Master switch. */
|
|
33
41
|
enabled: CardFieldState
|
|
34
42
|
/** System-prompt announcement flag. */
|
|
35
43
|
announceToAgent: CardFieldState
|
|
36
44
|
allowAgentImageGeneration: CardFieldState
|
|
37
|
-
/** API base URL. */
|
|
38
|
-
apiUrl: CardFieldState
|
|
39
|
-
/** API key draft (the stored value is never rendered). */
|
|
40
|
-
apiKey: CardFieldState
|
|
41
|
-
imageModels: CardFieldState
|
|
42
45
|
promptApiUrl: CardFieldState
|
|
43
46
|
promptApiKey: CardFieldState
|
|
44
47
|
promptModel: CardFieldState
|
|
@@ -46,17 +49,18 @@ export interface ImageGenSettingsCardState extends CardShell {
|
|
|
46
49
|
|
|
47
50
|
/** The registration-side face the card's slot entry injects. */
|
|
48
51
|
export interface ImageGenSettingsCardFace extends CardActions {
|
|
52
|
+
/** Channel staging actions (committed together with the card's save). */
|
|
53
|
+
channels: ChannelsFormActions
|
|
49
54
|
hooks: {
|
|
50
55
|
/** Card snapshot bound by the renderer as useImageGenSettingsCard. */
|
|
51
56
|
imageGenSettingsCard: SnapshotStore<ImageGenSettingsCardState>
|
|
52
|
-
/** Whether a secret (apiKey) is currently stored. */
|
|
53
|
-
imageGenKeySet: SnapshotStore<boolean>
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
/** Bridges the imagegen scope onto the card's staged
|
|
60
|
+
/** Bridges the imagegen scope onto the card's staged forms. */
|
|
58
61
|
export class ImageGenSettingsCardController {
|
|
59
62
|
private readonly form: CardForm<ImageGenSettings>
|
|
63
|
+
private readonly channelsForm: ChannelsForm
|
|
60
64
|
|
|
61
65
|
/** @param scope - the bound bridge scope for the dsh-imagegen namespace. */
|
|
62
66
|
constructor(private readonly scope: ImageGenScope) {
|
|
@@ -64,28 +68,24 @@ export class ImageGenSettingsCardController {
|
|
|
64
68
|
booleanField('enabled'),
|
|
65
69
|
booleanField('announceToAgent'),
|
|
66
70
|
booleanField('allowAgentImageGeneration'),
|
|
67
|
-
textField('apiUrl'),
|
|
68
|
-
secretField('apiKey'),
|
|
69
|
-
stringListField('imageModels'),
|
|
70
71
|
textField('promptApiUrl'),
|
|
71
72
|
secretField('promptApiKey'),
|
|
72
73
|
textField('promptModel'),
|
|
73
74
|
], {
|
|
74
|
-
|
|
75
|
-
// judged by the namespace's secrets sidecar instead.
|
|
76
|
-
secretSettled: () => this.scope.getKeySetSnapshot(),
|
|
75
|
+
secretSettled: () => this.scope.getSecretSetSnapshot('promptApiKey'),
|
|
77
76
|
})
|
|
77
|
+
this.channelsForm = new ChannelsForm(scope)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
private projection(): ImageGenSettingsCardState {
|
|
81
|
+
const shell = this.form.shell()
|
|
81
82
|
return {
|
|
82
|
-
...
|
|
83
|
+
...shell,
|
|
84
|
+
dirty: shell.dirty || this.channelsForm.snapshot().dirty,
|
|
85
|
+
channels: this.channelsForm.snapshot(),
|
|
83
86
|
enabled: this.form.field('enabled'),
|
|
84
87
|
announceToAgent: this.form.field('announceToAgent'),
|
|
85
88
|
allowAgentImageGeneration: this.form.field('allowAgentImageGeneration'),
|
|
86
|
-
apiUrl: this.form.field('apiUrl'),
|
|
87
|
-
apiKey: this.form.field('apiKey'),
|
|
88
|
-
imageModels: this.form.field('imageModels'),
|
|
89
89
|
promptApiUrl: this.form.field('promptApiUrl'),
|
|
90
90
|
promptApiKey: this.form.field('promptApiKey'),
|
|
91
91
|
promptModel: this.form.field('promptModel'),
|
|
@@ -94,17 +94,16 @@ export class ImageGenSettingsCardController {
|
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
96
|
* Build the face the card's slot registration injects.
|
|
97
|
-
* @returns the card's snapshot
|
|
97
|
+
* @returns the card's snapshot and the form/channel actions.
|
|
98
98
|
*/
|
|
99
99
|
inject(): ImageGenSettingsCardFace {
|
|
100
100
|
const cardStore = this.form.bind(() => this.projection())
|
|
101
|
-
|
|
102
|
-
this.scope.subscribeKeySet(() => { keySetStore.set(this.scope.getKeySetSnapshot()) })
|
|
101
|
+
this.channelsForm.subscribe(() => { cardStore.set(this.projection()) })
|
|
103
102
|
return {
|
|
104
103
|
hooks: {
|
|
105
104
|
imageGenSettingsCard: cardStore,
|
|
106
|
-
imageGenKeySet: keySetStore,
|
|
107
105
|
},
|
|
106
|
+
channels: this.channelsForm.actions(),
|
|
108
107
|
...this.form.actions(),
|
|
109
108
|
}
|
|
110
109
|
}
|
|
@@ -116,6 +115,12 @@ export type ImageGenSettingsCardProps =
|
|
|
116
115
|
& PropsLocale<'dsh-imagegen'>
|
|
117
116
|
& InjectFace<ImageGenSettingsCardFace>
|
|
118
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
|
+
|
|
119
124
|
/**
|
|
120
125
|
* Render the card.
|
|
121
126
|
* @param props - locale copy, the card snapshot, and the form actions.
|
|
@@ -124,24 +129,37 @@ export type ImageGenSettingsCardProps =
|
|
|
124
129
|
export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
125
130
|
const { t } = props
|
|
126
131
|
const state = props.useImageGenSettingsCard(snapshot => snapshot)
|
|
127
|
-
const keySet = props.useImageGenKeySet(snapshot => snapshot)
|
|
128
132
|
const [open, setOpen] = useState(false)
|
|
133
|
+
// Global-section local states (prompt enhancement etc.).
|
|
129
134
|
const [promptModels, setPromptModels] = useState<string[]>([])
|
|
130
135
|
const [loadingPromptModels, setLoadingPromptModels] = useState(false)
|
|
131
136
|
const [promptModelsError, setPromptModelsError] = useState<string | null>(null)
|
|
132
|
-
const [imageModelCandidates, setImageModelCandidates] = useState<string[]>([])
|
|
133
|
-
const [loadingImageModels, setLoadingImageModels] = useState(false)
|
|
134
|
-
const [imageModelsError, setImageModelsError] = useState<string | null>(null)
|
|
135
|
-
const [manualModelsOpen, setManualModelsOpen] = useState(false)
|
|
136
|
-
const [manualModel, setManualModel] = useState('')
|
|
137
|
-
const [enhancementOpen, setEnhancementOpen] = useState(false)
|
|
138
137
|
const [manualPromptModelOpen, setManualPromptModelOpen] = useState(false)
|
|
139
138
|
const [manualPromptModel, setManualPromptModel] = useState('')
|
|
139
|
+
const [enhancementOpen, setEnhancementOpen] = useState(false)
|
|
140
140
|
const [promptApiOpen, setPromptApiOpen] = useState(false)
|
|
141
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
|
+
|
|
142
160
|
if (!state.available) return null
|
|
143
161
|
const title = t('settings.title')
|
|
144
|
-
const blocked = !state.dirty || state.invalid || state.saving
|
|
162
|
+
const blocked = !state.dirty || state.invalid || state.saving || state.channels.saving
|
|
145
163
|
const disabled = !state.writable
|
|
146
164
|
const fieldProps = {
|
|
147
165
|
overriddenLabel: t('settings.overridden'),
|
|
@@ -149,6 +167,7 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
149
167
|
invalidLabel: t('settings.invalidNumber'),
|
|
150
168
|
disabled,
|
|
151
169
|
}
|
|
170
|
+
|
|
152
171
|
if (!state.exposed) {
|
|
153
172
|
return (
|
|
154
173
|
<li className={css.card}>
|
|
@@ -175,6 +194,10 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
175
194
|
</li>
|
|
176
195
|
)
|
|
177
196
|
}
|
|
197
|
+
|
|
198
|
+
const channels = state.channels.channels
|
|
199
|
+
const editing = editingId === null ? undefined : channels.find(channel => channel.id === editingId)
|
|
200
|
+
|
|
178
201
|
return (
|
|
179
202
|
<li className={css.card}>
|
|
180
203
|
<button
|
|
@@ -195,131 +218,90 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
195
218
|
? (
|
|
196
219
|
<div className={css.body}>
|
|
197
220
|
{!state.writable ? <p className={css.readOnly} role="status">{t('settings.readOnly')}</p> : null}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
label={t('settings.apiUrl')}
|
|
201
|
-
hint={t('settings.apiUrlHint')}
|
|
202
|
-
placeholder="https://api.openai.com/v1"
|
|
203
|
-
{...fieldProps}
|
|
204
|
-
{...state.apiUrl}
|
|
205
|
-
onEdit={(text) => { props.edit('apiUrl', text) }}
|
|
206
|
-
onReset={() => { props.resetField('apiUrl') }}
|
|
207
|
-
/>
|
|
208
|
-
<ValueField
|
|
209
|
-
id="dsh-imagegen-settings-apikey"
|
|
210
|
-
label={t('settings.apiKey')}
|
|
211
|
-
hint={keySet ? t('settings.apiKeySet') : t('settings.apiKeyHint')}
|
|
212
|
-
placeholder="sk-…"
|
|
213
|
-
secret
|
|
214
|
-
{...fieldProps}
|
|
215
|
-
{...state.apiKey}
|
|
216
|
-
overridden={false}
|
|
217
|
-
onEdit={(text) => { props.edit('apiKey', text) }}
|
|
218
|
-
onReset={() => { props.resetField('apiKey') }}
|
|
219
|
-
clearLabel={t('settings.apiKeyClear')}
|
|
220
|
-
onClear={() => { props.resetField('apiKey') }}
|
|
221
|
-
canClear={keySet}
|
|
222
|
-
/>
|
|
223
|
-
<div className={css.sectionDivider} />
|
|
224
|
-
<section className={css.modelSection} aria-label={t('settings.imageModelsTitle')}>
|
|
221
|
+
|
|
222
|
+
<section className={css.channelSection} aria-label={t('channels.title')}>
|
|
225
223
|
<div className={css.sectionHeader}>
|
|
226
224
|
<div>
|
|
227
|
-
<h3 className={css.sectionTitle}>{t('
|
|
228
|
-
<p className={css.sectionHint}>{t('
|
|
225
|
+
<h3 className={css.sectionTitle}>{t('channels.title')}</h3>
|
|
226
|
+
<p className={css.sectionHint}>{t('channels.hint')}</p>
|
|
229
227
|
</div>
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
{channel.apiUrl !== '' ? <span className={css.channelHost}>{hostOf(channel.apiUrl)}</span> : null}
|
|
253
|
+
<span className={css.channelBadge} data-warn={keyHeld ? undefined : ''}>{keyHeld ? t('channels.keySet') : t('channels.keyMissing')}</span>
|
|
254
|
+
<span className={css.channelBadge} data-warn={channel.models.length > 0 ? undefined : ''}>{channel.models.length > 0 ? t('channels.modelCount', { n: channel.models.length }) : t('channels.noModels')}</span>
|
|
255
|
+
{isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : null}
|
|
256
|
+
</span>
|
|
257
|
+
</button>
|
|
258
|
+
<button type="button" className={css.channelAction} onClick={() => { setEditingId(channel.id) }}>{t('channels.edit')}</button>
|
|
259
|
+
<button type="button" className={css.channelAction} data-danger onClick={() => { setConfirmDeleteId(channel.id) }}>{t('channels.delete')}</button>
|
|
260
|
+
</li>
|
|
261
|
+
)
|
|
262
|
+
})}
|
|
263
|
+
</ul>
|
|
264
|
+
)}
|
|
265
|
+
<div className={css.channelControls}>
|
|
266
|
+
{open && presetPickerOpen ? (
|
|
267
|
+
<PresetPicker
|
|
268
|
+
t={t}
|
|
269
|
+
presets={presets}
|
|
270
|
+
error={presetError}
|
|
271
|
+
disabled={state.writable === false}
|
|
272
|
+
onLoad={() => {
|
|
273
|
+
setPresetError(null)
|
|
274
|
+
void fetch(PRESETS_API, { method: 'POST' })
|
|
238
275
|
.then(async response => {
|
|
239
|
-
const body = await response.json() as { ok?: boolean;
|
|
240
|
-
if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
241
|
-
|
|
276
|
+
const body = await response.json() as { ok?: boolean; presets?: PresetProviderView[]; message?: string }
|
|
277
|
+
if (!response.ok || body.ok !== true || body.presets === undefined) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
278
|
+
setPresets(body.presets)
|
|
242
279
|
})
|
|
243
|
-
.catch(error => {
|
|
244
|
-
.finally(() => { setLoadingImageModels(false) })
|
|
280
|
+
.catch(error => { setPresetError(error instanceof Error ? error.message : String(error)) })
|
|
245
281
|
}}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
282
|
+
onPick={(preset) => {
|
|
283
|
+
const draft = newChannelDraft(preset)
|
|
284
|
+
props.channels.setChannels([...channels, draft])
|
|
285
|
+
setPresetPickerOpen(false)
|
|
286
|
+
setEditingId(draft.id)
|
|
287
|
+
}}
|
|
288
|
+
onCustom={() => {
|
|
289
|
+
const draft = newChannelDraft(undefined)
|
|
290
|
+
props.channels.setChannels([...channels, draft])
|
|
291
|
+
setPresetPickerOpen(false)
|
|
292
|
+
setEditingId(draft.id)
|
|
293
|
+
}}
|
|
294
|
+
onClose={() => { setPresetPickerOpen(false) }}
|
|
295
|
+
/>
|
|
296
|
+
) : null}
|
|
297
|
+
|
|
298
|
+
<div className={css.channelAddRow}>
|
|
299
|
+
<button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { setPresetError(null); setPresetPickerOpen(true) }}>+ {t('channels.addProvider')}</button>
|
|
300
|
+
<button type="button" className={css.channelAdd} disabled={disabled} onClick={() => { addCustomChannel(channels, props.channels, setEditingId) }}>+ {t('channels.addCustom')}</button>
|
|
249
301
|
</div>
|
|
250
|
-
<div className={css.modelSummary}>
|
|
251
|
-
{splitModels(state.imageModels.text).map(model => (
|
|
252
|
-
<span key={model} className={css.modelChip}>
|
|
253
|
-
<span>{model}</span>
|
|
254
|
-
<button
|
|
255
|
-
type="button"
|
|
256
|
-
disabled={disabled}
|
|
257
|
-
aria-label={`${t('settings.removeModel')}: ${model}`}
|
|
258
|
-
onClick={() => { props.edit('imageModels', splitModels(state.imageModels.text).filter(value => value !== model).join('\n')) }}
|
|
259
|
-
>
|
|
260
|
-
×
|
|
261
|
-
</button>
|
|
262
|
-
</span>
|
|
263
|
-
))}
|
|
264
|
-
<button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualModelsOpen(open => !open) }}>
|
|
265
|
-
{manualModelsOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
|
|
266
|
-
</button>
|
|
267
302
|
</div>
|
|
268
|
-
{manualModelsOpen ? (
|
|
269
|
-
<div className={css.manualModelRow}>
|
|
270
|
-
<input
|
|
271
|
-
className={css.input}
|
|
272
|
-
value={manualModel}
|
|
273
|
-
placeholder={t('settings.addModelPlaceholder')}
|
|
274
|
-
disabled={disabled}
|
|
275
|
-
onChange={event => { setManualModel(event.target.value) }}
|
|
276
|
-
onKeyDown={event => {
|
|
277
|
-
if (event.key !== 'Enter') return
|
|
278
|
-
event.preventDefault()
|
|
279
|
-
const next = manualModel.trim()
|
|
280
|
-
if (next === '') return
|
|
281
|
-
props.edit('imageModels', [...splitModels(state.imageModels.text), next].join('\n'))
|
|
282
|
-
setManualModel('')
|
|
283
|
-
}}
|
|
284
|
-
/>
|
|
285
|
-
<button
|
|
286
|
-
type="button"
|
|
287
|
-
className={css.addModel}
|
|
288
|
-
disabled={disabled || manualModel.trim() === ''}
|
|
289
|
-
onClick={() => {
|
|
290
|
-
props.edit('imageModels', [...splitModels(state.imageModels.text), manualModel.trim()].join('\n'))
|
|
291
|
-
setManualModel('')
|
|
292
|
-
}}
|
|
293
|
-
>
|
|
294
|
-
{t('settings.addModelConfirm')}
|
|
295
|
-
</button>
|
|
296
|
-
</div>
|
|
297
|
-
) : null}
|
|
298
|
-
{imageModelCandidates.length > 0 ? (
|
|
299
|
-
<div className={css.modelCandidateList} role="group" aria-label={t('settings.imageModelsCandidates')}>
|
|
300
|
-
<span className={css.modelCandidateLabel}>{t('settings.imageModelsCandidates')}</span>
|
|
301
|
-
{imageModelCandidates.map(candidate => {
|
|
302
|
-
const selected = splitModels(state.imageModels.text).includes(candidate)
|
|
303
|
-
return (
|
|
304
|
-
<label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
|
|
305
|
-
<input
|
|
306
|
-
type="checkbox"
|
|
307
|
-
checked={selected}
|
|
308
|
-
disabled={disabled}
|
|
309
|
-
onChange={() => {
|
|
310
|
-
const selectedModels = splitModels(state.imageModels.text)
|
|
311
|
-
const next = selected ? selectedModels.filter(model => model !== candidate) : [...selectedModels, candidate]
|
|
312
|
-
props.edit('imageModels', next.join('\n'))
|
|
313
|
-
}}
|
|
314
|
-
/>
|
|
315
|
-
<span>{candidate}</span>
|
|
316
|
-
</label>
|
|
317
|
-
)
|
|
318
|
-
})}
|
|
319
|
-
</div>
|
|
320
|
-
) : null}
|
|
321
|
-
{imageModelsError !== null ? <p className={css.failed} role="status">{imageModelsError}</p> : null}
|
|
322
303
|
</section>
|
|
304
|
+
|
|
323
305
|
<button
|
|
324
306
|
type="button"
|
|
325
307
|
className={css.disclosure}
|
|
@@ -364,16 +346,14 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
364
346
|
<button type="button" disabled={disabled} aria-label={`${t('settings.removeModel')}: ${state.promptModel.text}`} onClick={() => { props.edit('promptModel', '') }}>×</button>
|
|
365
347
|
</span>
|
|
366
348
|
) : null}
|
|
367
|
-
<button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open) }}>
|
|
349
|
+
<button type="button" className={css.addModel} disabled={disabled} onClick={() => { setManualPromptModelOpen(open => !open); setEnhancementOpen(true) }}>
|
|
368
350
|
{manualPromptModelOpen ? t('settings.cancelAddModel') : t('settings.addModel')}
|
|
369
351
|
</button>
|
|
370
352
|
</div>
|
|
371
353
|
{manualPromptModelOpen ? (
|
|
372
354
|
<div className={css.manualModelRow}>
|
|
373
355
|
<input className={css.input} value={manualPromptModel} placeholder={t('settings.addPromptModelPlaceholder')} disabled={disabled} onChange={event => { setManualPromptModel(event.target.value) }} />
|
|
374
|
-
<button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>
|
|
375
|
-
{t('settings.addModelConfirm')}
|
|
376
|
-
</button>
|
|
356
|
+
<button type="button" className={css.addModel} disabled={disabled || manualPromptModel.trim() === ''} onClick={() => { props.edit('promptModel', manualPromptModel); setManualPromptModel('') }}>{t('settings.addModelConfirm')}</button>
|
|
377
357
|
</div>
|
|
378
358
|
) : null}
|
|
379
359
|
{promptModels.length > 0 ? (
|
|
@@ -425,6 +405,7 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
425
405
|
/>
|
|
426
406
|
</div> : null}
|
|
427
407
|
</section> : null}
|
|
408
|
+
|
|
428
409
|
<button type="button" className={css.disclosure} aria-expanded={moreOpen} onClick={() => { setMoreOpen(open => !open) }}>
|
|
429
410
|
<span>{t('settings.moreOptions')}</span>
|
|
430
411
|
<span aria-hidden="true">{moreOpen ? '⌃' : '⌄'}</span>
|
|
@@ -468,12 +449,12 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
468
449
|
/>
|
|
469
450
|
</div> : null}
|
|
470
451
|
<div className={css.footer}>
|
|
471
|
-
{state.failed ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
|
|
452
|
+
{(state.failed || state.channels.failed) ? <p className={css.failed} role="status">{t('settings.saveFailed')}</p> : null}
|
|
472
453
|
<button
|
|
473
454
|
type="button"
|
|
474
455
|
className={css.discard}
|
|
475
|
-
disabled={!state.dirty || state.saving}
|
|
476
|
-
onClick={props.discard}
|
|
456
|
+
disabled={!state.dirty || state.saving || state.channels.saving}
|
|
457
|
+
onClick={() => { props.discard(); props.channels.discard() }}
|
|
477
458
|
>
|
|
478
459
|
{t('settings.discard')}
|
|
479
460
|
</button>
|
|
@@ -481,18 +462,364 @@ export function ImageGenSettingsCard(props: ImageGenSettingsCardProps) {
|
|
|
481
462
|
type="button"
|
|
482
463
|
className={css.save}
|
|
483
464
|
disabled={blocked}
|
|
484
|
-
onClick={props.save}
|
|
465
|
+
onClick={() => { void props.channels.commit(); void props.save() }}
|
|
485
466
|
>
|
|
486
|
-
{t(!state.saving ? 'settings.save' : 'settings.saving')}
|
|
467
|
+
{t(!state.saving && !state.channels.saving ? 'settings.save' : 'settings.saving')}
|
|
487
468
|
</button>
|
|
488
469
|
</div>
|
|
489
470
|
</div>
|
|
490
471
|
)
|
|
491
472
|
: null}
|
|
473
|
+
|
|
474
|
+
{open && editing !== undefined ? (
|
|
475
|
+
<ChannelEditor
|
|
476
|
+
key={editing.id}
|
|
477
|
+
t={t}
|
|
478
|
+
channel={editing}
|
|
479
|
+
keyHeld={state.channels.keySet[editing.id] === true}
|
|
480
|
+
usage={usage}
|
|
481
|
+
otherChannels={channels.filter(channel => channel.id !== editing.id)}
|
|
482
|
+
isDefault={editing.id === state.channels.defaultChannelId}
|
|
483
|
+
writable={state.writable}
|
|
484
|
+
onPatch={(patch) => { replaceChannel(channels, editing.id, patch, props.channels) }}
|
|
485
|
+
onSetModels={(models) => { props.channels.setChannels(channels.map(channel => channel.id === editing.id ? { ...channel, models } : channel)) }}
|
|
486
|
+
onSetKey={(value) => { props.channels.setChannelKey(editing.id, value) }}
|
|
487
|
+
onSetDefault={() => { props.channels.setDefaultChannel(editing.id) }}
|
|
488
|
+
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) }}
|
|
489
|
+
onClose={() => { setEditingId(null) }}
|
|
490
|
+
/>
|
|
491
|
+
) : null}
|
|
492
|
+
|
|
492
493
|
</li>
|
|
493
494
|
)
|
|
494
495
|
}
|
|
495
496
|
|
|
497
|
+
/** Channel row + dialog helpers -------------------------------------------------- */
|
|
498
|
+
|
|
499
|
+
function newChannelDraft(preset: PresetProviderView | undefined): ChannelDraft {
|
|
500
|
+
return {
|
|
501
|
+
id: clientId(),
|
|
502
|
+
preset: preset?.id ?? '',
|
|
503
|
+
name: preset?.name ?? '',
|
|
504
|
+
apiUrl: preset?.apiUrl ?? '',
|
|
505
|
+
models: (preset?.models ?? []).map(model => ({ ...model })),
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function addCustomChannel(channels: ChannelDraft[], form: ChannelsFormActions, openEditor: (id: string) => void): void {
|
|
510
|
+
const draft = newChannelDraft(undefined)
|
|
511
|
+
form.setChannels([...channels, draft])
|
|
512
|
+
openEditor(draft.id)
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Patch one field (or models) of one staged channel. */
|
|
516
|
+
function replaceChannel(channels: ChannelDraft[], id: string, patch: Partial<ChannelDraft>, form: ChannelsFormActions): void {
|
|
517
|
+
form.setChannels(channels.map(channel => channel.id === id ? { ...channel, ...patch } : channel))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function hostOf(apiUrl: string): string {
|
|
521
|
+
try {
|
|
522
|
+
return new URL(apiUrl).hostname
|
|
523
|
+
} catch {
|
|
524
|
+
return apiUrl
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function clientId(): string {
|
|
529
|
+
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : undefined
|
|
530
|
+
return random ?? `ch-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Built-in provider picker, expanded inside the settings card. */
|
|
534
|
+
function PresetPicker(props: {
|
|
535
|
+
t: (key: ImageGenKey, params?: Record<string, string | number>) => string
|
|
536
|
+
presets: PresetProviderView[]
|
|
537
|
+
error: string | null
|
|
538
|
+
disabled: boolean
|
|
539
|
+
onLoad: () => void
|
|
540
|
+
onPick: (preset: PresetProviderView) => void
|
|
541
|
+
onCustom: () => void
|
|
542
|
+
onClose: () => void
|
|
543
|
+
}) {
|
|
544
|
+
const { t } = props
|
|
545
|
+
const loadedRef = useRef(false)
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (loadedRef.current) return
|
|
548
|
+
loadedRef.current = true
|
|
549
|
+
props.onLoad()
|
|
550
|
+
}, [])
|
|
551
|
+
return (
|
|
552
|
+
<section className={css.presetInline} aria-label={t('channels.presetPickerTitle')}>
|
|
553
|
+
<header className={css.presetInlineHeader}>
|
|
554
|
+
<div>
|
|
555
|
+
<h3 className={css.sectionTitle}>{t('channels.presetPickerTitle')}</h3>
|
|
556
|
+
<p className={css.sectionHint}>{t('channels.presetPickerHint')}</p>
|
|
557
|
+
</div>
|
|
558
|
+
<button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
|
|
559
|
+
</header>
|
|
560
|
+
<div className={css.presetList}>
|
|
561
|
+
{props.presets.map(preset => (
|
|
562
|
+
<button key={preset.id} type="button" className={css.presetRow} disabled={props.disabled} onClick={() => { props.onPick(preset) }}>
|
|
563
|
+
<span className={css.presetName}>{preset.name}</span>
|
|
564
|
+
<span className={css.presetMeta}>{preset.apiUrl} · {t('channels.presetModelsHint', { n: preset.models.length })}</span>
|
|
565
|
+
<span className={css.presetHint}>{preset.hint}</span>
|
|
566
|
+
</button>
|
|
567
|
+
))}
|
|
568
|
+
<button type="button" className={css.presetRow} data-custom disabled={props.disabled} onClick={props.onCustom}>
|
|
569
|
+
<span className={css.presetName}>+ {t('channels.addCustom')}</span>
|
|
570
|
+
<span className={css.presetHint}>{t('channels.presetCustomHint')}</span>
|
|
571
|
+
</button>
|
|
572
|
+
{props.error !== null ? <p className={css.failed} role="status">{t('channels.presetLoadFailed', { error: props.error })}</p> : null}
|
|
573
|
+
</div>
|
|
574
|
+
</section>
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Channel editor (modal): key, display name, API URL, model catalog. */
|
|
579
|
+
function ChannelEditor(props: {
|
|
580
|
+
t: (key: ImageGenKey, params?: Record<string, string | number>) => string
|
|
581
|
+
channel: ChannelDraft
|
|
582
|
+
keyHeld: boolean
|
|
583
|
+
usage: UsageCounters | null
|
|
584
|
+
otherChannels: ChannelDraft[]
|
|
585
|
+
isDefault: boolean
|
|
586
|
+
writable: boolean
|
|
587
|
+
onPatch: (patch: Partial<ChannelDraft>) => void
|
|
588
|
+
onSetModels: (models: ModelMapping[]) => void
|
|
589
|
+
onSetKey: (value: string | undefined) => void
|
|
590
|
+
onSetDefault: () => void
|
|
591
|
+
onRemove: () => void
|
|
592
|
+
onClose: () => void
|
|
593
|
+
}) {
|
|
594
|
+
const { t, channel } = props
|
|
595
|
+
const [keyDraft, setKeyDraft] = useState('')
|
|
596
|
+
const [candidates, setCandidates] = useState<string[] | null>(null)
|
|
597
|
+
const [detecting, setDetecting] = useState(false)
|
|
598
|
+
const [detectError, setDetectError] = useState<string | null>(null)
|
|
599
|
+
const [manualId, setManualId] = useState('')
|
|
600
|
+
const [removeOpen, setRemoveOpen] = useState(false)
|
|
601
|
+
const [copyFrom, setCopyFrom] = useState('')
|
|
602
|
+
|
|
603
|
+
const generatedCount = (alias: string): number => {
|
|
604
|
+
if (props.usage === null) return 0
|
|
605
|
+
const channelBucket = props.usage.byChannel[channel.id] ?? props.usage.byChannel[`name:${channel.name}`] ?? {}
|
|
606
|
+
return channelBucket[alias] ?? props.usage.totals[alias] ?? 0
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const detect = (): void => {
|
|
610
|
+
setDetecting(true)
|
|
611
|
+
setDetectError(null)
|
|
612
|
+
const payload: Record<string, unknown> = { channelId: channel.id }
|
|
613
|
+
if (channel.apiUrl.trim() !== '') payload.apiUrl = channel.apiUrl.trim()
|
|
614
|
+
if (keyDraft.trim() !== '') payload.apiKey = keyDraft.trim()
|
|
615
|
+
void fetch(IMAGE_MODEL_API.models, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
|
|
616
|
+
.then(async response => {
|
|
617
|
+
const body = await response.json() as { ok?: boolean; models?: string[]; message?: string }
|
|
618
|
+
if (!response.ok || body.ok !== true) throw new Error(body.message ?? `HTTP ${response.status}`)
|
|
619
|
+
setCandidates(body.models ?? [])
|
|
620
|
+
})
|
|
621
|
+
.catch(error => { setDetectError(error instanceof Error ? error.message : String(error)) })
|
|
622
|
+
.finally(() => { setDetecting(false) })
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Auto-detect once when the dialog opens with a complete endpoint.
|
|
626
|
+
const autoDetected = useRef(false)
|
|
627
|
+
useEffect(() => {
|
|
628
|
+
if (autoDetected.current) return
|
|
629
|
+
autoDetected.current = true
|
|
630
|
+
if (channel.apiUrl.trim() !== '' && (props.keyHeld || keyDraft.trim() !== '')) detect()
|
|
631
|
+
}, [])
|
|
632
|
+
|
|
633
|
+
const addManual = (): void => {
|
|
634
|
+
const id = manualId.trim()
|
|
635
|
+
if (id === '') return
|
|
636
|
+
const next = [...channel.models]
|
|
637
|
+
const alias = id
|
|
638
|
+
if (!next.some(model => model.alias === alias)) next.push({ alias, id })
|
|
639
|
+
props.onSetModels(next)
|
|
640
|
+
setManualId('')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const copyFromChannel = (): void => {
|
|
644
|
+
const source = props.otherChannels.find(chance => chance.id === copyFrom)
|
|
645
|
+
if (source === undefined) return
|
|
646
|
+
const merged = [...channel.models]
|
|
647
|
+
for (const model of source.models) {
|
|
648
|
+
const alias = model.alias
|
|
649
|
+
// Copy with a collision suffix so both sources stay selectable.
|
|
650
|
+
let unique = alias
|
|
651
|
+
let suffix = 2
|
|
652
|
+
while (merged.some(entry => entry.alias === unique)) unique = `${alias} (${suffix++})`
|
|
653
|
+
merged.push({ alias: unique, id: model.id })
|
|
654
|
+
}
|
|
655
|
+
props.onSetModels(merged)
|
|
656
|
+
setCopyFrom('')
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const onlyKnown = (): void => {
|
|
660
|
+
const known = (candidates ?? []).filter(id => describeModel(id).known)
|
|
661
|
+
const merged = [...channel.models]
|
|
662
|
+
for (const id of known) {
|
|
663
|
+
const alias = id
|
|
664
|
+
if (!merged.some(model => model.alias === alias)) merged.push({ alias, id })
|
|
665
|
+
}
|
|
666
|
+
props.onSetModels(merged)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const knownCount = (candidates ?? []).filter(id => describeModel(id).known).length
|
|
670
|
+
|
|
671
|
+
return (
|
|
672
|
+
<div className={css.editorBackdrop} role="dialog" aria-modal="true" aria-label={`${t('channels.editorTitle')} · ${channel.name || t('channels.untitled')}`} onClick={props.onClose}>
|
|
673
|
+
<div className={css.editorPanel} onClick={event => { event.stopPropagation() }}>
|
|
674
|
+
<header className={css.editorHeader}>
|
|
675
|
+
<div>
|
|
676
|
+
<h3 className={css.sectionTitle}>{t('channels.editorTitle')} · {channel.name || t('channels.untitled')}</h3>
|
|
677
|
+
<p className={css.sectionHint}>{t('channels.editorSaveNote')}</p>
|
|
678
|
+
</div>
|
|
679
|
+
<button type="button" className={css.editorClose} aria-label={t('preview.close')} onClick={props.onClose}>×</button>
|
|
680
|
+
</header>
|
|
681
|
+
|
|
682
|
+
<div className={css.editorField}>
|
|
683
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-name">{t('channels.displayName')}</label>
|
|
684
|
+
<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 }) }} />
|
|
685
|
+
</div>
|
|
686
|
+
<div className={css.editorField}>
|
|
687
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-url">{t('channels.apiUrl')}</label>
|
|
688
|
+
<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 }) }} />
|
|
689
|
+
</div>
|
|
690
|
+
<div className={css.editorField}>
|
|
691
|
+
<div className={css.head}>
|
|
692
|
+
<label className={css.label} htmlFor="dsh-imagegen-channel-key">{t('channels.apiKey')}</label>
|
|
693
|
+
{props.keyHeld || keyDraft !== ''
|
|
694
|
+
? (
|
|
695
|
+
<button type="button" className={css.reset} disabled={!props.writable} onClick={() => { setKeyDraft(''); props.onSetKey(undefined) }}>
|
|
696
|
+
{t('channels.keyClear')}
|
|
697
|
+
</button>
|
|
698
|
+
)
|
|
699
|
+
: null}
|
|
700
|
+
</div>
|
|
701
|
+
<input
|
|
702
|
+
id="dsh-imagegen-channel-key"
|
|
703
|
+
className={css.input}
|
|
704
|
+
type="password"
|
|
705
|
+
autoComplete="off"
|
|
706
|
+
value={keyDraft}
|
|
707
|
+
placeholder={props.keyHeld ? t('channels.keyReplaceHint') : t('channels.keyMissingHint')}
|
|
708
|
+
disabled={!props.writable}
|
|
709
|
+
onChange={event => { const value = event.target.value; setKeyDraft(value); props.onSetKey(value === '' ? undefined : value) }}
|
|
710
|
+
/>
|
|
711
|
+
</div>
|
|
712
|
+
|
|
713
|
+
<div className={css.editorDivider} />
|
|
714
|
+
|
|
715
|
+
<div className={css.editorSectionHeader}>
|
|
716
|
+
<h4 className={css.label}>{t('channels.modelCatalogTitle')}</h4>
|
|
717
|
+
<button type="button" className={css.modelFetch} disabled={!props.writable || detecting} onClick={detect}>
|
|
718
|
+
{detecting ? t('channels.detecting') : t('channels.detect')}
|
|
719
|
+
</button>
|
|
720
|
+
</div>
|
|
721
|
+
{detectError !== null ? <p className={css.failed} role="status">{t('channels.detectFailed', { error: detectError })}</p> : null}
|
|
722
|
+
{candidates !== null && detectError === null ? <p className={css.detectOk} role="status">{t('channels.detectSuccess', { n: candidates.length })}</p> : null}
|
|
723
|
+
|
|
724
|
+
{channel.models.length === 0
|
|
725
|
+
? <p className={css.sectionHint}>{t('channels.noModelsHint')}</p>
|
|
726
|
+
: (
|
|
727
|
+
<ul className={css.modelRows}>
|
|
728
|
+
{channel.models.map((model, index) => {
|
|
729
|
+
const entry = describeModel(model.id || model.alias)
|
|
730
|
+
const generated = generatedCount(model.alias)
|
|
731
|
+
return (
|
|
732
|
+
<li key={`${model.alias}-${index}`} className={css.modelRow}>
|
|
733
|
+
<div className={css.modelRowInputs}>
|
|
734
|
+
<input className={css.input} value={model.alias} aria-label={t('channels.modelAliasLabel')} disabled={!props.writable} onChange={event => {
|
|
735
|
+
const next = [...channel.models]
|
|
736
|
+
next[index] = { ...model, alias: event.target.value }
|
|
737
|
+
props.onSetModels(next)
|
|
738
|
+
}} />
|
|
739
|
+
<span className={css.modelArrow}>→</span>
|
|
740
|
+
<input className={css.input} value={model.id} aria-label={t('channels.modelIdLabel')} disabled={!props.writable} onChange={event => {
|
|
741
|
+
const next = [...channel.models]
|
|
742
|
+
next[index] = { ...model, id: event.target.value }
|
|
743
|
+
props.onSetModels(next)
|
|
744
|
+
}} />
|
|
745
|
+
</div>
|
|
746
|
+
<div className={css.modelRowBadges}>
|
|
747
|
+
<span className={css.modelBadge}>{entry.labelZh}{entry.known ? '' : ` · ${t('channels.unknownProtocol')}`}</span>
|
|
748
|
+
{generated > 0 ? <span className={css.modelBadge} data-verified>{t('channels.generated', { n: generated })}</span> : null}
|
|
749
|
+
<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>
|
|
750
|
+
</div>
|
|
751
|
+
</li>
|
|
752
|
+
)
|
|
753
|
+
})}
|
|
754
|
+
</ul>
|
|
755
|
+
)}
|
|
756
|
+
|
|
757
|
+
<div className={css.editorTools}>
|
|
758
|
+
<div className={css.manualModelRow}>
|
|
759
|
+
<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() } }} />
|
|
760
|
+
<button type="button" className={css.addModel} disabled={!props.writable || manualId.trim() === ''} onClick={addManual}>{t('channels.addModelConfirm')}</button>
|
|
761
|
+
</div>
|
|
762
|
+
{props.otherChannels.length > 0 ? (
|
|
763
|
+
<div className={css.manualModelRow}>
|
|
764
|
+
<select className={css.modelChoices} value={copyFrom} disabled={!props.writable} onChange={event => { setCopyFrom(event.target.value) }} aria-label={t('channels.copyFrom')}>
|
|
765
|
+
<option value="">{t('channels.copyFrom')}</option>
|
|
766
|
+
{props.otherChannels.map(other => (
|
|
767
|
+
<option key={other.id} value={other.id}>{other.name || t('channels.untitled')}</option>
|
|
768
|
+
))}
|
|
769
|
+
</select>
|
|
770
|
+
<button type="button" className={css.addModel} disabled={!props.writable || copyFrom === ''} onClick={copyFromChannel}>{t('channels.copyApply')}</button>
|
|
771
|
+
</div>
|
|
772
|
+
) : null}
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
{candidates !== null && candidates.length > 0 ? (
|
|
776
|
+
<div className={css.modelCandidateList}>
|
|
777
|
+
<span className={css.modelCandidateLabel}>
|
|
778
|
+
{t('channels.candidatesTitle')}
|
|
779
|
+
<button type="button" className={css.inlineDisclosure} disabled={!props.writable || knownCount === 0} onClick={onlyKnown}>{t('channels.candidatesQuick')}</button>
|
|
780
|
+
</span>
|
|
781
|
+
{candidates.map(candidate => {
|
|
782
|
+
const selected = channel.models.some(model => model.alias === candidate)
|
|
783
|
+
const entry = describeModel(candidate)
|
|
784
|
+
return (
|
|
785
|
+
<label key={candidate} className={css.modelCandidate} data-selected={selected ? '' : undefined}>
|
|
786
|
+
<input type="checkbox" checked={selected} disabled={!props.writable} onChange={() => {
|
|
787
|
+
const merged = selected
|
|
788
|
+
? channel.models.filter(model => model.alias !== candidate)
|
|
789
|
+
: [...channel.models, { alias: candidate, id: candidate }]
|
|
790
|
+
props.onSetModels(merged)
|
|
791
|
+
}} />
|
|
792
|
+
<span>{candidate}</span>
|
|
793
|
+
{!entry.known ? <span className={css.modelBadge} data-warn>{t('channels.unknownProtocol')}</span> : <span className={css.modelBadge}>{entry.labelZh}</span>}
|
|
794
|
+
</label>
|
|
795
|
+
)
|
|
796
|
+
})}
|
|
797
|
+
</div>
|
|
798
|
+
) : null}
|
|
799
|
+
|
|
800
|
+
<div className={css.editorDivider} />
|
|
801
|
+
|
|
802
|
+
<div className={css.editorFooter}>
|
|
803
|
+
{props.isDefault ? <span className={css.channelBadge} data-default>{t('channels.defaultLabel')}</span> : (
|
|
804
|
+
<button type="button" className={css.inlineDisclosure} disabled={!props.writable} onClick={props.onSetDefault}>{t('channels.setDefault')}</button>
|
|
805
|
+
)}
|
|
806
|
+
<span className={css.spacer} />
|
|
807
|
+
{removeOpen
|
|
808
|
+
? (
|
|
809
|
+
<>
|
|
810
|
+
<button type="button" className={css.channelDanger} disabled={!props.writable} onClick={props.onRemove}>{t('channels.confirm')}</button>
|
|
811
|
+
<button type="button" className={css.channelAction} onClick={() => { setRemoveOpen(false) }}>{t('channels.cancel')}</button>
|
|
812
|
+
</>
|
|
813
|
+
)
|
|
814
|
+
: (
|
|
815
|
+
<button type="button" className={css.channelAction} data-danger onClick={() => { setRemoveOpen(true) }}>{t('channels.deleteThisChannel')}</button>
|
|
816
|
+
)}
|
|
817
|
+
</div>
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
)
|
|
821
|
+
}
|
|
822
|
+
|
|
496
823
|
/** Props every field control needs regardless of its value type. */
|
|
497
824
|
interface FieldProps {
|
|
498
825
|
/** Stable id associating the label with its control. */
|
|
@@ -533,8 +860,6 @@ function ValueField(props: FieldProps & {
|
|
|
533
860
|
onClear?: () => void
|
|
534
861
|
/** Whether a stored secret exists (enables the clear control). */
|
|
535
862
|
canClear?: boolean
|
|
536
|
-
/** Render a multiline input for newline-separated configuration values. */
|
|
537
|
-
multiline?: boolean
|
|
538
863
|
}) {
|
|
539
864
|
return (
|
|
540
865
|
<div className={css.field}>
|
|
@@ -568,29 +893,17 @@ function ValueField(props: FieldProps & {
|
|
|
568
893
|
)
|
|
569
894
|
: null}
|
|
570
895
|
</div>
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
<input
|
|
583
|
-
id={props.id}
|
|
584
|
-
className={props.invalid ? css.inputInvalid : css.input}
|
|
585
|
-
type={props.secret === true ? 'password' : 'text'}
|
|
586
|
-
autoComplete={props.secret === true ? 'off' : undefined}
|
|
587
|
-
{...props.invalid ? { 'aria-invalid': true } : {}}
|
|
588
|
-
value={props.text}
|
|
589
|
-
placeholder={props.placeholder ?? ''}
|
|
590
|
-
disabled={props.disabled}
|
|
591
|
-
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
592
|
-
/>
|
|
593
|
-
)}
|
|
896
|
+
<input
|
|
897
|
+
id={props.id}
|
|
898
|
+
className={props.invalid ? css.inputInvalid : css.input}
|
|
899
|
+
type={props.secret === true ? 'password' : 'text'}
|
|
900
|
+
autoComplete={props.secret === true ? 'off' : undefined}
|
|
901
|
+
{...props.invalid ? { 'aria-invalid': true } : {}}
|
|
902
|
+
value={props.text}
|
|
903
|
+
placeholder={props.placeholder ?? ''}
|
|
904
|
+
disabled={props.disabled}
|
|
905
|
+
onChange={(event) => { props.onEdit(event.target.value) }}
|
|
906
|
+
/>
|
|
594
907
|
<p className={props.invalid ? css.invalid : css.hint}>
|
|
595
908
|
{props.invalid ? props.invalidLabel : props.hint}
|
|
596
909
|
</p>
|
|
@@ -598,10 +911,6 @@ function ValueField(props: FieldProps & {
|
|
|
598
911
|
)
|
|
599
912
|
}
|
|
600
913
|
|
|
601
|
-
function splitModels(value: string): string[] {
|
|
602
|
-
return [...new Set(value.split(/[\n,]/).map(model => model.trim()).filter(Boolean))]
|
|
603
|
-
}
|
|
604
|
-
|
|
605
914
|
/** A staged boolean field: 继承 / 开 / 关. */
|
|
606
915
|
function BooleanField(props: FieldProps & {
|
|
607
916
|
/** Copy for the inherit option. */
|