@dickpy/dsh-imagegen 1.0.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Browser-side settings scope for the dsh-imagegen namespace, served by the
3
+ * plugin's own loopback bridge routes (/api/dsh-imagegen/settings). The
4
+ * official rc.6 settings scope answers "unavailable" for every third-party
5
+ * namespace (the host-apiproxy allowlist is hard-coded), so this package
6
+ * re-serves its namespace through the host settings seam over a same-origin,
7
+ * loopback-only HTTP pair — the same pattern the dsh-web-ui family bridge
8
+ * uses, self-contained per plugin.
9
+ */
10
+
11
+ import {
12
+ createSnapshotStore,
13
+ type SettingsScope,
14
+ type SettingsScopeSnapshot,
15
+ type SnapshotStore,
16
+ } from '@deepseek-ai/dsh-client-runtime/client'
17
+ import { SETTINGS_API } from '../protocol.ts'
18
+
19
+ /** The fields this plugin's settings card edits. */
20
+ export interface ImageGenConfig {
21
+ enabled?: boolean
22
+ announceToAgent?: boolean
23
+ apiUrl?: string
24
+ apiKey?: string
25
+ }
26
+
27
+ /** Wire shape of one namespace view from the bridge. */
28
+ interface BridgeView {
29
+ ns: string
30
+ value: unknown
31
+ base?: unknown
32
+ user?: unknown
33
+ revision: number
34
+ secrets?: Array<{ path: string[]; set: boolean }>
35
+ }
36
+
37
+ /** The bridge response envelope ({ ok: true, value } | { ok: false, code, message }). */
38
+ type BridgeEnvelope =
39
+ | { ok: true; value: { namespaces?: BridgeView[]; writable?: boolean } | BridgeView }
40
+ | { ok: false; code: string; message: string }
41
+
42
+ /** Settings wire face over the bridge routes (fetch-backed). */
43
+ function createBridgeApi(fetchFn: typeof fetch): {
44
+ settings: {
45
+ describe(payload: Record<string, never>): Promise<{ result: BridgeEnvelope }>
46
+ mutate(payload: { ns: string; ops: unknown[]; expectedRevision?: number }): Promise<{ result: BridgeEnvelope }>
47
+ }
48
+ } {
49
+ const post = async (path: string, body: unknown): Promise<{ result: BridgeEnvelope }> => {
50
+ try {
51
+ const response = await fetchFn(path, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/json' },
54
+ body: JSON.stringify(body),
55
+ })
56
+ if (!response.ok) {
57
+ return { result: { ok: false, code: 'internal', message: `bridge HTTP ${response.status}` } }
58
+ }
59
+ return { result: await response.json() as BridgeEnvelope }
60
+ } catch {
61
+ return { result: { ok: false, code: 'internal', message: 'settings bridge unreachable' } }
62
+ }
63
+ }
64
+ return {
65
+ settings: {
66
+ describe: async payload => post(SETTINGS_API.describe, payload),
67
+ mutate: async payload => post(SETTINGS_API.mutate, payload),
68
+ },
69
+ }
70
+ }
71
+
72
+ /**
73
+ * A SettingsScope over the bridge face: serialized queue, revision-fenced
74
+ * writes, recovery read after a refusal. Mirrors the official controller's
75
+ * ordering but trusts the Host-seam value without re-running the wire-schema
76
+ * validation — the seam already validated it.
77
+ */
78
+ class BridgeScopeController<T> implements SettingsScope<T> {
79
+ private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
80
+ /** Whether the namespace currently holds a stored secret (e.g. apiKey). */
81
+ private readonly keySet: SnapshotStore<boolean>
82
+ private tail: Promise<void> = Promise.resolve()
83
+ private disposed = false
84
+
85
+ constructor(
86
+ private readonly api: ReturnType<typeof createBridgeApi>['settings'],
87
+ private readonly spec: { namespace: string },
88
+ ) {
89
+ this.store = createSnapshotStore<SettingsScopeSnapshot<T>>({
90
+ status: 'loading',
91
+ value: undefined,
92
+ base: undefined,
93
+ user: undefined,
94
+ revision: undefined,
95
+ writable: false,
96
+ mode: 'host',
97
+ })
98
+ this.keySet = createSnapshotStore(false)
99
+ }
100
+
101
+ getSnapshot(): SettingsScopeSnapshot<T> {
102
+ return this.store.getSnapshot()
103
+ }
104
+
105
+ /** Whether a stored secret exists (from the redacted view's secrets list). */
106
+ getKeySetSnapshot(): boolean {
107
+ return this.keySet.getSnapshot()
108
+ }
109
+
110
+ /** Observe the secret-set flag. */
111
+ subscribeKeySet(listener: () => void): () => void {
112
+ return this.keySet.subscribe(listener)
113
+ }
114
+
115
+ subscribe(listener: () => void): () => void {
116
+ return this.store.subscribe(listener)
117
+ }
118
+
119
+ /** Queue a bridge refresh. */
120
+ load(): Promise<void> {
121
+ return this.enqueue(() => this.read())
122
+ }
123
+
124
+ set(field: string, value: unknown): Promise<void> {
125
+ return this.enqueue(() => this.write({ op: 'set', path: [field], value }))
126
+ }
127
+
128
+ unset(field: string): Promise<void> {
129
+ return this.enqueue(() => this.write({ op: 'unset', path: [field] }))
130
+ }
131
+
132
+ async dispose(): Promise<void> {
133
+ this.disposed = true
134
+ await this.tail
135
+ }
136
+
137
+ private enqueue(operation: () => Promise<void>): Promise<void> {
138
+ if (this.disposed) return Promise.resolve()
139
+ const task = this.tail.then(async () => {
140
+ if (this.disposed) return
141
+ await operation()
142
+ })
143
+ this.tail = task.catch(() => {})
144
+ return task
145
+ }
146
+
147
+ private async read(): Promise<void> {
148
+ let response
149
+ try {
150
+ response = await this.api.describe({})
151
+ } catch {
152
+ if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
153
+ return
154
+ }
155
+ if (!response.result.ok || this.disposed) {
156
+ if (!this.disposed) this.store.update(draft => { draft.status = 'unavailable' })
157
+ return
158
+ }
159
+ const { namespaces, writable } = response.result.value as { namespaces?: BridgeView[]; writable?: boolean }
160
+ const view = namespaces?.find(candidate => candidate.ns === this.spec.namespace)
161
+ if (view === undefined) {
162
+ this.store.update(draft => {
163
+ draft.status = 'unavailable'
164
+ draft.writable = writable === true
165
+ })
166
+ this.keySet.set(false)
167
+ return
168
+ }
169
+ this.accept(view, writable)
170
+ }
171
+
172
+ private async write(op: { op: 'set' | 'unset'; path: string[]; value?: unknown }): Promise<void> {
173
+ const revision = this.getSnapshot().revision
174
+ let response
175
+ try {
176
+ response = await this.api.mutate({
177
+ ns: this.spec.namespace,
178
+ ops: [op],
179
+ ...revision === undefined ? {} : { expectedRevision: revision },
180
+ })
181
+ } catch {
182
+ await this.read()
183
+ return
184
+ }
185
+ if (!response.result.ok || this.disposed) {
186
+ await this.read()
187
+ return
188
+ }
189
+ this.accept(response.result.value as BridgeView, undefined)
190
+ }
191
+
192
+ private accept(view: BridgeView, writable: boolean | undefined): void {
193
+ this.store.update(draft => {
194
+ draft.revision = view.revision
195
+ draft.base = view.base
196
+ draft.user = view.user
197
+ if (writable !== undefined) draft.writable = writable
198
+ draft.status = 'ready'
199
+ // Trust the Host-seam value: the seam already validated it, and the
200
+ // card binds without a narrowing decoder.
201
+ draft.value = view.value as T
202
+ })
203
+ this.keySet.set(Array.isArray(view.secrets) && view.secrets.some(secret => secret.set))
204
+ }
205
+ }
206
+
207
+ /** The bound scope plus the secret-set flag, as the card and panel consume it. */
208
+ export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
209
+ /** Queue a bridge refresh (the invalidation path re-reads the namespace). */
210
+ load(): Promise<void>
211
+ getKeySetSnapshot(): boolean
212
+ subscribeKeySet(listener: () => void): () => void
213
+ }
214
+
215
+ /**
216
+ * Bind the dsh-imagegen settings scope over the bridge routes and start its
217
+ * initial read (the caller mounts nothing until the scope settles).
218
+ * @param fetchFn - the fetch implementation (the global fetch on loopback).
219
+ * @returns the scope; unavailable when the bridge is unreachable.
220
+ */
221
+ export function bindImageGenScope(fetchFn: typeof fetch = fetch): ImageGenScope {
222
+ const controller = new BridgeScopeController<ImageGenConfig>(createBridgeApi(fetchFn).settings, {
223
+ namespace: 'dsh-imagegen',
224
+ })
225
+ void controller.load()
226
+ return controller
227
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Sidebar entry injection.
3
+ *
4
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
5
+ * so — following the dsh-ssh / task-board precedent of DOM-level extension —
6
+ * the entry row is injected after the shell's New Session button (after the
7
+ * sibling plugin family block). The injection self-heals: a MutationObserver
8
+ * watches the sidebar root and re-inserts the row whenever a React re-render
9
+ * displaces it (re-insertion happens in the same frame, before paint).
10
+ *
11
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
12
+ * reconciliation; the panel view it toggles is a separate React root mounted
13
+ * in the center column (see mount.tsx).
14
+ */
15
+
16
+ import type { ImageGenController } from './controller.ts'
17
+ import css from './panel.module.css'
18
+
19
+ /** Stable data attribute identifying the injected entry row. */
20
+ export const ENTRY_SELECTOR = '[data-dsh-imagegen-entry]'
21
+
22
+ /** Inline icon (matches the shell's 16px nav-icon look): a picture glyph. */
23
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="2.5" width="12" height="11" rx="1.5"/><circle cx="5.6" cy="5.8" r="1"/><path d="M2.5 12.5l3.6-3.4 2.4 2.2 3-3 2 2.4"/></svg>'
24
+
25
+ /** Family entry selectors of sibling plugins (relative placement anchor). */
26
+ const FAMILY_ENTRY_SELECTOR = '[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-imagegen-entry]'
27
+
28
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
29
+ function sidebarRoot(): HTMLElement | undefined {
30
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
31
+ if (column === null) return undefined
32
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
+ }
35
+
36
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
+ if (nested !== null) return nested
40
+ for (const child of root.children) {
41
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ /** Build the entry row (a detached button; insert once the shell is up). */
47
+ function createEntry(controller: ImageGenController, label: string, tooltip: string): HTMLButtonElement {
48
+ const entry = document.createElement('button')
49
+ entry.type = 'button'
50
+ entry.dataset.dshImagegenEntry = ''
51
+ entry.className = css.entry
52
+ entry.setAttribute('aria-label', label)
53
+ entry.setAttribute('title', tooltip)
54
+ entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + label + '</span>'
55
+ entry.addEventListener('click', () => { controller.toggle() })
56
+ return entry
57
+ }
58
+
59
+ /** Re-insert the entry after the family block (task board → ssh → imagegen). */
60
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
+ const button = newSessionButton(root)
62
+ if (button === undefined) return false
63
+ if (entry.parentElement !== root) {
64
+ // Position relative to the family block, never relative to transient
65
+ // logoRow geometry: every family plugin that self-heals during a
66
+ // re-render then lands in the same relative order.
67
+ const row = button.closest('[class*="logoRow"]')
68
+ const base = (row !== null && row.parentElement === root) ? row : button
69
+ const family = Array.from(root.children).filter(
70
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_ENTRY_SELECTOR),
71
+ )
72
+ const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
73
+ root.insertBefore(entry, anchor)
74
+ }
75
+ return true
76
+ }
77
+
78
+ /**
79
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
80
+ * on later React re-renders.
81
+ * @param controller - the panel controller the entry toggles.
82
+ * @param label - the entry label (localized).
83
+ * @param tooltip - the entry tooltip (localized).
84
+ * @returns disposer removing the entry and its observers.
85
+ */
86
+ export function mountSidebarEntry(controller: ImageGenController, label: string, tooltip: string): () => void {
87
+ const entry = createEntry(controller, label, tooltip)
88
+ let root: HTMLElement | undefined
89
+ let placed = false
90
+
91
+ const tryPlace = (): void => {
92
+ if (root !== undefined && !root.isConnected) {
93
+ rootObserver.disconnect()
94
+ root = undefined
95
+ placed = false
96
+ }
97
+ if (placed) {
98
+ if (document.body.contains(entry)) return
99
+ rootObserver.disconnect()
100
+ root = undefined
101
+ placed = false
102
+ }
103
+ root ??= sidebarRoot()
104
+ if (root === undefined) return
105
+ placed = placeEntry(root, entry)
106
+ if (placed) {
107
+ rootObserver.observe(root, { childList: true, subtree: true })
108
+ }
109
+ }
110
+
111
+ // Body-level watcher retained as the "whole rebuild" fallback.
112
+ const waitObserver = new MutationObserver(() => { tryPlace() })
113
+ waitObserver.observe(document.body, { childList: true, subtree: true })
114
+
115
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
116
+ // same frame (microtask before paint -> no visible flicker).
117
+ const rootObserver = new MutationObserver(() => {
118
+ if (root === undefined || !root.isConnected) {
119
+ placed = false
120
+ tryPlace()
121
+ return
122
+ }
123
+ if (!root.contains(entry)) {
124
+ placed = placeEntry(root, entry)
125
+ }
126
+ })
127
+
128
+ // Reflect the panel's open state on the row (active highlight).
129
+ const syncActive = () => {
130
+ if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
131
+ else delete entry.dataset.active
132
+ }
133
+ const unsubscribe = controller.subscribe(syncActive)
134
+ syncActive()
135
+
136
+ tryPlace()
137
+
138
+ return () => {
139
+ waitObserver.disconnect()
140
+ rootObserver.disconnect()
141
+ unsubscribe()
142
+ entry.remove()
143
+ }
144
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,284 @@
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
+ /** Content-type extension hints for URL-fetched images. */
46
+ function mimeOfExtension(path: string): string | undefined {
47
+ const match = /\.([a-z0-9]+)$/i.exec(path)
48
+ if (match === null) return undefined
49
+ switch (match[1]!.toLowerCase()) {
50
+ case 'png': return 'image/png'
51
+ case 'jpg':
52
+ case 'jpeg': return 'image/jpeg'
53
+ case 'webp': return 'image/webp'
54
+ case 'gif': return 'image/gif'
55
+ default: return undefined
56
+ }
57
+ }
58
+
59
+ /** Parse `data:<mime>;base64,<payload>` into its parts; undefined when malformed. */
60
+ function parseDataUrl(dataUrl: string): { mime: string; base64: string } | undefined {
61
+ const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(dataUrl.trim())
62
+ if (match === null || match[3] === undefined) return undefined
63
+ if (match[2] === undefined) {
64
+ // Plain (non-base64) data URLs are not supported for reference images.
65
+ return undefined
66
+ }
67
+ return { mime: match[1] ?? 'application/octet-stream', base64: match[3] }
68
+ }
69
+
70
+ /** Strip a data: prefix from an upstream b64 payload if a gateway added one. */
71
+ function bareBase64(value: string): string {
72
+ const parsed = parseDataUrl(value)
73
+ return parsed !== undefined && parsed.base64 !== undefined ? parsed.base64 : value
74
+ }
75
+
76
+ /** Clamp the requested image count into the API-accepted range. */
77
+ function clampCount(n: number): number {
78
+ if (!Number.isFinite(n)) return 1
79
+ return Math.min(4, Math.max(1, Math.round(n)))
80
+ }
81
+
82
+ /** Pick the effective per-model request parameters. Never includes `n`: the
83
+ * batch parameter is rejected by Responses-API-based gateways (tools[0].n),
84
+ * so the count is satisfied by parallel single-image requests instead. */
85
+ function effectiveParams(request: GenerateRequest): {
86
+ model: string
87
+ size?: string
88
+ quality?: string
89
+ detail?: string
90
+ } {
91
+ const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
92
+ // dall-e-3 has no quality/detail knobs and only produces one image.
93
+ if (model === 'dall-e-3') {
94
+ const size = DALLE3_SIZES.has(request.size) ? request.size : '1024x1024'
95
+ return { model, size }
96
+ }
97
+ return {
98
+ model,
99
+ ...request.size !== '' && request.size !== 'auto' ? { size: request.size } : {},
100
+ ...request.quality !== '' && request.quality !== 'auto' ? { quality: request.quality } : {},
101
+ ...request.detail !== '' ? { detail: request.detail } : {},
102
+ }
103
+ }
104
+
105
+ /** How many single-image requests to issue for the requested image count. */
106
+ function effectiveCount(request: GenerateRequest): number {
107
+ const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
108
+ if (model === 'dall-e-3') return 1
109
+ return clampCount(request.n)
110
+ }
111
+
112
+ /** Normalize one upstream data item into a base64 image. */
113
+ async function normalizeItem(
114
+ item: Record<string, unknown>,
115
+ upstream: UpstreamConfig,
116
+ ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
117
+ const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
118
+ if (typeof item.b64_json === 'string') {
119
+ return { b64: bareBase64(item.b64_json), mime: 'image/png', revisedPrompt }
120
+ }
121
+ if (typeof item.url !== 'string' || item.url === '') {
122
+ throw new ImageGenError('upstream image item has neither b64_json nor url')
123
+ }
124
+ const url = item.url
125
+ if (url.startsWith('data:')) {
126
+ const parsed = parseDataUrl(url)
127
+ if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
128
+ return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
129
+ }
130
+ let response: Response
131
+ try {
132
+ response = await fetch(url, {
133
+ headers: {
134
+ ...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
135
+ },
136
+ signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS),
137
+ })
138
+ } catch (error) {
139
+ throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
140
+ }
141
+ if (!response.ok) {
142
+ throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
143
+ }
144
+ const buffer = Buffer.from(await response.arrayBuffer())
145
+ const contentType = response.headers.get('content-type')
146
+ const mime = contentType !== null && contentType !== ''
147
+ ? contentType.split(';')[0]!.trim()
148
+ : mimeOfExtension(url) ?? 'image/png'
149
+ return { b64: buffer.toString('base64'), mime, revisedPrompt }
150
+ }
151
+
152
+ /**
153
+ * Issue one single-image request (never sends `n`). The response is kept as a
154
+ * list so a gateway that happens to return several images per call still works.
155
+ */
156
+ async function requestOneImage(
157
+ baseUrl: string,
158
+ upstream: UpstreamConfig,
159
+ request: GenerateRequest,
160
+ params: ReturnType<typeof effectiveParams>,
161
+ ): Promise<GeneratedImage[]> {
162
+ const headers: Record<string, string> = {
163
+ authorization: `Bearer ${upstream.apiKey.trim()}`,
164
+ }
165
+ let body: BodyInit
166
+ if (request.mode === 'edit') {
167
+ if (typeof request.image !== 'string' || request.image === '') {
168
+ throw new ImageGenError('图生图需要上传参考图片', 'edit-image-missing')
169
+ }
170
+ const parsed = parseDataUrl(request.image)
171
+ if (parsed === undefined) throw new ImageGenError('参考图片格式无效', 'edit-image-invalid')
172
+ let bytes: Buffer
173
+ try {
174
+ bytes = Buffer.from(parsed.base64, 'base64')
175
+ } catch {
176
+ throw new ImageGenError('参考图片数据无法解码', 'edit-image-invalid')
177
+ }
178
+ if (bytes.byteLength > MAX_EDIT_IMAGE_BYTES) {
179
+ throw new ImageGenError('参考图片超过 10MB 上限', 'edit-image-too-large')
180
+ }
181
+ const form = new FormData()
182
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
183
+ form.append('prompt', request.prompt)
184
+ form.append('model', params.model)
185
+ if (params.size !== undefined) form.append('size', params.size)
186
+ if (params.quality !== undefined) form.append('quality', params.quality)
187
+ if (params.detail !== undefined) form.append('detail', params.detail)
188
+ body = form
189
+ } else {
190
+ headers['content-type'] = 'application/json'
191
+ body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
192
+ }
193
+
194
+ let response: Response
195
+ try {
196
+ response = await fetch(`${baseUrl}/images/${request.mode === 'edit' ? 'edits' : 'generations'}`, {
197
+ method: 'POST',
198
+ headers,
199
+ body,
200
+ signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
201
+ })
202
+ } catch (error) {
203
+ const message = error instanceof Error ? error.message : String(error)
204
+ if (/aborter/i.test(message) || /timeout/i.test(message)) {
205
+ throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
206
+ }
207
+ throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
208
+ }
209
+
210
+ let payload: unknown
211
+ try {
212
+ payload = await response.json()
213
+ } catch {
214
+ throw new ImageGenError(`上游接口返回了非 JSON 响应(HTTP ${response.status})`, 'upstream-invalid')
215
+ }
216
+ if (!response.ok || payload === null || typeof payload !== 'object') {
217
+ throw new ImageGenError(upstreamMessage(payload, response.status), 'upstream-rejected')
218
+ }
219
+
220
+ const record = payload as Record<string, unknown>
221
+ const data = Array.isArray(record.data)
222
+ ? record.data
223
+ : Array.isArray(record.images)
224
+ ? record.images
225
+ : Array.isArray(record.output)
226
+ ? record.output
227
+ : undefined
228
+ if (data === undefined) {
229
+ throw new ImageGenError('上游响应缺少 data 数组', 'upstream-invalid')
230
+ }
231
+ if (data.length === 0) {
232
+ throw new ImageGenError('上游返回了 0 张图片', 'upstream-empty')
233
+ }
234
+ return Promise.all(data.map(async (entry) => {
235
+ if (entry === null || typeof entry !== 'object') {
236
+ throw new ImageGenError('上游响应包含无效的图片条目', 'upstream-invalid')
237
+ }
238
+ return normalizeItem(entry as Record<string, unknown>, upstream)
239
+ }))
240
+ }
241
+
242
+ /**
243
+ * Forward one generate request to the configured endpoint. The requested image
244
+ * count is satisfied with N parallel single-image requests (the `n` batch
245
+ * parameter is never sent, because Responses-API-based gateways reject it as
246
+ * `tools[0].n`), then the results are flattened in order.
247
+ */
248
+ export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest): Promise<GenerateResult> {
249
+ const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
250
+ if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
251
+ if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
252
+ const params = effectiveParams(request)
253
+ const count = effectiveCount(request)
254
+ const batches = await Promise.all(
255
+ Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params)),
256
+ )
257
+ return { images: batches.flat() }
258
+ }
259
+
260
+ /** Human-readable failure message from an upstream error payload. */
261
+ function upstreamMessage(payload: unknown, status: number): string {
262
+ if (payload !== null && typeof payload === 'object') {
263
+ const record = payload as Record<string, unknown>
264
+ const error = record.error
265
+ if (error !== null && typeof error === 'object') {
266
+ const message = (error as Record<string, unknown>).message
267
+ if (typeof message === 'string' && message !== '') return message
268
+ }
269
+ if (typeof record.message === 'string' && record.message !== '') return record.message
270
+ if (typeof record.error === 'string' && record.error !== '') return record.error
271
+ }
272
+ return `上游接口拒绝请求(HTTP ${status})`
273
+ }
274
+
275
+ /** File extension for a MIME type (multipart reference image). */
276
+ function extensionOf(mime: string): string {
277
+ switch (mime.split(';')[0]!.trim()) {
278
+ case 'image/jpeg': return 'jpg'
279
+ case 'image/webp': return 'webp'
280
+ case 'image/gif': return 'gif'
281
+ case 'image/png':
282
+ default: return 'png'
283
+ }
284
+ }