@dickpy/dsh-imagegen 1.2.3 → 1.4.0

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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
package/src/engine.ts CHANGED
@@ -1,464 +1,520 @@
1
- /**
2
- * Upstream proxy engine: forwards a generate request to the configured
3
- * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
- * /images/edits for image-to-image) and normalizes the response to base64
5
- * images so the browser never fetches the upstream itself.
6
- *
7
- * Framework-free (no cordis imports) so the route layer and tests can drive
8
- * it directly.
9
- */
10
-
11
- import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
-
13
- /** The upstream credentials the panel's settings card configures. */
14
- export interface UpstreamConfig {
15
- /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
16
- apiUrl: string
17
- /** Bearer API key. */
18
- apiKey: string
19
- }
20
-
21
- /** A generation failure with a user-presentable message. */
22
- export class ImageGenError extends Error {
23
- /** Stable wire code. */
24
- readonly code: string
25
-
26
- constructor(message: string, code = 'generate-failed') {
27
- super(message)
28
- this.name = 'ImageGenError'
29
- this.code = code
30
- }
31
- }
32
-
33
- /** Total budget for the upstream generation call (image models are slow). */
34
- const UPSTREAM_TIMEOUT_MS = 240_000
35
-
36
- /** Budget for downloading one result image URL. */
37
- const IMAGE_FETCH_TIMEOUT_MS = 60_000
38
-
39
- /** Cap on the reference image payload (edit mode), in bytes. */
40
- const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
41
-
42
- /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
43
- const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
44
-
45
- /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
46
- * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
47
- * and exposes its own aspect-ratio / response-format knobs instead of the
48
- * OpenAI size/quality/detail passthrough. */
49
- function isGrokImagine(model: string): boolean {
50
- return /^grok-imagine(?:-|$)/.test(model)
51
- }
52
-
53
- /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
54
- * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
55
- * gateways expose). OpenAI-compatible gateways serve these with their own
56
- * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
57
- * passthrough. */
58
- function isNanoBanana(model: string): boolean {
59
- if (/^nanobanana/i.test(model)) return true
60
- return (
61
- model === 'gemini-3-pro-image' || model === 'gemini-3-pro-image-preview' ||
62
- model === 'gemini-3.1-flash-image' || model === 'gemini-3.1-flash-image-preview' ||
63
- model === 'gemini-3.1-flash-lite-image' ||
64
- model === 'gemini-2.5-flash-image'
65
- )
66
- }
67
-
68
- /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
69
- * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
70
- * serve Seedream through a unified generate-and-edit architecture:
71
- * generation AND editing both go to /images/generations, reference images are
72
- * a JSON URL / data-URL array, and the clarity tier is `resolution` while
73
- * `size` carries the aspect ratio (or exact pixels). */
74
- function isSeedream(model: string): boolean {
75
- return /^(?:doubao-)?seedream/i.test(model)
76
- }
77
-
78
- /** The panel's aspect ratios mapped to the closest OpenAI pixel size
79
- * (gpt-image-2 / generic OpenAI-compatible endpoints). */
80
- const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
81
- '1:1': '1024x1024',
82
- '3:4': '1024x1536',
83
- '4:3': '1536x1024',
84
- '9:16': '1024x1792',
85
- '2:3': '1024x1536',
86
- '3:2': '1536x1024',
87
- '16:9': '1792x1024',
88
- '21:9': '1792x1024',
89
- }
90
-
91
- /** Panel ratios that need renaming for a model's vocabulary. Grok documents
92
- * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
93
- const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
94
- '21:9': '20:9',
95
- }
96
-
97
- /**
98
- * One request-scoped timeout that is cleared as soon as its fetch settles.
99
- * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
100
- * task queue leaves an otherwise idle Node process holding every timeout.
101
- */
102
- function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
103
- const controller = new AbortController()
104
- const abortFromSource = () => { controller.abort(source?.reason) }
105
- if (source?.aborted === true) abortFromSource()
106
- else source?.addEventListener('abort', abortFromSource, { once: true })
107
- const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
108
- timeout.unref()
109
- return {
110
- signal: controller.signal,
111
- dispose: () => {
112
- clearTimeout(timeout)
113
- source?.removeEventListener('abort', abortFromSource)
114
- },
115
- }
116
- }
117
-
118
- /** Content-type extension hints for URL-fetched images. */
119
- function mimeOfExtension(path: string): string | undefined {
120
- const match = /\.([a-z0-9]+)$/i.exec(path)
121
- if (match === null) return undefined
122
- switch (match[1]!.toLowerCase()) {
123
- case 'png': return 'image/png'
124
- case 'jpg':
125
- case 'jpeg': return 'image/jpeg'
126
- case 'webp': return 'image/webp'
127
- case 'gif': return 'image/gif'
128
- default: return undefined
129
- }
130
- }
131
-
132
- /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
133
- function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
134
- const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
135
- if (match === null || match[3] === undefined) return undefined
136
- if (match[2] === undefined) {
137
- // Plain (non-base64) data URLs are not supported for reference images.
138
- return undefined
139
- }
140
- return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
141
- }
142
-
143
- /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
144
- function bareBase64(value: string): string {
145
- const parsed = parseDataUrl(value)
146
- return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
147
- }
148
-
149
- /** Clamp the requested image count into the API-accepted range. */
150
- function clampCount(n: number): number {
151
- if (!Number.isFinite(n)) return 1
152
- return Math.min(4, Math.max(1, Math.round(n)))
153
- }
154
-
155
- /** Pick the effective per-model request parameters. Never includes `n`: the
156
- * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
157
- * so the count is satisfied by parallel single-image requests instead. */
158
- function effectiveParams(request: GenerateRequest): {
159
- model: string
160
- size?: string
161
- quality?: string
162
- detail?: string
163
- aspect_ratio?: string
164
- image_size?: string
165
- resolution?: string
166
- response_format?: string
167
- } {
168
- const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
169
- // dall-e-3 has no quality/detail knobs and only produces one image.
170
- if (model === 'dall-e-3') {
171
- const pixel = OPENAI_SIZE_BY_RATIO[request.size]
172
- const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
173
- return { model, size }
174
- }
175
- // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
176
- // the documented 20:9), the clarity tiers become the resolution parameter
177
- // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
178
- // output keeps the temporary signed result URLs from expiring before the
179
- // host downloads them.
180
- if (isGrokImagine(model)) {
181
- return {
182
- model,
183
- ...request.size !== '' && request.size !== 'auto'
184
- ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
185
- : {},
186
- ...request.quality !== '' && request.quality !== 'auto'
187
- ? { resolution: request.quality === '4k' ? '2k' : request.quality }
188
- : {},
189
- response_format: 'b64_json',
190
- }
191
- }
192
- // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
193
- // documents 1:1 … 21:9 natively), the clarity tiers become image_size
194
- // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
195
- // rejects higher tiers is its own call), and base64 output keeps any signed
196
- // result URLs from expiring before the host downloads them.
197
- if (isNanoBanana(model)) {
198
- return {
199
- model,
200
- ...request.size !== '' && request.size !== 'auto'
201
- ? { aspect_ratio: request.size }
202
- : {},
203
- ...request.quality !== '' && request.quality !== 'auto'
204
- ? { image_size: request.quality.toUpperCase() }
205
- : {},
206
- response_format: 'b64_json',
207
- }
208
- }
209
- // ByteDance Seedream: OpenAI-compatible gateways accept the aspect ratio in
210
- // `size` directly and the clarity tiers as `resolution` (1K / 2K; the 5.0-pro
211
- // tier caps at 2K, so 4k falls back to 2K). There is no quality/detail knob,
212
- // and edits reuse /images/generations with an image array (see below).
213
- if (isSeedream(model)) {
214
- return {
215
- model,
216
- ...request.size !== '' && request.size !== 'auto'
217
- ? { size: request.size }
218
- : {},
219
- ...request.quality !== '' && request.quality !== 'auto'
220
- ? { resolution: request.quality === '4k' ? '2K' : request.quality.toUpperCase() }
221
- : {},
222
- response_format: 'b64_json',
223
- }
224
- }
225
- // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
226
- // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
227
- return {
228
- model,
229
- ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
230
- ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
231
- : {},
232
- ...request.quality === '1k' ? { quality: 'low' } : {},
233
- ...request.quality === '2k' ? { quality: 'medium' } : {},
234
- ...request.quality === '4k' ? { quality: 'high' } : {},
235
- ...request.detail !== '' ? { detail: request.detail } : {},
236
- }
237
- }
238
-
239
- /** How many single-image requests to issue for the requested image count. */
240
- function effectiveCount(request: GenerateRequest): number {
241
- const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
242
- if (model === 'dall-e-3') return 1
243
- return clampCount(request.n)
244
- }
245
-
246
- /** Normalize one upstream data item into a base64 image. */
247
- async function normalizeItem(
248
- item: Record<string, unknown>,
249
- upstream: UpstreamConfig,
250
- ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
251
- const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
252
- if (typeof item.b64_json === 'string') {
253
- return { b64: bareBase64(item.b64_json), mime: 'image/png', revisedPrompt }
254
- }
255
- if (typeof item.url !== 'string' || item.url === '') {
256
- throw new ImageGenError('upstream image item has neither b64_json nor url')
257
- }
258
- const url = item.url
259
- if (url.startsWith('data:')) {
260
- const parsed = parseDataUrl(url)
261
- if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
262
- return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
263
- }
264
- const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
265
- let response: Response
266
- try {
267
- response = await fetch(url, {
268
- headers: {
269
- ...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
270
- },
271
- signal: budget.signal,
272
- })
273
- } catch (error) {
274
- throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
275
- } finally {
276
- budget.dispose()
277
- }
278
- if (!response.ok) {
279
- throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
280
- }
281
- const buffer = Buffer.from(await response.arrayBuffer())
282
- const contentType = response.headers.get('content-type')
283
- const mime = contentType !== null && contentType !== ''
284
- ? contentType.split(';')[0]!.trim()
285
- : mimeOfExtension(url) ?? 'image/png'
286
- return { b64: buffer.toString('base64'), mime, revisedPrompt }
287
- }
288
-
289
- /**
290
- * Issue one single-image request (never sends `n`). The response is kept as a
291
- * list so a gateway that happens to return several images per call still works.
292
- */
293
- async function requestOneImage(
294
- baseUrl: string,
295
- upstream: UpstreamConfig,
296
- request: GenerateRequest,
297
- params: ReturnType<typeof effectiveParams>,
298
- signal?: AbortSignal,
299
- ): Promise<GeneratedImage[]> {
300
- const headers: Record<string, string> = {
301
- authorization: `Bearer ${upstream.apiKey.trim()}`,
302
- }
303
- let body: BodyInit
304
- if (request.mode === 'edit') {
305
- if (typeof request.image !== 'string' || request.image === '') {
306
- throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
307
- }
308
- const parsed = parseDataUrl(request.image)
309
- if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
310
- let bytes: Buffer
311
- try {
312
- bytes = Buffer.from(parsed.base64, 'base64')
313
- } catch {
314
- throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
315
- }
316
- if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
317
- throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
318
- }
319
- // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
320
- // URI is accepted) instead of OpenAI's multipart form-data upload.
321
- if (isGrokImagine(params.model)) {
322
- headers['content-type'] = 'application/json'
323
- body = JSON.stringify({
324
- model: params.model,
325
- prompt: request.prompt,
326
- image: { url: request.image, type: 'image_url' },
327
- ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
328
- response_format: 'b64_json',
329
- })
330
- } else if (isNanoBanana(params.model)) {
331
- // Nano Banana OpenAI-compatible gateways accept the standard multipart
332
- // edit upload, with the family's own aspect_ratio / image_size knobs.
333
- const form = new FormData()
334
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
335
- form.append('prompt', request.prompt)
336
- form.append('model', params.model)
337
- if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
338
- if (params.image_size !== undefined) form.append('image_size', params.image_size)
339
- body = form
340
- } else if (isSeedream(params.model)) {
341
- // Seedream unifies generation and editing on /images/generations; the
342
- // reference image is a JSON URL / data-URL array, never multipart.
343
- headers['content-type'] = 'application/json'
344
- body = JSON.stringify({
345
- model: params.model,
346
- prompt: request.prompt,
347
- image: [request.image],
348
- ...params.size !== undefined ? { size: params.size } : {},
349
- ...params.resolution !== undefined ? { resolution: params.resolution } : {},
350
- response_format: 'b64_json',
351
- })
352
- } else {
353
- const form = new FormData()
354
- form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
355
- form.append('prompt', request.prompt)
356
- form.append('model', params.model)
357
- if (params.size !== undefined) form.append('size', params.size)
358
- if (params.quality !== undefined) form.append('quality', params.quality)
359
- if (params.detail !== undefined) form.append('detail', params.detail)
360
- body = form
361
- }
362
- } else {
363
- headers['content-type'] = 'application/json'
364
- body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
365
- }
366
-
367
- const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
368
- let response: Response
369
- try {
370
- // Seedream has no /images/edits endpoint: both modes hit generations.
371
- const endpoint = request.mode === 'edit' && !isSeedream(params.model)
372
- ? '/images/edits'
373
- : '/images/generations'
374
- response = await fetch(`${baseUrl}${endpoint}`, {
375
- method: 'POST',
376
- headers,
377
- body,
378
- signal: budget.signal,
379
- })
380
- } catch (error) {
381
- const message = error instanceof Error ? error.message : String(error)
382
- if (/aborter/i.test(message) || /timeout/i.test(message)) {
383
- throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
384
- }
385
- throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
386
- } finally {
387
- budget.dispose()
388
- }
389
-
390
- let payload: unknown
391
- try {
392
- payload = await response.json()
393
- } catch {
394
- throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
395
- }
396
- if (!response.ok || payload === null || typeof payload !== 'object') {
397
- throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
398
- }
399
-
400
- const record = payload as Record<string, unknown>
401
- const data = Array.isArray(record.data)
402
- ? record.data
403
- : Array.isArray(record.images)
404
- ? record.images
405
- : Array.isArray(record.output)
406
- ? record.output
407
- : undefined
408
- if (data === undefined) {
409
- throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
410
- }
411
- if (data.length === 0) {
412
- throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
413
- }
414
- return Promise.all(data.map(async (entry) => {
415
- if (entry === null || typeof entry !== 'object') {
416
- throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
417
- }
418
- return normalizeItem(entry as Record<string, unknown>, upstream)
419
- }))
420
- }
421
-
422
- /**
423
- * Forward one generate request to the configured endpoint. The requested image
424
- * count is satisfied with N parallel single-image requests (the `n` batch
425
- * parameter is never sent, because Responses-API-based gateways reject it as
426
- * `tools[0].n`), then the results are flattened in order.
427
- */
428
- export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
429
- const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
430
- if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
431
- if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
432
- const params = effectiveParams(request)
433
- const count = effectiveCount(request)
434
- const batches = await Promise.all(
435
- Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
436
- )
437
- return { images: batches.flat() }
438
- }
439
-
440
- /** Human-readable failure message from an upstream error payload. */
441
- function upstreamMessage(payload: unknown, status: number): string {
442
- if (payload !== null && typeof payload === 'object') {
443
- const record = payload as Record<string, unknown>
444
- const error = record.error
445
- if (error !== null && typeof error === 'object') {
446
- const message = (error as Record<string, unknown>).message
447
- if (typeof message === 'string' && message !== '') return message
448
- }
449
- if (typeof record.message === 'string' && record.message !== '') return record.message
450
- if (typeof record.error === 'string' && record.error !== '') return record.error
451
- }
452
- return `上游接口拒绝请求(HTTP ${status})`
453
- }
454
-
455
- /** File extension for a MIME type (multipart reference image). */
456
- function extensionOf(mime: string): string {
457
- switch (mime.split(';')[0]!.trim()) {
458
- case 'image/jpeg': return 'jpg'
459
- case 'image/webp': return 'webp'
460
- case 'image/gif': return 'gif'
461
- case 'image/png':
462
- default: return 'png'
463
- }
464
- }
1
+ /**
2
+ * Upstream proxy engine: forwards a generate request to the configured
3
+ * OpenAI-compatible image endpoint (/images/generations for text-to-image,
4
+ * /images/edits for image-to-image) and normalizes the response to base64
5
+ * images so the browser never fetches the upstream itself.
6
+ *
7
+ * Framework-free (no cordis imports) so the route layer and tests can drive
8
+ * it directly.
9
+ */
10
+
11
+ import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
12
+ import { detectImageMime } from './image-format.ts'
13
+ import { modelFamily } from './model-catalog.ts'
14
+
15
+ /** The upstream credentials the panel's settings card configures. */
16
+ export interface UpstreamConfig {
17
+ /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
18
+ apiUrl: string
19
+ /** Bearer API key. */
20
+ apiKey: string
21
+ }
22
+
23
+ /** A generation failure with a user-presentable message. */
24
+ export class ImageGenError extends Error {
25
+ /** Stable wire code. */
26
+ readonly code: string
27
+
28
+ constructor(message: string, code = 'generate-failed') {
29
+ super(message)
30
+ this.name = 'ImageGenError'
31
+ this.code = code
32
+ }
33
+ }
34
+
35
+ /** Total budget for the upstream generation call (image models are slow). */
36
+ const UPSTREAM_TIMEOUT_MS = 240_000
37
+
38
+ /** Budget for downloading one result image URL. */
39
+ const IMAGE_FETCH_TIMEOUT_MS = 60_000
40
+
41
+ /** Cap on the reference image payload (edit mode), in bytes. */
42
+ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
43
+
44
+ /** Sizes dall-e-3 accepts; anything else falls back to its square default. */
45
+ const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
46
+
47
+ /** The wire model id for a request: `upstream` (host-filled alias mapping)
48
+ * wins, then the alias, then the family default. */
49
+ function wireModel(request: GenerateRequest): string {
50
+ const upstream = request.upstream?.trim()
51
+ if (upstream !== undefined && upstream !== '') return upstream
52
+ const alias = request.model.trim()
53
+ return alias === '' ? 'gpt-image-2' : alias
54
+ }
55
+
56
+ /** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
57
+ * grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
58
+ * and exposes its own aspect-ratio / response-format knobs instead of the
59
+ * OpenAI size/quality/detail passthrough. */
60
+ function isGrokImagine(model: string): boolean {
61
+ return modelFamily(model) === 'grok'
62
+ }
63
+
64
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
+ * gateways expose). OpenAI-compatible gateways serve these with their own
67
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
+ * passthrough. */
69
+ function isNanoBanana(model: string): boolean {
70
+ return modelFamily(model) === 'nanobanana'
71
+ }
72
+
73
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
+ * serve Seedream through a unified generate-and-edit architecture:
76
+ * generation AND editing both go to /images/generations and reference images
77
+ * are a JSON URL / data-URL array. */
78
+ function isSeedream(model: string): boolean {
79
+ return modelFamily(model) === 'seedream'
80
+ }
81
+
82
+ /** Whether the model uses the official Zhipu image-generation contract. */
83
+ function isZhipuImage(model: string): boolean {
84
+ return modelFamily(model) === 'zhipu'
85
+ }
86
+
87
+ function isGlmImage(model: string): boolean {
88
+ return /^glm-image(?:-|$)/i.test(model.trim())
89
+ }
90
+
91
+ /** Whether this is the official Volcengine Ark model naming convention. */
92
+ function isVolcSeedream(model: string): boolean {
93
+ return /^doubao-seedream(?:-|$)/i.test(model.trim())
94
+ }
95
+
96
+ /** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
97
+ function seedreamSize(quality: string): string {
98
+ // Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
99
+ // degrading them to the highest supported tier instead of sending 4K.
100
+ if (quality === '1k') return '1K'
101
+ return '2K'
102
+ }
103
+
104
+ /** The panel's aspect ratios mapped to the closest OpenAI pixel size
105
+ * (gpt-image-2 / generic OpenAI-compatible endpoints). */
106
+ const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
107
+ '1:1': '1024x1024',
108
+ '3:4': '1024x1536',
109
+ '4:3': '1536x1024',
110
+ '9:16': '1024x1792',
111
+ '2:3': '1024x1536',
112
+ '3:2': '1536x1024',
113
+ '16:9': '1792x1024',
114
+ '21:9': '1792x1024',
115
+ }
116
+
117
+ /** Panel ratios that need renaming for a model's vocabulary. Grok documents
118
+ * 20:9 as its ultra-wide ratio, so the panel's 21:9 label is sent as 20:9. */
119
+ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
120
+ '21:9': '20:9',
121
+ }
122
+
123
+ /**
124
+ * One request-scoped timeout that is cleared as soon as its fetch settles.
125
+ * AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
126
+ * task queue leaves an otherwise idle Node process holding every timeout.
127
+ */
128
+ function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
129
+ const controller = new AbortController()
130
+ const abortFromSource = () => { controller.abort(source?.reason) }
131
+ if (source?.aborted === true) abortFromSource()
132
+ else source?.addEventListener('abort', abortFromSource, { once: true })
133
+ const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
134
+ timeout.unref()
135
+ return {
136
+ signal: controller.signal,
137
+ dispose: () => {
138
+ clearTimeout(timeout)
139
+ source?.removeEventListener('abort', abortFromSource)
140
+ },
141
+ }
142
+ }
143
+
144
+ /** Content-type extension hints for URL-fetched images. */
145
+ function mimeOfExtension(path: string): string | undefined {
146
+ const match = /\.([a-z0-9]+)$/i.exec(path)
147
+ if (match === null) return undefined
148
+ switch (match[1]!.toLowerCase()) {
149
+ case 'png': return 'image/png'
150
+ case 'jpg':
151
+ case 'jpeg': return 'image/jpeg'
152
+ case 'webp': return 'image/webp'
153
+ case 'gif': return 'image/gif'
154
+ default: return undefined
155
+ }
156
+ }
157
+
158
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
159
+ function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
160
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
161
+ if (match === null || match[3] === undefined) return undefined
162
+ if (match[2] === undefined) {
163
+ // Plain (non-base64) data URLs are not supported for reference images.
164
+ return undefined
165
+ }
166
+ return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
167
+ }
168
+
169
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
170
+ function bareBase64(value: string): string {
171
+ const parsed = parseDataUrl(value)
172
+ return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
173
+ }
174
+
175
+ /** Whether a result URL carries cloud-storage signing credentials. */
176
+ function isPresignedUrl(value: string): boolean {
177
+ let url: URL
178
+ try {
179
+ url = new URL(value)
180
+ } catch {
181
+ return false
182
+ }
183
+ const params = new Set(Array.from(url.searchParams.keys(), key => key.toLowerCase()))
184
+ if (params.has('x-goog-signature') || params.has('x-goog-credential')) return true
185
+ if (params.has('x-amz-signature') || params.has('x-amz-credential')) return true
186
+ return params.has('signature') && (
187
+ params.has('expires') || params.has('googleaccessid') || params.has('awsaccesskeyid')
188
+ )
189
+ }
190
+
191
+ /** Clamp the requested image count into the API-accepted range. */
192
+ function clampCount(n: number): number {
193
+ if (!Number.isFinite(n)) return 1
194
+ return Math.min(4, Math.max(1, Math.round(n)))
195
+ }
196
+
197
+ /** Pick the effective per-model request parameters. Never includes `n`: the
198
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
199
+ * so the count is satisfied by parallel single-image requests instead. */
200
+ function effectiveParams(request: GenerateRequest): {
201
+ model: string
202
+ size?: string
203
+ quality?: string
204
+ detail?: string
205
+ aspect_ratio?: string
206
+ image_size?: string
207
+ resolution?: string
208
+ response_format?: string
209
+ } {
210
+ const model = wireModel(request)
211
+ // dall-e-3 has no quality/detail knobs and only produces one image.
212
+ if (model === 'dall-e-3') {
213
+ const pixel = OPENAI_SIZE_BY_RATIO[request.size]
214
+ const size = (pixel !== undefined && DALLE3_SIZES.has(pixel)) ? pixel : '1024x1024'
215
+ return { model, size }
216
+ }
217
+ // Grok Imagine: the panel's aspect ratios are sent as-is (21:9 aliased to
218
+ // the documented 20:9), the clarity tiers become the resolution parameter
219
+ // (the API documents 1k / 2k only, so 4k falls back to 2k), and base64
220
+ // output keeps the temporary signed result URLs from expiring before the
221
+ // host downloads them.
222
+ if (isGrokImagine(model)) {
223
+ return {
224
+ model,
225
+ ...request.size !== '' && request.size !== 'auto'
226
+ ? { aspect_ratio: GROK_ASPECT_ALIASES[request.size] ?? request.size }
227
+ : {},
228
+ ...request.quality !== '' && request.quality !== 'auto'
229
+ ? { resolution: request.quality === '4k' ? '2k' : request.quality }
230
+ : {},
231
+ response_format: 'b64_json',
232
+ }
233
+ }
234
+ // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
235
+ // documents 1:1 21:9 natively), the clarity tiers become image_size
236
+ // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
237
+ // rejects higher tiers is its own call), and base64 output keeps any signed
238
+ // result URLs from expiring before the host downloads them.
239
+ if (isNanoBanana(model)) {
240
+ return {
241
+ model,
242
+ ...request.size !== '' && request.size !== 'auto'
243
+ ? { aspect_ratio: request.size }
244
+ : {},
245
+ ...request.quality !== '' && request.quality !== 'auto'
246
+ ? { image_size: request.quality.toUpperCase() }
247
+ : {},
248
+ response_format: 'b64_json',
249
+ }
250
+ }
251
+ // ByteDance Seedream: the official Volcengine Ark API uses `size` for the
252
+ // resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
253
+ // temporary URLs, so ask Ark for URL output and let the host download it.
254
+ // Other compatible gateways retain the base64 response fallback.
255
+ if (isSeedream(model)) {
256
+ return {
257
+ model,
258
+ size: seedreamSize(request.quality),
259
+ response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
260
+ }
261
+ }
262
+ // Zhipu's official image API accepts OpenAI-style JSON but uses its own
263
+ // quality vocabulary. GLM-Image currently supports hd only; CogView uses
264
+ // the standard tier. Size remains a valid custom pixel size for both.
265
+ if (isZhipuImage(model)) {
266
+ return {
267
+ model,
268
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
269
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
270
+ : {},
271
+ quality: isGlmImage(model) ? 'hd' : 'standard',
272
+ }
273
+ }
274
+ // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
275
+ // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
276
+ return {
277
+ model,
278
+ ...request.size !== '' && request.size !== 'auto' && OPENAI_SIZE_BY_RATIO[request.size] !== undefined
279
+ ? { size: OPENAI_SIZE_BY_RATIO[request.size] }
280
+ : {},
281
+ ...request.quality === '1k' ? { quality: 'low' } : {},
282
+ ...request.quality === '2k' ? { quality: 'medium' } : {},
283
+ ...request.quality === '4k' ? { quality: 'high' } : {},
284
+ ...request.detail !== '' ? { detail: request.detail } : {},
285
+ }
286
+ }
287
+
288
+ /** How many single-image requests to issue for the requested image count. */
289
+ function effectiveCount(request: GenerateRequest): number {
290
+ const model = wireModel(request)
291
+ if (model === 'dall-e-3') return 1
292
+ return clampCount(request.n)
293
+ }
294
+
295
+ /** Normalize one upstream data item into a base64 image. */
296
+ async function normalizeItem(
297
+ item: Record<string, unknown>,
298
+ upstream: UpstreamConfig,
299
+ ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
300
+ const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
301
+ if (typeof item.b64_json === 'string' && item.b64_json.trim() !== '') {
302
+ const b64 = bareBase64(item.b64_json)
303
+ if (b64.trim() !== '') {
304
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
305
+ }
306
+ }
307
+ if (typeof item.url !== 'string' || item.url === '') {
308
+ throw new ImageGenError('upstream image item has neither b64_json nor url')
309
+ }
310
+ const url = item.url
311
+ if (url.startsWith('data:')) {
312
+ const parsed = parseDataUrl(url)
313
+ if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
314
+ return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
315
+ }
316
+ const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
317
+ let response: Response
318
+ try {
319
+ response = await fetch(url, {
320
+ ...isPresignedUrl(url) || upstream.apiKey === ''
321
+ ? {}
322
+ : { headers: { authorization: `Bearer ${upstream.apiKey}` } },
323
+ signal: budget.signal,
324
+ })
325
+ } catch (error) {
326
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
327
+ } finally {
328
+ budget.dispose()
329
+ }
330
+ if (!response.ok) {
331
+ throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
332
+ }
333
+ const buffer = Buffer.from(await response.arrayBuffer())
334
+ const contentType = response.headers.get('content-type')
335
+ const mime = detectImageMime(buffer)
336
+ ?? (contentType !== null && contentType !== ''
337
+ ? contentType.split(';')[0]!.trim()
338
+ : mimeOfExtension(url) ?? 'image/png')
339
+ return { b64: buffer.toString('base64'), mime, revisedPrompt }
340
+ }
341
+
342
+ /**
343
+ * Issue one single-image request (never sends `n`). The response is kept as a
344
+ * list so a gateway that happens to return several images per call still works.
345
+ */
346
+ async function requestOneImage(
347
+ baseUrl: string,
348
+ upstream: UpstreamConfig,
349
+ request: GenerateRequest,
350
+ params: ReturnType<typeof effectiveParams>,
351
+ signal?: AbortSignal,
352
+ ): Promise<GeneratedImage[]> {
353
+ const headers: Record<string, string> = {
354
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
355
+ }
356
+ let body: BodyInit
357
+ if (request.mode === 'edit') {
358
+ if (typeof request.image !== 'string' || request.image === '') {
359
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
360
+ }
361
+ const parsed = parseDataUrl(request.image)
362
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
363
+ let bytes: Buffer
364
+ try {
365
+ bytes = Buffer.from(parsed.base64, 'base64')
366
+ } catch {
367
+ throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
368
+ }
369
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
370
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
371
+ }
372
+ // Grok Imagine /images/edits takes a JSON image_url object (a base64 data
373
+ // URI is accepted) instead of OpenAI's multipart form-data upload.
374
+ if (isGrokImagine(params.model)) {
375
+ headers['content-type'] = 'application/json'
376
+ body = JSON.stringify({
377
+ model: params.model,
378
+ prompt: request.prompt,
379
+ image: { url: request.image, type: 'image_url' },
380
+ ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
381
+ response_format: 'b64_json',
382
+ })
383
+ } else if (isNanoBanana(params.model)) {
384
+ // Nano Banana OpenAI-compatible gateways accept the standard multipart
385
+ // edit upload, with the family's own aspect_ratio / image_size knobs.
386
+ const form = new FormData()
387
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
388
+ form.append('prompt', request.prompt)
389
+ form.append('model', params.model)
390
+ if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
391
+ if (params.image_size !== undefined) form.append('image_size', params.image_size)
392
+ body = form
393
+ } else if (isSeedream(params.model)) {
394
+ // Seedream unifies generation and editing on /images/generations; the
395
+ // reference image is a JSON URL / data-URL array, never multipart.
396
+ headers['content-type'] = 'application/json'
397
+ body = JSON.stringify({
398
+ model: params.model,
399
+ prompt: request.prompt,
400
+ image: [request.image],
401
+ ...params.size !== undefined ? { size: params.size } : {},
402
+ ...params.resolution !== undefined ? { resolution: params.resolution } : {},
403
+ response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
404
+ })
405
+ } else {
406
+ const form = new FormData()
407
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
408
+ form.append('prompt', request.prompt)
409
+ form.append('model', params.model)
410
+ if (params.size !== undefined) form.append('size', params.size)
411
+ if (params.quality !== undefined) form.append('quality', params.quality)
412
+ if (params.detail !== undefined) form.append('detail', params.detail)
413
+ body = form
414
+ }
415
+ } else {
416
+ headers['content-type'] = 'application/json'
417
+ body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
418
+ }
419
+
420
+ const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
421
+ let response: Response
422
+ try {
423
+ // Seedream has no /images/edits endpoint: both modes hit generations.
424
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
425
+ ? '/images/edits'
426
+ : '/images/generations'
427
+ response = await fetch(`${baseUrl}${endpoint}`, {
428
+ method: 'POST',
429
+ headers,
430
+ body,
431
+ signal: budget.signal,
432
+ })
433
+ } catch (error) {
434
+ const message = error instanceof Error ? error.message : String(error)
435
+ if (/aborter/i.test(message) || /timeout/i.test(message)) {
436
+ throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
437
+ }
438
+ throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
439
+ } finally {
440
+ budget.dispose()
441
+ }
442
+
443
+ let payload: unknown
444
+ try {
445
+ payload = await response.json()
446
+ } catch {
447
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
448
+ }
449
+ if (!response.ok || payload === null || typeof payload !== 'object') {
450
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
451
+ }
452
+
453
+ const record = payload as Record<string, unknown>
454
+ const data = Array.isArray(record.data)
455
+ ? record.data
456
+ : Array.isArray(record.images)
457
+ ? record.images
458
+ : Array.isArray(record.output)
459
+ ? record.output
460
+ : undefined
461
+ if (data === undefined) {
462
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
463
+ }
464
+ if (data.length === 0) {
465
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
466
+ }
467
+ return Promise.all(data.map(async (entry) => {
468
+ if (entry === null || typeof entry !== 'object') {
469
+ throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
470
+ }
471
+ return normalizeItem(entry as Record<string, unknown>, upstream)
472
+ }))
473
+ }
474
+
475
+ /**
476
+ * Forward one generate request to the configured endpoint. The requested image
477
+ * count is satisfied with N parallel single-image requests (the `n` batch
478
+ * parameter is never sent, because Responses-API-based gateways reject it as
479
+ * `tools[0].n`), then the results are flattened in order.
480
+ */
481
+ export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
482
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
483
+ if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
484
+ if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
485
+ if (request.mode === 'edit' && isZhipuImage(wireModel(request))) {
486
+ throw new ImageGenError('智谱 GLM-Image 当前仅支持文生图,请切换到文生图模式或选择支持图生图的模型', 'edit-unsupported')
487
+ }
488
+ const params = effectiveParams(request)
489
+ const count = effectiveCount(request)
490
+ const batches = await Promise.all(
491
+ Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
492
+ )
493
+ return { images: batches.flat() }
494
+ }
495
+
496
+ /** Human-readable failure message from an upstream error payload. */
497
+ function upstreamMessage(payload: unknown, status: number): string {
498
+ if (payload !== null && typeof payload === 'object') {
499
+ const record = payload as Record<string, unknown>
500
+ const error = record.error
501
+ if (error !== null && typeof error === 'object') {
502
+ const message = (error as Record<string, unknown>).message
503
+ if (typeof message === 'string' && message !== '') return message
504
+ }
505
+ if (typeof record.message === 'string' && record.message !== '') return record.message
506
+ if (typeof record.error === 'string' && record.error !== '') return record.error
507
+ }
508
+ return `上游接口拒绝请求(HTTP ${status})`
509
+ }
510
+
511
+ /** File extension for a MIME type (multipart reference image). */
512
+ function extensionOf(mime: string): string {
513
+ switch (mime.split(';')[0]!.trim()) {
514
+ case 'image/jpeg': return 'jpg'
515
+ case 'image/webp': return 'webp'
516
+ case 'image/gif': return 'gif'
517
+ case 'image/png':
518
+ default: return 'png'
519
+ }
520
+ }