@dsh-plus/image-studio 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 配置卡片草稿模型与草稿 ↔ settings 载荷转换(无 React/JSX,供 node --test 直接导入):
3
+ * JSON 字段(extraHeaders / paramSpecs)以文本镜像承载,保存时解析并做本地校验。
4
+ * @module image-studio/client/draft
5
+ */
6
+ import type { ImageStudioConfig } from '../config.ts'
7
+ import { credentialRefNameOf, isValidPresetId } from '../credentials.ts'
8
+ import { validateParamSpecs } from '../params/spec.ts'
9
+
10
+ /** 编辑态草稿:JSON 字段与数值以文本承载(保存时解析校验)。 */
11
+ export interface ProviderDraft {
12
+ id: string
13
+ name: string
14
+ protocol: string
15
+ baseUrl: string
16
+ model: string
17
+ extraHeadersText: string
18
+ }
19
+
20
+ export interface ParamDraft {
21
+ id: string
22
+ name: string
23
+ endpoint: string
24
+ specsText: string
25
+ }
26
+
27
+ export interface PromptDraft {
28
+ id: string
29
+ name: string
30
+ text: string
31
+ }
32
+
33
+ export interface Draft {
34
+ providers: ProviderDraft[]
35
+ params: ParamDraft[]
36
+ prompts: PromptDraft[]
37
+ maxConcurrent: string
38
+ requestTimeoutMs: string
39
+ proxy: string
40
+ galleryMaxItems: string
41
+ uploadTtlHours: string
42
+ }
43
+
44
+ /** 配置值 → 编辑草稿。 */
45
+ export function draftOf(value: ImageStudioConfig): Draft {
46
+ return {
47
+ providers: value.providerPresets.map((p) => ({
48
+ id: p.id,
49
+ name: p.name,
50
+ protocol: p.protocol,
51
+ baseUrl: p.baseUrl,
52
+ model: p.model,
53
+ extraHeadersText: JSON.stringify(p.extraHeaders ?? {}, null, 2),
54
+ })),
55
+ params: value.paramPresets.map((p) => ({
56
+ id: p.id,
57
+ name: p.name,
58
+ endpoint: p.endpoint,
59
+ specsText: JSON.stringify(p.paramSpecs ?? {}, null, 2),
60
+ })),
61
+ prompts: value.promptPresets.map((p) => ({ id: p.id, name: p.name, text: p.text })),
62
+ maxConcurrent: String(value.maxConcurrent),
63
+ requestTimeoutMs: String(value.requestTimeoutMs),
64
+ proxy: value.proxy,
65
+ galleryMaxItems: String(value.galleryMaxItems),
66
+ uploadTtlHours: String(value.uploadTtlHours),
67
+ }
68
+ }
69
+
70
+ function parseJson(text: string, label: string): unknown {
71
+ try {
72
+ return JSON.parse(text)
73
+ } catch (error) {
74
+ throw new Error(`${label} ${error instanceof Error ? error.message : String(error)}`)
75
+ }
76
+ }
77
+
78
+ /** 草稿 → settings 载荷(边界校验:JSON 解析、参数表校验、预设 id 形态)。 */
79
+ export function payloadOf(draft: Draft): Record<string, unknown> {
80
+ const providers = draft.providers.map((p) => {
81
+ if (!isValidPresetId(p.id)) throw new Error(p.id)
82
+ return {
83
+ id: p.id,
84
+ name: p.name,
85
+ protocol: p.protocol,
86
+ baseUrl: p.baseUrl,
87
+ model: p.model,
88
+ credentialRef: credentialRefNameOf(p.id),
89
+ extraHeaders: parseJson(p.extraHeadersText, p.id) as Record<string, string>,
90
+ }
91
+ })
92
+ const params = draft.params.map((p) => ({
93
+ id: p.id,
94
+ name: p.name,
95
+ endpoint: p.endpoint,
96
+ paramSpecs: validateParamSpecs(parseJson(p.specsText, p.id)),
97
+ }))
98
+ return {
99
+ promptPresets: draft.prompts.map((p) => ({ id: p.id, name: p.name, text: p.text })),
100
+ paramPresets: params,
101
+ providerPresets: providers,
102
+ maxConcurrent: Math.max(0, Math.floor(Number(draft.maxConcurrent) || 0)),
103
+ requestTimeoutMs: Math.max(10_000, Math.floor(Number(draft.requestTimeoutMs) || 300_000)),
104
+ proxy: draft.proxy,
105
+ galleryMaxItems: Math.max(0, Math.floor(Number(draft.galleryMaxItems) || 0)),
106
+ uploadTtlHours: Math.max(1, Math.floor(Number(draft.uploadTtlHours) || 24)),
107
+ }
108
+ }
109
+
110
+ /** 空提供商预设(新增行用;协议取目录首项)。 */
111
+ export function emptyProviderDraft(protocol: string): ProviderDraft {
112
+ return { id: '', name: '', protocol, baseUrl: '', model: '', extraHeadersText: '{}' }
113
+ }
114
+
115
+ /** 空参数预设(新增行用;id 由时间戳生成,行序即身份)。 */
116
+ export function emptyParamDraft(): ParamDraft {
117
+ return {
118
+ id: `param-${Date.now().toString(36)}`,
119
+ name: '',
120
+ endpoint: 'generation',
121
+ specsText: '{}',
122
+ }
123
+ }
124
+
125
+ /** 空提示词预设(新增行用)。 */
126
+ export function emptyPromptDraft(): PromptDraft {
127
+ return { id: `prompt-${Date.now().toString(36)}`, name: '', text: '' }
128
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * 配置卡片的三组预设编辑区:提供商(含逐条 API Key 管理)、提示词、参数。
3
+ * 从 card.tsx 拆出,避免单组件过长;草稿类型与纯转换在 ./draft.ts。
4
+ * @module image-studio/client/preset-sections
5
+ */
6
+ import { TextField } from '@dsh-plus/shared/client'
7
+ import { type ReactElement, useEffect, useState } from 'react'
8
+
9
+ import { isValidPresetId } from '../credentials.ts'
10
+ import { fetchCredentialStatus, setCredential, unsetCredential } from './api.ts'
11
+ import {
12
+ type Draft,
13
+ emptyParamDraft,
14
+ emptyPromptDraft,
15
+ emptyProviderDraft,
16
+ type ParamDraft,
17
+ type PromptDraft,
18
+ type ProviderDraft,
19
+ } from './draft.ts'
20
+ import type { Translate } from './i18n.ts'
21
+
22
+ /** 单个提供商预设的 API Key 行(describe 徽标 + 设置/删除,值不回显)。 */
23
+ function CredentialRow(props: { t: Translate; presetId: string; disabled: boolean }): ReactElement {
24
+ const { t, presetId, disabled } = props
25
+ const [configured, setConfigured] = useState<boolean | null>(null)
26
+ const [value, setValue] = useState('')
27
+ const [busy, setBusy] = useState(false)
28
+
29
+ useEffect(() => {
30
+ if (!isValidPresetId(presetId)) return
31
+ let alive = true
32
+ fetchCredentialStatus(presetId)
33
+ .then((res) => {
34
+ if (alive) setConfigured(res.configured)
35
+ })
36
+ .catch(() => {})
37
+ return () => {
38
+ alive = false
39
+ }
40
+ }, [presetId])
41
+
42
+ const act = (fn: () => Promise<unknown>): void => {
43
+ setBusy(true)
44
+ fn()
45
+ .then(() => fetchCredentialStatus(presetId))
46
+ .then((res) => {
47
+ setConfigured(res.configured)
48
+ setValue('')
49
+ })
50
+ .catch(() => {})
51
+ .finally(() => setBusy(false))
52
+ }
53
+
54
+ return (
55
+ <div className="imsc-credRow">
56
+ <input
57
+ className="imsc-input"
58
+ type="password"
59
+ autoComplete="off"
60
+ placeholder={t('card.credential.placeholder')}
61
+ aria-label={t('card.credential.label')}
62
+ value={value}
63
+ disabled={disabled || busy}
64
+ onChange={(event) => setValue(event.target.value)}
65
+ />
66
+ {configured !== null ? (
67
+ <span className={`imsc-badge ${configured ? 'imsc-badgeSet' : 'imsc-badgeUnset'}`}>
68
+ {t(configured ? 'card.credential.configured' : 'card.credential.unconfigured')}
69
+ </span>
70
+ ) : null}
71
+ <button
72
+ type="button"
73
+ className="imsc-btn imsc-btnGhost"
74
+ disabled={disabled || busy || value === '' || !isValidPresetId(presetId)}
75
+ onClick={() => act(() => setCredential(presetId, value))}
76
+ >
77
+ {t('card.credential.set')}
78
+ </button>
79
+ <button
80
+ type="button"
81
+ className="imsc-btn imsc-btnGhost"
82
+ disabled={disabled || busy || configured !== true || !isValidPresetId(presetId)}
83
+ onClick={() => act(() => unsetCredential(presetId))}
84
+ >
85
+ {t('card.credential.unset')}
86
+ </button>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ /** 三组预设编辑区共用的 props(各组按需取用其中一部分)。 */
92
+ export interface PresetSectionsProps {
93
+ t: Translate
94
+ draft: Draft
95
+ disabled: boolean
96
+ protocols: Array<{ id: string; label: string }>
97
+ onPatch(patch: Partial<Draft>): void
98
+ onPatchProvider(index: number, patch: Partial<ProviderDraft>): void
99
+ onPatchParam(index: number, patch: Partial<ParamDraft>): void
100
+ onPatchPrompt(index: number, patch: Partial<PromptDraft>): void
101
+ }
102
+
103
+ export function ProviderPresetSection(props: PresetSectionsProps): ReactElement {
104
+ const { t, draft, disabled, protocols, onPatch, onPatchProvider } = props
105
+ return (
106
+ <>
107
+ <h4 className="imsc-groupTitle">{t('card.providers')}</h4>
108
+ {draft.providers.map((provider, index) => (
109
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id(id 字段本身可编辑),行序即身份
110
+ <div className="imsc-presetBox" key={`provider-${index}`}>
111
+ <div className="imsc-presetHead">
112
+ <span className="imsc-presetName">{provider.name || provider.id || '—'}</span>
113
+ <button
114
+ type="button"
115
+ className="imsc-btn imsc-btnGhost"
116
+ disabled={disabled}
117
+ onClick={() => onPatch({ providers: draft.providers.filter((_, i) => i !== index) })}
118
+ >
119
+ {t('common.delete')}
120
+ </button>
121
+ </div>
122
+ <div className="imsc-grid2">
123
+ <TextField
124
+ prefix="imsc"
125
+ id={`imsc-pid-${index}`}
126
+ label={t('card.field.id')}
127
+ hint={t('card.idHint')}
128
+ value={provider.id}
129
+ invalid={provider.id !== '' && !isValidPresetId(provider.id)}
130
+ invalidLabel={t('card.idInvalid')}
131
+ disabled={disabled}
132
+ onEdit={(v) => onPatchProvider(index, { id: v })}
133
+ />
134
+ <TextField
135
+ prefix="imsc"
136
+ id={`imsc-pname-${index}`}
137
+ label={t('card.field.name')}
138
+ value={provider.name}
139
+ disabled={disabled}
140
+ onEdit={(v) => onPatchProvider(index, { name: v })}
141
+ />
142
+ </div>
143
+ <div className="imsc-grid2">
144
+ <div className="imsc-field">
145
+ <div className="imsc-head">
146
+ <label className="imsc-label" htmlFor={`imsc-pproto-${index}`}>
147
+ {t('card.field.protocol')}
148
+ </label>
149
+ </div>
150
+ <select
151
+ id={`imsc-pproto-${index}`}
152
+ className="imsc-select"
153
+ value={provider.protocol}
154
+ disabled={disabled}
155
+ onChange={(event) => onPatchProvider(index, { protocol: event.target.value })}
156
+ >
157
+ {(protocols.length > 0
158
+ ? protocols
159
+ : [{ id: provider.protocol, label: provider.protocol }]
160
+ ).map((protocol) => (
161
+ <option key={protocol.id} value={protocol.id}>
162
+ {protocol.label}
163
+ </option>
164
+ ))}
165
+ </select>
166
+ </div>
167
+ <TextField
168
+ prefix="imsc"
169
+ id={`imsc-pmodel-${index}`}
170
+ label={t('card.field.model')}
171
+ value={provider.model}
172
+ disabled={disabled}
173
+ onEdit={(v) => onPatchProvider(index, { model: v })}
174
+ />
175
+ </div>
176
+ <TextField
177
+ prefix="imsc"
178
+ id={`imsc-purl-${index}`}
179
+ label={t('card.field.baseUrl')}
180
+ value={provider.baseUrl}
181
+ disabled={disabled}
182
+ onEdit={(v) => onPatchProvider(index, { baseUrl: v })}
183
+ />
184
+ <div className="imsc-field">
185
+ <div className="imsc-head">
186
+ <label className="imsc-label" htmlFor={`imsc-pheaders-${index}`}>
187
+ {t('card.field.extraHeaders')}
188
+ </label>
189
+ </div>
190
+ <textarea
191
+ id={`imsc-pheaders-${index}`}
192
+ className="imsc-textarea"
193
+ value={provider.extraHeadersText}
194
+ disabled={disabled}
195
+ onChange={(event) => onPatchProvider(index, { extraHeadersText: event.target.value })}
196
+ />
197
+ </div>
198
+ <CredentialRow t={t} presetId={provider.id} disabled={disabled} />
199
+ </div>
200
+ ))}
201
+ <button
202
+ type="button"
203
+ className="imsc-btn imsc-btnGhost"
204
+ disabled={disabled}
205
+ onClick={() =>
206
+ onPatch({
207
+ providers: [
208
+ ...draft.providers,
209
+ emptyProviderDraft(protocols[0]?.id ?? 'openai-images'),
210
+ ],
211
+ })
212
+ }
213
+ >
214
+ {t('card.add')} · {t('card.providers')}
215
+ </button>
216
+ </>
217
+ )
218
+ }
219
+
220
+ export function PromptPresetSection(props: PresetSectionsProps): ReactElement {
221
+ const { t, draft, disabled, onPatch, onPatchPrompt } = props
222
+ return (
223
+ <>
224
+ <h4 className="imsc-groupTitle">{t('card.prompts')}</h4>
225
+ {draft.prompts.map((prompt, index) => (
226
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
227
+ <div className="imsc-presetBox" key={`prompt-${index}`}>
228
+ <div className="imsc-presetHead">
229
+ <input
230
+ className="imsc-input"
231
+ aria-label={t('card.field.name')}
232
+ value={prompt.name}
233
+ disabled={disabled}
234
+ onChange={(event) => onPatchPrompt(index, { name: event.target.value })}
235
+ />
236
+ <button
237
+ type="button"
238
+ className="imsc-btn imsc-btnGhost"
239
+ disabled={disabled}
240
+ onClick={() => onPatch({ prompts: draft.prompts.filter((_, i) => i !== index) })}
241
+ >
242
+ {t('common.delete')}
243
+ </button>
244
+ </div>
245
+ <textarea
246
+ className="imsc-textarea"
247
+ aria-label={t('card.field.text')}
248
+ value={prompt.text}
249
+ disabled={disabled}
250
+ onChange={(event) => onPatchPrompt(index, { text: event.target.value })}
251
+ />
252
+ </div>
253
+ ))}
254
+ <button
255
+ type="button"
256
+ className="imsc-btn imsc-btnGhost"
257
+ disabled={disabled}
258
+ onClick={() => onPatch({ prompts: [...draft.prompts, emptyPromptDraft()] })}
259
+ >
260
+ {t('card.add')} · {t('card.prompts')}
261
+ </button>
262
+ </>
263
+ )
264
+ }
265
+
266
+ export function ParamPresetSection(props: PresetSectionsProps): ReactElement {
267
+ const { t, draft, disabled, onPatch, onPatchParam } = props
268
+ return (
269
+ <>
270
+ <h4 className="imsc-groupTitle">{t('card.params')}</h4>
271
+ {draft.params.map((param, index) => (
272
+ // biome-ignore lint/suspicious/noArrayIndexKey: 草稿行无稳定 id,行序即身份
273
+ <div className="imsc-presetBox" key={`param-${index}`}>
274
+ <div className="imsc-presetHead">
275
+ <input
276
+ className="imsc-input"
277
+ aria-label={t('card.field.name')}
278
+ value={param.name}
279
+ disabled={disabled}
280
+ onChange={(event) => onPatchParam(index, { name: event.target.value })}
281
+ />
282
+ <select
283
+ className="imsc-select"
284
+ aria-label={t('card.field.endpoint')}
285
+ value={param.endpoint}
286
+ disabled={disabled}
287
+ onChange={(event) => onPatchParam(index, { endpoint: event.target.value })}
288
+ >
289
+ <option value="generation">{t('tab.generation')}</option>
290
+ <option value="edit">{t('tab.edit')}</option>
291
+ </select>
292
+ <button
293
+ type="button"
294
+ className="imsc-btn imsc-btnGhost"
295
+ disabled={disabled}
296
+ onClick={() => onPatch({ params: draft.params.filter((_, i) => i !== index) })}
297
+ >
298
+ {t('common.delete')}
299
+ </button>
300
+ </div>
301
+ <textarea
302
+ className="imsc-textarea"
303
+ aria-label={t('card.field.paramSpecs')}
304
+ placeholder='{ "size": { "enabled": true, "value": "1024x1024" } }'
305
+ value={param.specsText}
306
+ disabled={disabled}
307
+ onChange={(event) => onPatchParam(index, { specsText: event.target.value })}
308
+ />
309
+ </div>
310
+ ))}
311
+ <button
312
+ type="button"
313
+ className="imsc-btn imsc-btnGhost"
314
+ disabled={disabled}
315
+ onClick={() => onPatch({ params: [...draft.params, emptyParamDraft()] })}
316
+ >
317
+ {t('card.add')} · {t('card.params')}
318
+ </button>
319
+ </>
320
+ )
321
+ }
@@ -586,6 +586,14 @@ export const imageStudioCss = `
586
586
  }
587
587
  .imsc-credRow { display: flex; align-items: center; gap: 8px; }
588
588
  .imsc-credRow .imsc-input { flex: 1; min-width: 0; }
589
+ /* 分节形态(插件页 page 视图,无边框分节):预设块改为分隔线分组,去掉第二层内边距 */
590
+ .imsc-section .imsc-presetBox {
591
+ border: 0;
592
+ border-top: 1px solid var(--dsw-alias-border-l2);
593
+ border-radius: 0;
594
+ padding: 10px 0 2px;
595
+ margin-bottom: 0;
596
+ }
589
597
 
590
598
  /* ── 移动端(≤767px,与 ui-mobile-fit 同断点)── */
591
599
  @media (max-width: 767px) {
@@ -619,6 +627,7 @@ export const imageStudioCss = `
619
627
  .ims-btn { min-height: 40px; }
620
628
  .ims-btnSmall { min-height: 32px; }
621
629
  .imsc-grid2 { grid-template-columns: 1fr; }
630
+ .imsc-section .imsc-presetBox { padding: 10px 0 2px; }
622
631
  }
623
632
  `
624
633