@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.
- package/LICENSE +201 -0
- package/README.md +126 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +2413 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +896 -0
- package/package.json +66 -0
- package/src/client/ImageGenPanel.tsx +687 -0
- package/src/client/SettingsCard.tsx +373 -0
- package/src/client/api.ts +89 -0
- package/src/client/controller.ts +46 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/helpers.ts +33 -0
- package/src/client/index.ts +127 -0
- package/src/client/locales.ts +204 -0
- package/src/client/mount.tsx +119 -0
- package/src/client/panel.module.css +970 -0
- package/src/client/settings-card.module.css +288 -0
- package/src/client/settings-form.ts +324 -0
- package/src/client/settings-scope.ts +227 -0
- package/src/client/sidebar-entry.ts +144 -0
- package/src/engine.ts +284 -0
- package/src/history-store.ts +217 -0
- package/src/index.ts +139 -0
- package/src/protocol.ts +118 -0
- package/src/routes.ts +373 -0
|
@@ -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
|
+
}
|