@dsh-plus/image-studio 0.1.6 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/image-studio",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "dsh-plus service+ui plugin: 图像工作室——OpenAI Images 协议文生图/图生图,全参数开关化,双预设体系,并发生图任务与可二次编辑画廊",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
package/src/api.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * image-studio HTTP 端点(同源 webServer,与 usage-panel 同一暴露面约定):
3
3
  * - POST generate / GET tasks|tasks/<id> / POST tasks/<id>/cancel
4
4
  * - GET gallery|gallery/<id> / DELETE gallery/<id> / GET images/<id>
5
+ * - POST uploads(本地文件上传原图)/ GET uploads|uploads/<id>(列表/缩略图流)
5
6
  * - GET|POST|DELETE credentials(describe/set/unset,值永不回传)
6
7
  * - GET providers(协议 registry + 参数目录,前端表单依据)
7
8
  * @module image-studio/api
@@ -12,6 +13,8 @@ import type { Context } from '@deepseek-ai/cordis'
12
13
  import type {} from '@deepseek-ai/dsh-host-webserver'
13
14
  import type { GenerateRequest } from './dto.ts'
14
15
  import { ImageStudioError, statusOfCode } from './errors.ts'
16
+ import { detectImageMime, mimeOfExt } from './images/mime.ts'
17
+ import { UPLOAD_MAX_BYTES } from './limits.ts'
15
18
  import { PARAM_CATALOG } from './params/catalog.ts'
16
19
  import { ParamValidationError } from './params/spec.ts'
17
20
  import { listProtocols } from './provider/registry.ts'
@@ -19,6 +22,9 @@ import type { ImageStudioService } from './service.ts'
19
22
 
20
23
  const ROUTE_PREFIX = '/dsh-plus/image-studio'
21
24
 
25
+ /** 上传文件名长度上限(仅展示用途,超长截断)。 */
26
+ const UPLOAD_NAME_MAX = 200
27
+
22
28
  function sendJson(res: ServerResponse, status: number, body: unknown): void {
23
29
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
24
30
  res.end(JSON.stringify(body))
@@ -33,6 +39,32 @@ function readBody(req: IncomingMessage): Promise<string> {
33
39
  })
34
40
  }
35
41
 
42
+ /**
43
+ * 读取二进制请求体(上传通道):超限即中止并抛 upload-too-large。
44
+ * 限额内的字节在内存中聚合(魔数嗅探需完整头部判定,单文件 ≤50MB;
45
+ * 超限后不再累积,内存占用有界)。导出供单测用小限额直接驱动。
46
+ */
47
+ export function readBinaryBody(
48
+ req: IncomingMessage,
49
+ maxBytes: number = UPLOAD_MAX_BYTES,
50
+ ): Promise<Buffer> {
51
+ return new Promise((resolve, reject) => {
52
+ const chunks: Buffer[] = []
53
+ let size = 0
54
+ req.on('data', (chunk: Buffer) => {
55
+ size += chunk.length
56
+ if (size > maxBytes) {
57
+ // 不 destroy:让调用方统一结束响应(后续 data 事件继续被丢弃)。
58
+ reject(new ImageStudioError('upload-too-large', `文件超过 ${maxBytes} 字节上限`))
59
+ return
60
+ }
61
+ chunks.push(chunk)
62
+ })
63
+ req.on('end', () => resolve(Buffer.concat(chunks)))
64
+ req.on('error', reject)
65
+ })
66
+ }
67
+
36
68
  /** 统一错误结算:ImageStudioError/ParamValidationError 按码映射,其余 500。 */
