@dsh-plus/image-studio 0.1.5 → 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/lib/client.js +523 -98
- package/lib/index.d.ts +172 -9
- package/lib/index.js +537 -57
- package/package.json +2 -2
- package/src/api.ts +83 -3
- package/src/client/api.ts +25 -0
- package/src/client/card.tsx +12 -0
- package/src/client/client.ts +2 -1
- package/src/client/i18n.ts +17 -0
- package/src/client/panel/controller.ts +4 -2
- package/src/client/panel/gallery.tsx +7 -2
- package/src/client/panel/generator.tsx +65 -26
- package/src/client/panel/panel.tsx +4 -1
- package/src/client/panel/params-form.tsx +50 -6
- package/src/client/panel/picker.tsx +124 -40
- package/src/client/panel/uploads.tsx +124 -0
- package/src/client/styles.ts +50 -0
- package/src/config.ts +6 -0
- package/src/dto.ts +20 -2
- package/src/errors.ts +6 -0
- package/src/gallery/store.ts +16 -15
- package/src/images/id.ts +12 -0
- package/src/images/mime.ts +64 -0
- package/src/index.ts +12 -5
- package/src/limits.ts +12 -0
- package/src/params/catalog.ts +9 -3
- package/src/params/size.ts +98 -0
- package/src/params/spec.ts +17 -2
- package/src/provider/openai-images.ts +9 -5
- package/src/provider/registry.ts +1 -1
- package/src/service.ts +160 -28
- package/src/uploads/store.ts +168 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/image-studio",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"https-proxy-agent": "^9.1.0",
|
|
23
|
-
"@dsh-plus/shared": "0.1.
|
|
23
|
+
"@dsh-plus/shared": "0.1.15"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@deepseek-ai/cordis": "^4.0.2",
|
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
|
|
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}`)
|
package/src/client/card.tsx
CHANGED
|
@@ -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
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -32,7 +32,8 @@ export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
|
|
|
32
32
|
const PLUGIN_ID = '@dsh-plus/image-studio'
|
|
33
33
|
|
|
34
34
|
interface SlotsLike {
|
|
35
|
-
inject
|
|
35
|
+
/** 官方 slots 服务:回调返回 disposer;inject 返回等待/生效 effect 的幂等 disposer。 */
|
|
36
|
+
inject(key: string, callback: () => unknown): () => void
|
|
36
37
|
register(options: Record<string, unknown>, component: unknown): () => void
|
|
37
38
|
}
|
|
38
39
|
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
-
/**
|
|
14
|
-
|
|
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 {
|
|
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
|
|
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 [
|
|
75
|
-
const [
|
|
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
|
-
|
|
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' &&
|
|
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.
|
|
237
|
-
if (
|
|
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
|
-
{
|
|
383
|
-
<span key={
|
|
384
|
-
<img src={
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
{
|
|
444
|
+
{mask !== null ? (
|
|
408
445
|
<span className="ims-thumb">
|
|
409
|
-
<img src={
|
|
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={() =>
|
|
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' ?
|
|
511
|
-
onConfirm={(
|
|
512
|
-
if (picker === 'sources')
|
|
513
|
-
else
|
|
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
|
-
|
|
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={
|
|
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={
|
|
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=
|
|
81
|
+
className={`ims-input ims-paramControl${hint !== null ? ' ims-inputInvalid' : ''}`}
|
|
69
82
|
type="text"
|
|
70
|
-
|
|
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
|
-
{
|
|
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
|
}
|