@dsh-plus/image-studio 0.1.22 → 0.1.24

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.
@@ -2,8 +2,8 @@
2
2
  * 浏览器半入口:
3
3
  * - sidebar.footer.action:侧栏底部入口按钮(与文件/终端入口 flex 等分行宽);
4
4
  * - shell.overlay:图像工作室面板(文生图/图生图/画廊 + 任务条);
5
- * - 配置卡片:injectPluginConfigCard 三槽位(三组预设 + 凭据 + 高级项;
6
- * legacy settings.plugin.item 与 0.1.6-alpha.2 插件页 row/bundle config)。
5
+ * - 配置卡片:injectPluginConfigCard 两槽位(三组预设 + 凭据 + 高级项;
6
+ * 插件页 row/bundle config)。
7
7
  * 模式与 web-files 一致:CJS factory 产物、data-plugin-css 样式
8
8
  * 约定、slot 失配静默降级;配置读写经 ctx.remote.settings 直连(远程域名可用)。
9
9
  * @module @dsh-plus/image-studio/client
@@ -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
+ }
@@ -561,10 +561,8 @@ export const imageStudioCss = `
561
561
  .imsc-loading{padding:14px 16px;margin:0;font-size:13px;color:var(--dsw-alias-label-tertiary)}
562
562
  .imsc-groupTitle { margin: 14px 0 6px; font-size: 13px; font-weight: 600; }
563
563
  .imsc-presetBox {
564
- border: 1px solid var(--dsw-alias-border-l3);
565
- border-radius: 10px;
566
- padding: 10px;
567
- margin-bottom: 10px;
564
+ border-top: 1px solid var(--dsw-alias-border-l3);
565
+ padding: 10px 0 2px;
568
566
  display: grid;
569
567
  gap: 8px;
570
568
  }
@@ -587,6 +585,7 @@ export const imageStudioCss = `
587
585
  .imsc-credRow { display: flex; align-items: center; gap: 8px; }
588
586
  .imsc-credRow .imsc-input { flex: 1; min-width: 0; }
589
587
 
588
+
590
589
  /* ── 移动端(≤767px,与 ui-mobile-fit 同断点)── */
591
590
  @media (max-width: 767px) {
592
591
  .ims-modal {
package/src/ns.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
3
  * 服务端直接以字面量注册(0.1.2-alpha.2 起 dsh-settings 对命名空间做编译期
4
4
  * 语法校验,见 config.ts);浏览器半把它作为配置卡片的注册键
5
- * (legacy settings.plugin.item keyed 槽位按「卡片编辑的 settings 命名空间」
6
- * 分发;0.1.6-alpha.2 起改挂插件页 row/bundle config,见 shared/config-slots),
5
+ * (插件页配置卡片挂在 row/bundle config 槽位上,与命名空间解耦,
6
+ * 见 shared/config-slots),
7
7
  * 同一字面量两处共用,防止漂移。
8
8
  * @module image-studio/ns
9
9
  */