37
69
  function sendError(res: ServerResponse, error: unknown): void {
38
70
  if (error instanceof ImageStudioError) {
@@ -50,7 +82,15 @@ function sendError(res: ServerResponse, error: unknown): void {
50
82
  }
51
83
 
52
84
  /** 端点处理函数表(path 段 → handler)。 */
53
- type Handler = (req: IncomingMessage, res: ServerResponse, rest: string) => Promise<void>
85
+ type Handler = (req: IncomingMessage, res: ServerResponse, rest: string, url: URL) => Promise<void>
86
+
87
+ /** 上传文件名(query 参数,仅展示用):去控制字符并截断。 */
88
+ function uploadNameOf(url: URL): string {
89
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: 清洗用户文件名中的控制字符
90
+ const raw = (url.searchParams.get('name') ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim()
91
+ if (raw === '') return 'upload'
92
+ return raw.slice(0, UPLOAD_NAME_MAX)
93
+ }
54
94
 
55
95
  function buildHandlers(service: ImageStudioService): Record<string, Handler> {
56
96
  return {
@@ -128,10 +168,50 @@ function buildHandlers(service: ImageStudioService): Record<string, Handler> {
128
168
  sendJson(res, 404, { error: 'not-found' })
129
169
  return
130
170
  }
131
- const mime = bytes.ext === 'jpg' ? 'image/jpeg' : `image/${bytes.ext}`
171
+ const mime = mimeOfExt(bytes.ext)
132
172
  res.writeHead(200, { 'content-type': mime, 'cache-control': 'private, max-age=3600' })
133
173
  res.end(Buffer.from(bytes.data))
134
174
  },
175
+ async uploads(req, res, rest, url) {
176
+ // POST /uploads(raw body 流,query 携带原文件名);GET /uploads 列表;
177
+ // GET /uploads/<id> 缩略图流(源图预览,与 images 同构)。
178
+ const uploadId = rest.replace(/\/$/, '')
179
+ if (method(req) === 'POST') {
180
+ if (uploadId !== '') throw new ImageStudioError('invalid-request', '未知路径')
181
+ const data = new Uint8Array(await readBinaryBody(req))
182
+ // 魔数嗅探:不信任声明的 content-type,非白名单位图一律拒绝。
183
+ const mime = detectImageMime(data)
184
+ if (mime === null) {
185
+ throw new ImageStudioError('unsupported-media', '只支持 PNG/JPEG/WebP/GIF 图片')
186
+ }
187
+ const entry = await service.saveUpload({
188
+ name: uploadNameOf(url),
189
+ mime,
190
+ data,
191
+ })
192
+ sendJson(res, 201, entry)
193
+ return
194
+ }
195
+ if (method(req) !== 'GET') {
196
+ sendJson(res, 405, { error: 'GET or POST only' })
197
+ return
198
+ }
199
+ if (uploadId === '') {
200
+ const items = await service.listUploads()
201
+ sendJson(res, 200, { items, total: items.length })
202
+ return
203
+ }
204
+ const bytes = await service.uploadBytes(uploadId)
205
+ if (bytes === null) {
206
+ sendJson(res, 404, { error: 'not-found' })
207
+ return
208
+ }
209
+ res.writeHead(200, {
210
+ 'content-type': mimeOfExt(bytes.ext),
211
+ 'cache-control': 'private, max-age=3600',
212
+ })
213
+ res.end(Buffer.from(bytes.data))
214
+ },
135
215
  async credentials(_req, res, rest) {
136
216
  const refName = rest.replace(/\/$/, '')
137
217
  if (refName.length === 0) {
@@ -206,7 +286,7 @@ export function registerImageStudioApi(ctx: Context, service: ImageStudioService
206
286
  sendJson(res, 404, { error: `unknown endpoint: ${head}` })
207
287
  return
208
288
  }
209
- await handler(req, res, rest)
289
+ await handler(req, res, rest, url)
210
290
  })().catch((error: unknown) => sendError(res, error))
211
291
  },
212
292
  })
package/src/client/api.ts CHANGED
@@ -13,6 +13,7 @@ import type {
13
13
  PresetsWire,
14
14
  ProvidersWire,
15
15
  TaskWire,
16
+ UploadEntry,
16
17
  } from '../dto.ts'
17
18
 
18
19
  const BASE = '/dsh-plus/image-studio'
@@ -60,6 +61,30 @@ export function imageUrl(imageId: string): string {
60
61
  return `${BASE}/images/${imageId}`
61
62
  }
62
63
 
64
+ /** 上传原图 URL(缩略图直连,非 fetch)。 */
65
+ export function uploadImageUrl(uploadId: string): string {
66
+ return `${BASE}/uploads/${uploadId}`
67
+ }
68
+
69
+ /** 上传本地图片:raw body 流(query 携带原文件名,仅展示用)。 */
70
+ export async function uploadImage(file: File): Promise<UploadEntry> {
71
+ const query = new URLSearchParams({ name: file.name })
72
+ const res = await fetch(`${BASE}/uploads?${query.toString()}`, {
73
+ method: 'POST',
74
+ credentials: 'same-origin',
75
+ headers: { 'content-type': 'application/octet-stream' },
76
+ body: file,
77
+ })
78
+ const body = (await res.json()) as UploadEntry & { error?: string; message?: string }
79
+ if (!res.ok) throw new Error(body.message ?? body.error ?? `HTTP ${res.status}`)
80
+ return body
81
+ }
82
+
83
+ /** 上传列表(最新在后;界面自行倒序)。 */
84
+ export async function fetchUploads(): Promise<{ items: UploadEntry[]; total: number }> {
85
+ return getJson(`${BASE}/uploads`)
86
+ }
87
+
63
88
  /** 凭据状态(describe,永不回传值)。 */
64
89
  export function fetchCredentialStatus(refName: string): Promise<CredentialStatusWire> {
65
90
  return getJson(`${BASE}/credentials/${refName}`)
@@ -59,6 +59,7 @@ interface Draft {
59
59
  requestTimeoutMs: string
60
60
  proxy: string
61
61
  galleryMaxItems: string
62
+ uploadTtlHours: string
62
63
  }
63
64
 
64
65
  /** 配置值 → 编辑草稿。 */
@@ -83,6 +84,7 @@ function draftOf(value: ImageStudioConfig): Draft {
83
84
  requestTimeoutMs: String(value.requestTimeoutMs),
84
85
  proxy: value.proxy,
85
86
  galleryMaxItems: String(value.galleryMaxItems),
87
+ uploadTtlHours: String(value.uploadTtlHours),
86
88
  }
87
89
  }
88
90
 
@@ -122,6 +124,7 @@ function payloadOf(draft: Draft): Record<string, unknown> {
122
124
  requestTimeoutMs: Math.max(10_000, Math.floor(Number(draft.requestTimeoutMs) || 300_000)),
123
125
  proxy: draft.proxy,
124
126
  galleryMaxItems: Math.max(0, Math.floor(Number(draft.galleryMaxItems) || 0)),
127
+ uploadTtlHours: Math.max(1, Math.floor(Number(draft.uploadTtlHours) || 24)),
125
128
  }
126
129
  }
