@dsh-plus/image-studio 0.1.0 → 0.1.2

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,521 @@
1
+ /**
2
+ * 生图工作台表单(文生图/图生图共用,endpoint 区分):
3
+ * - 提供商预设选择 + 凭据状态徽标 + inline 覆盖(凭据沿用预设);
4
+ * - 提示词编辑 + 预设插入/另存;
5
+ * - 目录驱动的参数表单 + 参数预设应用/另存;
6
+ * - 图生图追加源图(≤16)与可选遮罩选择(画廊取图);
7
+ * - 提交 generate → 202 taskId 交由面板任务条轮询。
8
+ * 预设另存走 settings RPC(scope/api 注入),成功后经 onPresetSaved 刷新快照。
9
+ * @module image-studio/client/panel/generator
10
+ */
11
+ import { IconLayers, type NamespaceSettingsApi, type Scope } from '@dsh-plus/shared/client'
12
+ import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
13
+ import type { GenerateRequest, PresetsWire, ProvidersWire } from '../../dto.ts'
14
+ import type { GalleryItem } from '../../gallery/store.ts'
15
+ import type { ParamEntry } from '../../params/catalog.ts'
16
+ import { type ParamSpecMap, validateParamSpecs } from '../../params/spec.ts'
17
+ import type { ImageEndpoint } from '../../provider/types.ts'
18
+ import { fetchCredentialStatus, generate, imageUrl } from '../api.ts'
19
+ import type { Translate } from '../i18n.ts'
20
+ import type { EditSeed } from './controller.ts'
21
+ import {
22
+ applyParams,
23
+ applySpecs,
24
+ emptyParamForm,
25
+ formToSpecs,
26
+ type ParamFormState,
27
+ } from './param-state.ts'
28
+ import { ParamsForm } from './params-form.tsx'
29
+ import { ImagePicker } from './picker.tsx'
30
+
31
+ interface GeneratorProps {
32
+ t: Translate
33
+ endpoint: ImageEndpoint
34
+ catalog: readonly ParamEntry[]
35
+ protocols: ProvidersWire['protocols']
36
+ presets: PresetsWire
37
+ gallery: GalleryItem[]
38
+ scope: Scope
39
+ api: NamespaceSettingsApi
40
+ /** 二次编辑种子(仅图生图实例消费)。 */
41
+ editSeed: EditSeed | null
42
+ onPresetSaved(): void
43
+ onSubmitted(taskId: string): void
44
+ hidden: boolean
45
+ }
46
+
47
+ interface InlineDraft {
48
+ open: boolean
49
+ protocol: string
50
+ baseUrl: string
51
+ model: string
52
+ }
53
+
54
+ type Status = { kind: 'idle' | 'ok' | 'error'; text: string }
55
+ const IDLE: Status = { kind: 'idle', text: '' }
56
+
57
+ /** 预设 id 生成(稳定唯一即可;provider 预设的 kebab 约束不适用此处)。 */
58
+ function presetId(prefix: string): string {
59
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
60
+ }
61
+
62
+ export function GeneratorForm(props: GeneratorProps): ReactElement {
63
+ const { t, endpoint, catalog, protocols, presets, gallery, scope, api } = props
64
+ const [providerId, setProviderId] = useState('')
65
+ const [inline, setInline] = useState<InlineDraft>({
66
+ open: false,
67
+ protocol: protocols[0]?.id ?? 'openai-images',
68
+ baseUrl: '',
69
+ model: '',
70
+ })
71
+ const [credentialOk, setCredentialOk] = useState<boolean | null>(null)
72
+ const [prompt, setPrompt] = useState('')
73
+ const [form, setForm] = useState<ParamFormState>({})
74
+ const [sourceIds, setSourceIds] = useState<string[]>([])
75
+ const [maskId, setMaskId] = useState<string | null>(null)
76
+ const [picker, setPicker] = useState<'sources' | 'mask' | null>(null)
77
+ const [naming, setNaming] = useState<{ kind: 'prompt' | 'params'; name: string } | null>(null)
78
+ const [status, setStatus] = useState<Status>(IDLE)
79
+ const [submitting, setSubmitting] = useState(false)
80
+ /** 目录就绪后初始化表单一次(catalog 会话内静态,重载不覆盖用户编辑)。 */
81
+ const formReadyRef = useRef(false)
82
+ /** 已消费的种子 nonce(防重复回填)。 */
83
+ const seedRef = useRef(0)
84
+
85
+ // 目录到达后初始化参数表单。
86
+ useEffect(() => {
87
+ if (formReadyRef.current || catalog.length === 0) return
88
+ formReadyRef.current = true
89
+ setForm(emptyParamForm(catalog, endpoint))
90
+ }, [catalog, endpoint])
91
+
92
+ // 二次编辑种子回填(等目录就绪,保证参数完整回放)。
93
+ const seed = endpoint === 'edit' ? props.editSeed : null
94
+ useEffect(() => {
95
+ if (seed === null || seed.nonce === seedRef.current || !formReadyRef.current) return
96
+ seedRef.current = seed.nonce
97
+ setPrompt(seed.prompt)
98
+ setSourceIds(seed.sourceIds)
99
+ setForm(applyParams(catalog, endpoint, seed.params))
100
+ if (seed.providerPresetId !== null) setProviderId(seed.providerPresetId)
101
+ setStatus(IDLE)
102
+ }, [seed, catalog, endpoint])
103
+
104
+ // 选中预设变化 → 查询凭据状态。
105
+ useEffect(() => {
106
+ if (providerId === '') {
107
+ setCredentialOk(null)
108
+ return
109
+ }
110
+ let alive = true
111
+ fetchCredentialStatus(providerId)
112
+ .then((res) => {
113
+ if (alive) setCredentialOk(res.configured)
114
+ })
115
+ .catch(() => {
116
+ if (alive) setCredentialOk(null)
117
+ })
118
+ return () => {
119
+ alive = false
120
+ }
121
+ }, [providerId])
122
+
123
+ const paramPresets = useMemo(
124
+ () => presets.paramPresets.filter((preset) => preset.endpoint === endpoint),
125
+ [presets, endpoint],
126
+ )
127
+
128
+ const selectedProvider = presets.providerPresets.find((preset) => preset.id === providerId)
129
+
130
+ const toggleInline = (open: boolean): void => {
131
+ setInline((current) => ({
132
+ ...current,
133
+ open,
134
+ baseUrl: open && current.baseUrl === '' ? (selectedProvider?.baseUrl ?? '') : current.baseUrl,
135
+ model: open && current.model === '' ? (selectedProvider?.model ?? '') : current.model,
136
+ protocol:
137
+ open && current.protocol === ''
138
+ ? (selectedProvider?.protocol ?? current.protocol)
139
+ : current.protocol,
140
+ }))
141
+ }
142
+
143
+ const applyParamPreset = (id: string): void => {
144
+ if (id === '') return
145
+ const preset = paramPresets.find((item) => item.id === id)
146
+ if (preset === undefined) return
147
+ try {
148
+ const specs = validateParamSpecs(preset.paramSpecs)
149
+ setForm(applySpecs(catalog, endpoint, specs))
150
+ setStatus(IDLE)
151
+ } catch (error) {
152
+ setStatus({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
153
+ }
154
+ }
155
+
156
+ /** 预设另存(settings RPC + revision fencing,成功后刷新快照)。 */
157
+ const savePreset = (): void => {
158
+ if (naming === null || naming.name.trim() === '') return
159
+ const snapshot = scope.getSnapshot()
160
+ const value = (snapshot.value ?? {}) as {
161
+ promptPresets?: PresetsWire['promptPresets']
162
+ paramPresets?: PresetsWire['paramPresets']
163
+ }
164
+ const patch =
165
+ naming.kind === 'prompt'
166
+ ? {
167
+ promptPresets: [
168
+ ...(value.promptPresets ?? []),
169
+ { id: presetId('prompt'), name: naming.name.trim(), text: prompt },
170
+ ],
171
+ }
172
+ : {
173
+ paramPresets: [
174
+ ...(value.paramPresets ?? []),
175
+ {
176
+ id: presetId('param'),
177
+ name: naming.name.trim(),
178
+ endpoint,
179
+ paramSpecs: formToSpecs(catalog, endpoint, form) as unknown,
180
+ },
181
+ ],
182
+ }
183
+ api
184
+ .update(patch, snapshot.revision)
185
+ .then(() => scope.load())
186
+ .then(() => {
187
+ setNaming(null)
188
+ setStatus({
189
+ kind: 'ok',
190
+ text: t(naming.kind === 'prompt' ? 'prompt.preset.saved' : 'params.preset.saved'),
191
+ })
192
+ props.onPresetSaved()
193
+ })
194
+ .catch((error: unknown) => {
195
+ setStatus({
196
+ kind: 'error',
197
+ text: `${t('card.saveFailed')}${error instanceof Error ? error.message : String(error)}`,
198
+ })
199
+ })
200
+ }
201
+
202
+ const submit = (): void => {
203
+ if (providerId === '') {
204
+ setStatus({ kind: 'error', text: t('generate.needProvider') })
205
+ return
206
+ }
207
+ if (prompt.trim() === '') {
208
+ setStatus({ kind: 'error', text: t('generate.needPrompt') })
209
+ return
210
+ }
211
+ if (endpoint === 'edit' && sourceIds.length === 0) {
212
+ setStatus({ kind: 'error', text: t('generate.needSources') })
213
+ return
214
+ }
215
+ let paramSpecs: ParamSpecMap
216
+ try {
217
+ paramSpecs = validateParamSpecs(formToSpecs(catalog, endpoint, form))
218
+ } catch (error) {
219
+ setStatus({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
220
+ return
221
+ }
222
+ const request: GenerateRequest = {
223
+ providerPresetId: providerId,
224
+ endpoint,
225
+ prompt: prompt.trim(),
226
+ paramSpecs,
227
+ }
228
+ if (inline.open) {
229
+ request.inlineProvider = {
230
+ protocol: inline.protocol,
231
+ baseUrl: inline.baseUrl.trim(),
232
+ model: inline.model.trim(),
233
+ }
234
+ }
235
+ if (endpoint === 'edit') {
236
+ request.sourceIds = sourceIds
237
+ if (maskId !== null) request.maskId = maskId
238
+ }
239
+ setSubmitting(true)
240
+ setStatus(IDLE)
241
+ generate(request)
242
+ .then(({ taskId }) => {
243
+ setStatus({ kind: 'ok', text: t('generate.submitted').replace('{id}', taskId) })
244
+ props.onSubmitted(taskId)
245
+ })
246
+ .catch((error: unknown) => {
247
+ setStatus({
248
+ kind: 'error',
249
+ text: `${t('generate.failed')}${error instanceof Error ? error.message : String(error)}`,
250
+ })
251
+ })
252
+ .finally(() => setSubmitting(false))
253
+ }
254
+
255
+ return (
256
+ <div className="ims-gen" hidden={props.hidden}>
257
+ <div className="ims-genMain">
258
+ <section className="ims-block">
259
+ <h3 className="ims-blockTitle">{t('provider.label')}</h3>
260
+ {presets.providerPresets.length === 0 ? (
261
+ <p className="ims-hint">{t('provider.empty')}</p>
262
+ ) : (
263
+ <div className="ims-providerRow">
264
+ <select
265
+ className="ims-input"
266
+ value={providerId}
267
+ aria-label={t('provider.label')}
268
+ onChange={(event) => setProviderId(event.target.value)}
269
+ >
270
+ <option value="">{t('provider.placeholder')}</option>
271
+ {presets.providerPresets.map((preset) => (
272
+ <option key={preset.id} value={preset.id}>
273
+ {preset.name}({preset.model})
274
+ </option>
275
+ ))}
276
+ </select>
277
+ {credentialOk !== null ? (
278
+ <span className={`ims-badge ${credentialOk ? 'ims-badgeOn' : 'ims-badgeOff'}`}>
279
+ {t(
280
+ credentialOk ? 'provider.credential.configured' : 'provider.credential.missing',
281
+ )}
282
+ </span>
283
+ ) : null}
284
+ </div>
285
+ )}
286
+ {providerId !== '' ? (
287
+ <>
288
+ <label className="ims-checkLine">
289
+ <input
290
+ type="checkbox"
291
+ checked={inline.open}
292
+ onChange={(event) => toggleInline(event.target.checked)}
293
+ />
294
+ <span>{t('provider.inline.toggle')}</span>
295
+ </label>
296
+ {inline.open ? (
297
+ <div className="ims-inlineGrid">
298
+ <label className="ims-mini">
299
+ <span>{t('provider.inline.protocol')}</span>
300
+ <select
301
+ className="ims-input"
302
+ value={inline.protocol}
303
+ onChange={(event) => setInline({ ...inline, protocol: event.target.value })}
304
+ >
305
+ {protocols.map((protocol) => (
306
+ <option key={protocol.id} value={protocol.id}>
307
+ {protocol.label}
308
+ </option>
309
+ ))}
310
+ </select>
311
+ </label>
312
+ <label className="ims-mini">
313
+ <span>{t('provider.inline.baseUrl')}</span>
314
+ <input
315
+ className="ims-input"
316
+ value={inline.baseUrl}
317
+ placeholder="https://api.openai.com/v1"
318
+ onChange={(event) => setInline({ ...inline, baseUrl: event.target.value })}
319
+ />
320
+ </label>
321
+ <label className="ims-mini">
322
+ <span>{t('provider.inline.model')}</span>
323
+ <input
324
+ className="ims-input"
325
+ value={inline.model}
326
+ placeholder="gpt-image-1"
327
+ onChange={(event) => setInline({ ...inline, model: event.target.value })}
328
+ />
329
+ </label>
330
+ <p className="ims-hint">{t('provider.inline.hint')}</p>
331
+ </div>
332
+ ) : null}
333
+ </>
334
+ ) : null}
335
+ </section>
336
+
337
+ <section className="ims-block">
338
+ <h3 className="ims-blockTitle">{t('prompt.label')}</h3>
339
+ <textarea
340
+ className="ims-input ims-prompt"
341
+ rows={5}
342
+ value={prompt}
343
+ placeholder={t('prompt.placeholder')}
344
+ onChange={(event) => setPrompt(event.target.value)}
345
+ />
346
+ <div className="ims-rowActions">
347
+ <select
348
+ className="ims-input ims-rowGrow"
349
+ value=""
350
+ aria-label={t('prompt.preset.insert')}
351
+ onChange={(event) => {
352
+ const preset = presets.promptPresets.find((item) => item.id === event.target.value)
353
+ if (preset !== undefined) {
354
+ setPrompt((current) =>
355
+ current.trim() === '' ? preset.text : `${current}\n${preset.text}`,
356
+ )
357
+ }
358
+ }}
359
+ >
360
+ <option value="">{t('prompt.preset.insert')}</option>
361
+ {presets.promptPresets.map((preset) => (
362
+ <option key={preset.id} value={preset.id}>
363
+ {preset.name}
364
+ </option>
365
+ ))}
366
+ </select>
367
+ <button
368
+ type="button"
369
+ className="ims-btn ims-btnGhost"
370
+ disabled={prompt.trim() === ''}
371
+ onClick={() => setNaming({ kind: 'prompt', name: '' })}
372
+ >
373
+ {t('prompt.preset.save')}
374
+ </button>
375
+ </div>
376
+ </section>
377
+
378
+ {endpoint === 'edit' ? (
379
+ <section className="ims-block">
380
+ <h3 className="ims-blockTitle">{t('sources.label')}</h3>
381
+ <div className="ims-sources">
382
+ {sourceIds.map((imageId) => (
383
+ <span key={imageId} className="ims-thumb">
384
+ <img src={imageUrl(imageId)} alt="" />
385
+ <button
386
+ type="button"
387
+ className="ims-thumbRemove"
388
+ aria-label={t('common.delete')}
389
+ onClick={() =>
390
+ setSourceIds((current) => current.filter((id) => id !== imageId))
391
+ }
392
+ >
393
+ ×
394
+ </button>
395
+ </span>
396
+ ))}
397
+ <button type="button" className="ims-thumbAdd" onClick={() => setPicker('sources')}>
398
+ <IconLayers size={18} />
399
+ <span>{t('sources.pick')}</span>
400
+ </button>
401
+ </div>
402
+ <p className="ims-hint">
403
+ {t('sources.count').replace('{n}', String(sourceIds.length))}
404
+ </p>
405
+ <h3 className="ims-blockTitle">{t('sources.mask')}</h3>
406
+ <div className="ims-sources">
407
+ {maskId !== null ? (
408
+ <span className="ims-thumb">
409
+ <img src={imageUrl(maskId)} alt="" />
410
+ <button
411
+ type="button"
412
+ className="ims-thumbRemove"
413
+ aria-label={t('sources.maskClear')}
414
+ onClick={() => setMaskId(null)}
415
+ >
416
+ ×
417
+ </button>
418
+ </span>
419
+ ) : null}
420
+ <button type="button" className="ims-thumbAdd" onClick={() => setPicker('mask')}>
421
+ <IconLayers size={18} />
422
+ <span>{t('sources.maskPick')}</span>
423
+ </button>
424
+ </div>
425
+ </section>
426
+ ) : null}
427
+ </div>
428
+
429
+ <div className="ims-genSide">
430
+ <section className="ims-block">
431
+ <div className="ims-rowActions ims-blockHead">
432
+ <h3 className="ims-blockTitle">{t('params.basic')}</h3>
433
+ <select
434
+ className="ims-input ims-rowGrow"
435
+ value=""
436
+ aria-label={t('params.preset.placeholder')}
437
+ onChange={(event) => applyParamPreset(event.target.value)}
438
+ >
439
+ <option value="">{t('params.preset.placeholder')}</option>
440
+ {paramPresets.map((preset) => (
441
+ <option key={preset.id} value={preset.id}>
442
+ {preset.name}
443
+ </option>
444
+ ))}
445
+ </select>
446
+ <button
447
+ type="button"
448
+ className="ims-btn ims-btnGhost"
449
+ onClick={() => setNaming({ kind: 'params', name: '' })}
450
+ >
451
+ {t('params.preset.save')}
452
+ </button>
453
+ </div>
454
+ <ParamsForm t={t} catalog={catalog} endpoint={endpoint} form={form} onChange={setForm} />
455
+ </section>
456
+ </div>
457
+
458
+ <div className="ims-genFoot">
459
+ {naming !== null ? (
460
+ <div className="ims-naming">
461
+ <input
462
+ className="ims-input ims-rowGrow"
463
+ value={naming.name}
464
+ placeholder={t('prompt.preset.name')}
465
+ // biome-ignore lint/a11y/noAutofocus: 模态内命名输入聚焦是预期交互
466
+ autoFocus
467
+ onChange={(event) => setNaming({ ...naming, name: event.target.value })}
468
+ onKeyDown={(event) => {
469
+ if (event.key === 'Enter') savePreset()
470
+ if (event.key === 'Escape') setNaming(null)
471
+ }}
472
+ />
473
+ <button
474
+ type="button"
475
+ className="ims-btn ims-btnPrimary"
476
+ disabled={naming.name.trim() === ''}
477
+ onClick={savePreset}
478
+ >
479
+ {t('common.save')}
480
+ </button>
481
+ <button type="button" className="ims-btn ims-btnGhost" onClick={() => setNaming(null)}>
482
+ {t('common.cancel')}
483
+ </button>
484
+ </div>
485
+ ) : null}
486
+ {status.kind !== 'idle' ? (
487
+ <p
488
+ className={`ims-status${status.kind === 'error' ? ' ims-statusError' : ''}`}
489
+ role="status"
490
+ >
491
+ {status.text}
492
+ </p>
493
+ ) : null}
494
+ <button
495
+ type="button"
496
+ className="ims-btn ims-btnPrimary ims-submit"
497
+ disabled={submitting}
498
+ onClick={submit}
499
+ >
500
+ {t('generate.submit')}
501
+ </button>
502
+ </div>
503
+
504
+ {picker !== null ? (
505
+ <ImagePicker
506
+ t={t}
507
+ title={t(picker === 'sources' ? 'sources.label' : 'sources.mask')}
508
+ items={gallery}
509
+ multiple={picker === 'sources'}
510
+ initial={picker === 'sources' ? sourceIds : []}
511
+ onConfirm={(ids) => {
512
+ if (picker === 'sources') setSourceIds(ids)
513
+ else setMaskId(ids[0] ?? null)
514
+ setPicker(null)
515
+ }}
516
+ onClose={() => setPicker(null)}
517
+ />
518
+ ) : null}
519
+ </div>
520
+ )
521
+ }