127
130
 
@@ -579,6 +582,15 @@ export function StudioConfigCard(props: CardProps): ReactElement | null {
579
582
  onEdit={(v) => patch({ galleryMaxItems: v })}
580
583
  />
581
584
  </div>
585
+ <TextField
586
+ prefix="imsc"
587
+ id="imsc-upload-ttl"
588
+ label={t('card.uploadTtlHours')}
589
+ numeric
590
+ value={draft.uploadTtlHours}
591
+ disabled={disabled}
592
+ onEdit={(v) => patch({ uploadTtlHours: v })}
593
+ />
582
594
  </CardChrome>
583
595
  )
584
596
  }
@@ -58,8 +58,15 @@ export const zh = {
58
58
  'sources.mask': '遮罩(可选)',
59
59
  'sources.maskPick': '选择遮罩',
60
60
  'sources.maskClear': '清除',
61
+ 'sources.maskHint': '遮罩必须是与源图同尺寸的 PNG(透明区域为可重绘范围)',
62
+ 'sources.uploaded': '本地文件上传',
63
+ 'upload.pick': '上传图片',
64
+ 'upload.empty': '暂无上传图片,点上方「上传图片」从本地选择',
65
+ 'upload.failed': '上传失败:',
61
66
  'picker.empty': '画廊暂无图片',
62
67
  'picker.confirm': '使用所选({n})',
68
+ 'picker.section.gallery': '画廊',
69
+ 'picker.section.uploads': '本地上传',
63
70
  // 提交
64
71
  'generate.submit': '生成',
65
72
  'generate.submitted': '已提交任务 {id},进度见下方任务条',
@@ -114,6 +121,7 @@ export const zh = {
114
121
  'card.requestTimeoutMs': '单请求超时(毫秒)',
115
122
  'card.proxy': '上游代理(空 = 直连)',
116
123
  'card.galleryMaxItems': '画廊保留上限(0 = 不清理)',
124
+ 'card.uploadTtlHours': '上传原图保留时长(小时)',
117
125
  'card.specsInvalid': '参数表非法:',
118
126
  'card.idInvalid': 'ID 非法:',
119
127
  'card.save': '保存',
@@ -176,8 +184,16 @@ export const en: Record<DictKey, string> = {
176
184
  'sources.mask': 'Mask (optional)',
177
185
  'sources.maskPick': 'Pick mask',
178
186
  'sources.maskClear': 'Clear',
187
+ 'sources.maskHint':
188
+ 'The mask must be a PNG the same size as the source (transparent area is repainted)',
189
+ 'sources.uploaded': 'Uploaded from disk',
190
+ 'upload.pick': 'Upload image',
191
+ 'upload.empty': 'No uploads yet — use "Upload image" above to pick a local file',
192
+ 'upload.failed': 'Upload failed: ',
179
193
  'picker.empty': 'No images in the gallery yet',
180
194
  'picker.confirm': 'Use selected ({n})',
195
+ 'picker.section.gallery': 'Gallery',
196
+ 'picker.section.uploads': 'Uploads',
181
197
  'generate.submit': 'Generate',
182
198
  'generate.submitted': 'Task {id} submitted; progress in the task bar below',
183
199
  'generate.failed': 'Submit failed: ',
@@ -229,6 +245,7 @@ export const en: Record<DictKey, string> = {
229
245
  'card.requestTimeoutMs': 'Request timeout (ms)',
230
246
  'card.proxy': 'Upstream proxy (empty = direct)',
231
247
  'card.galleryMaxItems': 'Gallery retention (0 = keep all)',
248
+ 'card.uploadTtlHours': 'Upload retention (hours)',
232
249
  'card.specsInvalid': 'Invalid param specs: ',
233
250
  'card.idInvalid': 'Invalid ID: ',
234
251
  'card.save': 'Save',
@@ -5,13 +5,15 @@
5
5
  * @module image-studio/client/panel/controller
6
6
  */
7
7
 
8
+ import type { SourceRef } from '../../dto.ts'
9
+
8
10
  /** 面板标签页。 */
9
11
  export type StudioTab = 'generation' | 'edit' | 'gallery'
10
12
 
11
13
  /** 二次编辑种子(画廊条目 → 图生图表单回填)。 */
12
14
  export interface EditSeed {
13
- /** 源图 imageId 列表(≤16)。 */
14
- sourceIds: string[]
15
+ /** 源图引用(≤16;画廊图片与本地上传混用)。 */
16
+ sources: SourceRef[]
15
17
  /** 历史提示词回填。 */
16
18
  prompt: string
17
19
  /** 历史请求参数回填(画廊条目 params 快照)。 */
@@ -7,7 +7,7 @@ import { Modal } from '@deepseek-ai/dsh-client-ui-primitives'
7
7
  import { IconDownload, IconRefreshOutline16, IconTrashOutline16 } from '@dsh-plus/shared/client'
8
8
  import { type ReactElement, useState } from 'react'
9
9
  import type { GalleryItem } from '../../gallery/store.ts'
10
- import { deleteGalleryItem, imageUrl } from '../api.ts'
10
+ import { deleteGalleryItem, imageUrl, uploadImageUrl } from '../api.ts'
11
11
  import type { Translate } from '../i18n.ts'
12
12
 
13
13
  interface GalleryViewProps {
@@ -98,7 +98,7 @@ function GalleryDetail(props: {
98
98
  <div className="ims-detailInfo">
99
99
  <h4 className="ims-detailLabel">{t('gallery.detail.prompt')}</h4>
100
100
  <p className="ims-detailPrompt">{item.prompt}</p>
101
- {item.sourceIds.length > 0 ? (
101
+ {item.sourceIds.length + item.sourceUploads.length > 0 ? (
102
102
  <>
103
103
  <h4 className="ims-detailLabel">{t('gallery.detail.sources')}</h4>
104
104
  <div className="ims-sources">
@@ -107,6 +107,11 @@ function GalleryDetail(props: {
107
107
  <img src={imageUrl(imageId)} alt="" />
108
108
  </span>
109
109
  ))}
110
+ {item.sourceUploads.map((uploadId) => (
111
+ <span key={uploadId} className="ims-thumb" title={t('sources.uploaded')}>
112
+ <img src={uploadImageUrl(uploadId)} alt="" />
113
+ </span>
114
+ ))}
110
115
  </div>
111
116
  </>
112
117
  ) : null}
@@ -3,19 +3,25 @@
3
3
  * - 提供商预设选择 + 凭据状态徽标 + inline 覆盖(凭据沿用预设);
4
4
  * - 提示词编辑 + 预设插入/另存;
5
5
  * - 目录驱动的参数表单 + 参数预设应用/另存;
6
- * - 图生图追加源图(≤16)与可选遮罩选择(画廊取图);
6
+ * - 图生图追加源图(≤16,画廊选择与本地文件上传混用)与可选遮罩;
7
7
  * - 提交 generate → 202 taskId 交由面板任务条轮询。
8
8
  * 预设另存走 settings RPC(scope/api 注入),成功后经 onPresetSaved 刷新快照。
9
9
  * @module image-studio/client/panel/generator
10
10
  */
11
- import { IconLayers, type NamespaceSettingsApi, type Scope } from '@dsh-plus/shared/client'
11
+ import {
12
+ IconLayers,
13
+ IconPlusOutline16,
14
+ type NamespaceSettingsApi,
15
+ type Scope,
16
+ } from '@dsh-plus/shared/client'
12
17
  import { type ReactElement, useEffect, useMemo, useRef, useState } from 'react'
13
- import type { GenerateRequest, PresetsWire, ProvidersWire } from '../../dto.ts'
18
+ import type { GenerateRequest, PresetsWire, ProvidersWire, SourceRef } from '../../dto.ts'
14
19
  import type { GalleryItem } from '../../gallery/store.ts'
20
+ import { MAX_SOURCE_IMAGES } from '../../limits.ts'
15
21
  import type { ParamEntry } from '../../params/catalog.ts'
16
22
  import { type ParamSpecMap, validateParamSpecs } from '../../params/spec.ts'
17
23
  import type { ImageEndpoint } from '../../provider/types.ts'
18
- import { fetchCredentialStatus, generate, imageUrl } from '../api.ts'
24
+ import { fetchCredentialStatus, generate } from '../api.ts'
19
25
  import type { Translate } from '../i18n.ts'
20
26
  import type { EditSeed } from './controller.ts'
21
27
  import {
@@ -26,7 +32,8 @@ import {
26
32
  type ParamFormState,
27
33
  } from './param-state.ts'
28
34
  import { ParamsForm } from './params-form.tsx'
29
- import { ImagePicker } from './picker.tsx'
35
+ import { ImagePicker, refUrl } from './picker.tsx'
36
+ import { UploadButton, useUploads } from './uploads.tsx'
30
37
 
31
38
  interface GeneratorProps {
32
39
  t: Translate
@@ -71,8 +78,8 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
71
78
  const [credentialOk, setCredentialOk] = useState<boolean | null>(null)
72
79
  const [prompt, setPrompt] = useState('')
73
80
  const [form, setForm] = useState<ParamFormState>({})
74
- const [sourceIds, setSourceIds] = useState<string[]>([])
75
- const [maskId, setMaskId] = useState<string | null>(null)
81
+ const [sources, setSources] = useState<SourceRef[]>([])
82
+ const [mask, setMask] = useState<SourceRef | null>(null)
76
83
  const [picker, setPicker] = useState<'sources' | 'mask' | null>(null)
77
84
  const [naming, setNaming] = useState<{ kind: 'prompt' | 'params'; name: string } | null>(null)
78
85
  const [status, setStatus] = useState<Status>(IDLE)
@@ -81,6 +88,7 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
81
88
  const formReadyRef = useRef(false)
82
89
  /** 已消费的种子 nonce(防重复回填)。 */
83
90
  const seedRef = useRef(0)
91
+ const uploads = useUploads()
84
92
 
85
93
  // 目录到达后初始化参数表单。
86
94
  useEffect(() => {
@@ -95,7 +103,7 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
95
103
  if (seed === null || seed.nonce === seedRef.current || !formReadyRef.current) return
96
104
  seedRef.current = seed.nonce
97
105
  setPrompt(seed.prompt)
98
- setSourceIds(seed.sourceIds)
106
+ setSources(seed.sources)
99
107
  setForm(applyParams(catalog, endpoint, seed.params))
100
108
  if (seed.providerPresetId !== null) setProviderId(seed.providerPresetId)
101
109
  setStatus(IDLE)
@@ -208,7 +216,7 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
208
216
  setStatus({ kind: 'error', text: t('generate.needPrompt') })
209
217
  return
210
218
  }
211
- if (endpoint === 'edit' && sourceIds.length === 0) {
219
+ if (endpoint === 'edit' && sources.length === 0) {
212
220
  setStatus({ kind: 'error', text: t('generate.needSources') })
213
221
  return
214
222
  }
@@ -233,8 +241,8 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
233
241
  }
234
242
  }
235
243
  if (endpoint === 'edit') {
236
- request.sourceIds = sourceIds
237
- if (maskId !== null) request.maskId = maskId
244
+ request.sources = sources
245
+ if (mask !== null) request.mask = mask
238
246
  }
239
247
  setSubmitting(true)
240
248
  setStatus(IDLE)
@@ -379,39 +387,68 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
379
387
  <section className="ims-block">
380
388
  <h3 className="ims-blockTitle">{t('sources.label')}</h3>
381
389
  <div className="ims-sources">
382
- {sourceIds.map((imageId) => (
383
- <span key={imageId} className="ims-thumb">
384
- <img src={imageUrl(imageId)} alt="" />
390
+ {sources.map((ref) => (
391
+ <span key={`${ref.kind}:${ref.id}`} className="ims-thumb">
392
+ <img src={refUrl(ref)} alt="" />
385
393
  <button
386
394
  type="button"
387
395
  className="ims-thumbRemove"
388
396
  aria-label={t('common.delete')}
389
397
  onClick={() =>
390
- setSourceIds((current) => current.filter((id) => id !== imageId))
398
+ setSources((current) =>
399
+ current.filter((item) => !(item.kind === ref.kind && item.id === ref.id)),
400
+ )
391
401
  }
392
402
  >
393
403
  ×
394
404
  </button>
395
405
  </span>
396
406
  ))}
397
- <button type="button" className="ims-thumbAdd" onClick={() => setPicker('sources')}>
407
+ <button
408
+ type="button"
409
+ className="ims-thumbAdd"
410
+ disabled={sources.length >= MAX_SOURCE_IMAGES}
411
+ onClick={() => setPicker('sources')}
412
+ >
398
413
  <IconLayers size={18} />
399
414
  <span>{t('sources.pick')}</span>
400
415
  </button>
416
+ <UploadButton
417
+ t={t}
418
+ uploads={uploads}
419
+ multiple
420
+ disabled={sources.length >= MAX_SOURCE_IMAGES}
421
+ className="ims-thumbAdd"
422
+ onUploaded={(entries) =>
423
+ setSources((current) =>
424
+ [
425
+ ...current,
426
+ ...entries.map((entry) => ({ kind: 'upload' as const, id: entry.id })),
427
+ ].slice(0, MAX_SOURCE_IMAGES),
428
+ )
429
+ }
430
+ >
431
+ <IconPlusOutline16 size={18} />
432
+ <span>{t('upload.pick')}</span>
433
+ </UploadButton>
401
434
  </div>
402
- <p className="ims-hint">
403
- {t('sources.count').replace('{n}', String(sourceIds.length))}
404
- </p>
435
+ <p className="ims-hint">{t('sources.count').replace('{n}', String(sources.length))}</p>
436
+ {uploads.error !== null ? (
437
+ <p className="ims-status ims-statusError" role="alert">
438
+ {t('upload.failed')}
439
+ {uploads.error}
440
+ </p>
441
+ ) : null}
405
442
  <h3 className="ims-blockTitle">{t('sources.mask')}</h3>
406
443
  <div className="ims-sources">
407
- {maskId !== null ? (
444
+ {mask !== null ? (
408
445
  <span className="ims-thumb">
409
- <img src={imageUrl(maskId)} alt="" />
446
+ <img src={refUrl(mask)} alt="" />
410
447
  <button
411
448
  type="button"
412
449
  className="ims-thumbRemove"
413
450
  aria-label={t('sources.maskClear')}
414
- onClick={() => setMaskId(null)}
451
+ onClick={() => setMask(null)}
415
452
  >
416
453
  ×
417
454
  </button>
@@ -422,6 +459,7 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
422
459
  <span>{t('sources.maskPick')}</span>
423
460
  </button>
424
461
  </div>
462
+ <p className="ims-hint">{t('sources.maskHint')}</p>
425
463
  </section>
426
464
  ) : null}
427
465
  </div>
@@ -506,11 +544,12 @@ export function GeneratorForm(props: GeneratorProps): ReactElement {
506
544
  t={t}
507
545
  title={t(picker === 'sources' ? 'sources.label' : 'sources.mask')}
508
546
  items={gallery}
547
+ uploads={uploads}
509
548
  multiple={picker === 'sources'}
510
- initial={picker === 'sources' ? sourceIds : []}
511
- onConfirm={(ids) => {
512
- if (picker === 'sources') setSourceIds(ids)
513
- else setMaskId(ids[0] ?? null)
549
+ initial={picker === 'sources' ? sources : mask === null ? [] : [mask]}
550
+ onConfirm={(refs) => {
551
+ if (picker === 'sources') setSources(refs)
552
+ else setMask(refs[0] ?? null)
514
553
  setPicker(null)
515
554
  }}
516
555
  onClose={() => setPicker(null)}
@@ -188,7 +188,10 @@ export function StudioPanel(props: StudioPanelProps): ReactElement | null {
188
188
  onRefresh={reloadGallery}
189
189
  onEdit={(item) =>
190
190
  studio.seedEdit({
191
- sourceIds: [...item.imageIds],
191
+ sources: [
192
+ ...item.imageIds.map((id) => ({ kind: 'gallery' as const, id })),
193
+ ...item.sourceUploads.map((id) => ({ kind: 'upload' as const, id })),
194
+ ],
192
195
  prompt: item.prompt,
193
196
  params: item.params,
194
197
  providerPresetId: item.providerPresetId,
@@ -2,11 +2,13 @@
2
2
  * 参数表单:按参数目录动态渲染(kind → 控件形态,advanced 折叠)。
3
3
  * - boolean 参数的开关即取值(启用 = true);
4
4
  * - 其余参数:开关 + 值控件(enum 下拉 / integer 数字框 / string 文本框);
5
+ * - 声明了结构化规则的参数(size)追加预设快捷项与即时校验提示,自由输入不受限;
5
6
  * - 每项附中文说明与代际标记(GPT Image / dall-e-3 / dall-e-2)。
6
7
  * @module image-studio/client/panel/params-form
7
8
  */
8
9
  import type { ReactElement } from 'react'
9
10
  import type { ParamEntry } from '../../params/catalog.ts'
11
+ import { checkImageSize, SIZE_PRESETS } from '../../params/size.ts'
10
12
  import type { ImageEndpoint } from '../../provider/types.ts'
11
13
  import type { Translate } from '../i18n.ts'
12
14
  import { type ParamFormState, paramEntriesFor } from './param-state.ts'
@@ -19,7 +21,14 @@ interface ParamsFormProps {
19
21
  onChange(next: ParamFormState): void
20
22
  }
21
23
 
22
- /** 单参数行:开关 + 名称/说明 + 值控件。 */
24
+ /** 结构化规则的即时提示:规则通过或取值未填时不占位。 */
25
+ function ruleHint(entry: ParamEntry, value: string): string | null {
26
+ if (entry.rules !== 'image-size' || value.trim() === '') return null
27
+ const checked = checkImageSize(value)
28
+ return checked.ok ? null : checked.message
29
+ }
30
+
31
+ /** 单参数行:开关 + 名称/说明 + 值控件(+ 预设与校验提示)。 */
23
32
  function ParamRow(props: {
24
33
  t: Translate
25
34
  entry: ParamEntry
@@ -30,6 +39,10 @@ function ParamRow(props: {
30
39
  const field = form[entry.key] ?? { enabled: false, value: '' }
31
40
  const patch = (next: Partial<typeof field>): void =>
32
41
  onChange({ ...form, [entry.key]: { ...field, ...next } })
42
+ const text = String(field.value)
43
+ const hint = field.enabled ? ruleHint(entry, text) : null
44
+ /** size 预设:快捷填写,仍可自由改动输入框。 */
45
+ const presets = entry.rules === 'image-size' ? SIZE_PRESETS : []
33
46
 
34
47
  const control = (() => {
35
48
  if (entry.kind === 'boolean') return null
@@ -37,7 +50,7 @@ function ParamRow(props: {
37
50
  return (
38
51
  <select
39
52
  className="ims-input ims-paramControl"
40
- value={String(field.value)}
53
+ value={text}
41
54
  disabled={!field.enabled}
42
55
  onChange={(event) => patch({ value: event.target.value })}
43
56
  >
@@ -57,7 +70,7 @@ function ParamRow(props: {
57
70
  inputMode="numeric"
58
71
  min={entry.min}
59
72
  max={entry.max}
60
- value={String(field.value)}
73
+ value={text}
61
74
  disabled={!field.enabled}
62
75
  onChange={(event) => patch({ value: event.target.value })}
63
76
  />
@@ -65,10 +78,12 @@ function ParamRow(props: {
65
78
  }
66
79
  return (
67
80
  <input
68
- className="ims-input ims-paramControl"
81
+ className={`ims-input ims-paramControl${hint !== null ? ' ims-inputInvalid' : ''}`}
69
82
  type="text"
70
- value={String(field.value)}
83
+ list={presets.length > 0 ? `ims-size-options-${entry.key}` : undefined}
84
+ value={text}
71
85
  disabled={!field.enabled}
86
+ aria-invalid={hint !== null}
72
87
  onChange={(event) => patch({ value: event.target.value })}
73
88
  />
74
89
  )
@@ -99,8 +114,37 @@ function ParamRow(props: {
99
114
  ) : null}
100
115
  </span>
101
116
  <span className="ims-paramDesc">{entry.description}</span>
117
+ {hint !== null ? (
118
+ <span className="ims-paramWarn" role="alert">
119
+ {hint}
120
+ </span>
121
+ ) : null}
122
+ </div>
123
+ <div className="ims-paramValue">
124
+ {control}
125
+ {presets.length > 0 && field.enabled ? (
126
+ <span className="ims-paramPresets">
127
+ {presets.map((preset) => (
128
+ <button
129
+ key={preset.value}
130
+ type="button"
131
+ title={preset.label}
132
+ className={`ims-chip${preset.value === text ? ' ims-chipOn' : ''}`}
133
+ onClick={() => patch({ value: preset.value })}
134
+ >
135
+ {preset.value}
136
+ </button>
137
+ ))}
138
+ </span>
139
+ ) : null}
102
140
  </div>
103
- {control}
141
+ {presets.length > 0 ? (
142
+ <datalist id={`ims-size-options-${entry.key}`}>
143
+ {presets.map((preset) => (
144
+ <option key={preset.value} value={preset.value} label={preset.label} />
145
+ ))}
146
+ </datalist>
147
+ ) : null}
104
148
  </div>
105
149
  )
106
150
  }