@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
package/src/routes.ts
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/dsh-imagegen route family: a loopback-only settings bridge for the
|
|
3
|
+
* plugin's own namespace (describe/mutate, mirroring the dsh-web-ui family
|
|
4
|
+
* bridge wire) and the generate proxy that forwards to the configured
|
|
5
|
+
* OpenAI-compatible endpoint with the API key held host-side.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
10
|
+
import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
|
11
|
+
import { generateImage, type UpstreamConfig } from './engine.ts'
|
|
12
|
+
import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
|
|
13
|
+
import { GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, type GeneratedImage, type GenerateRequest, type HistoryEntryInput } from './protocol.ts'
|
|
14
|
+
|
|
15
|
+
/** Cap on JSON request bodies (settings ops and generate payloads are small). */
|
|
16
|
+
const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
/** Cap on history append bodies (base64 result images can be much larger). */
|
|
19
|
+
const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024
|
|
20
|
+
|
|
21
|
+
/** Settings seam face the bridge needs (the host settings provider). */
|
|
22
|
+
export interface SettingsSeam {
|
|
23
|
+
describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
|
|
24
|
+
mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
|
|
25
|
+
readonly writable?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Route dependencies. */
|
|
29
|
+
export interface ImageGenRoutesDeps {
|
|
30
|
+
/** The settings seam (namespace storage). */
|
|
31
|
+
settings: SettingsSeam
|
|
32
|
+
/** Resolve the current upstream config (composition entry + settings). */
|
|
33
|
+
resolve: () => UpstreamConfig
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
|
|
37
|
+
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
38
|
+
const address = request.socket.remoteAddress
|
|
39
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
40
|
+
const host = request.headers.host
|
|
41
|
+
if (typeof host !== 'string') return false
|
|
42
|
+
let hostUrl: URL
|
|
43
|
+
try {
|
|
44
|
+
hostUrl = new URL(`http://${host}`)
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
49
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
50
|
+
const origin = request.headers.origin
|
|
51
|
+
if (origin === undefined) return true
|
|
52
|
+
try {
|
|
53
|
+
return new URL(origin).host === hostUrl.host
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One JSON response. */
|
|
60
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
61
|
+
const payload = JSON.stringify(body)
|
|
62
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
63
|
+
res.end(payload)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read a JSON request body (undefined when too large or unparseable). */
|
|
67
|
+
async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
|
|
68
|
+
const chunks: Buffer[] = []
|
|
69
|
+
let size = 0
|
|
70
|
+
for await (const chunk of req) {
|
|
71
|
+
const buffer = chunk as Buffer
|
|
72
|
+
size += buffer.length
|
|
73
|
+
if (size > maxBytes) return undefined
|
|
74
|
+
chunks.push(buffer)
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
78
|
+
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Human-readable text from an unknown thrown value. */
|
|
85
|
+
function messageOf(error: unknown): string {
|
|
86
|
+
return error instanceof Error ? error.message : String(error)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validate a submitted history entry (images carry base64). */
|
|
90
|
+
function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
|
|
91
|
+
const raw = body.entry
|
|
92
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
93
|
+
const entry = raw as Record<string, unknown>
|
|
94
|
+
if (typeof entry.id !== 'string' || typeof entry.createdAt !== 'number') return undefined
|
|
95
|
+
if (entry.mode !== 'text' && entry.mode !== 'edit') return undefined
|
|
96
|
+
if (typeof entry.model !== 'string' || typeof entry.prompt !== 'string') return undefined
|
|
97
|
+
if (typeof entry.size !== 'string' || typeof entry.quality !== 'string' || typeof entry.detail !== 'string') return undefined
|
|
98
|
+
if (typeof entry.n !== 'number') return undefined
|
|
99
|
+
if (!Array.isArray(entry.images)) return undefined
|
|
100
|
+
const images: GeneratedImage[] = []
|
|
101
|
+
for (const item of entry.images) {
|
|
102
|
+
if (item === null || typeof item !== 'object') return undefined
|
|
103
|
+
const image = item as Record<string, unknown>
|
|
104
|
+
if (typeof image.b64 !== 'string' || typeof image.mime !== 'string') return undefined
|
|
105
|
+
images.push({
|
|
106
|
+
b64: image.b64,
|
|
107
|
+
mime: image.mime,
|
|
108
|
+
...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: entry.id,
|
|
113
|
+
createdAt: entry.createdAt,
|
|
114
|
+
mode: entry.mode,
|
|
115
|
+
model: entry.model,
|
|
116
|
+
prompt: entry.prompt,
|
|
117
|
+
size: entry.size,
|
|
118
|
+
quality: entry.quality,
|
|
119
|
+
detail: entry.detail,
|
|
120
|
+
n: entry.n,
|
|
121
|
+
images,
|
|
122
|
+
...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Extract the image file name from a history-image request URL. */
|
|
127
|
+
function imageFileFrom(rawUrl: string | undefined, basePath: string): string | undefined {
|
|
128
|
+
if (rawUrl === undefined) return undefined
|
|
129
|
+
let pathname: string
|
|
130
|
+
try {
|
|
131
|
+
pathname = new URL(rawUrl, 'http://localhost').pathname
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
if (!pathname.startsWith(`${basePath}/`)) return undefined
|
|
136
|
+
return decodeURIComponent(pathname.slice(basePath.length + 1))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Project one settings descriptor onto the bridge wire view. */
|
|
140
|
+
function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
|
|
141
|
+
return {
|
|
142
|
+
ns: String(descriptor.ns),
|
|
143
|
+
schema: descriptor.schema,
|
|
144
|
+
value: descriptor.value,
|
|
145
|
+
...descriptor.base === undefined ? {} : { base: descriptor.base },
|
|
146
|
+
...descriptor.user === undefined ? {} : { user: descriptor.user },
|
|
147
|
+
...descriptor.secrets === undefined ? {} : {
|
|
148
|
+
secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
|
|
149
|
+
},
|
|
150
|
+
revision: descriptor.revision,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Map a seam failure onto the bridge refusal envelope. */
|
|
155
|
+
function failureOf(error: unknown): { ok: false; code: string; message: string } {
|
|
156
|
+
if (error instanceof SettingsConflictError) {
|
|
157
|
+
return { ok: false, code: 'settings-conflict', message: error.message }
|
|
158
|
+
}
|
|
159
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
160
|
+
return { ok: false, code: 'settings-rejected', message }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build every /api/dsh-imagegen route.
|
|
165
|
+
* @param deps - settings seam + config resolver.
|
|
166
|
+
* @returns the route registrations.
|
|
167
|
+
*/
|
|
168
|
+
export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
169
|
+
const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
170
|
+
if (!isLoopbackRequest(req)) {
|
|
171
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
if (req.method !== method) {
|
|
175
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return [
|
|
182
|
+
// -------------------------------------------------- settings describe
|
|
183
|
+
{
|
|
184
|
+
kind: 'exact',
|
|
185
|
+
path: SETTINGS_API.describe,
|
|
186
|
+
handler: async (req, res) => {
|
|
187
|
+
if (!guard(req, res, 'POST')) return
|
|
188
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
189
|
+
.find(candidate => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE)
|
|
190
|
+
writeJson(res, 200, {
|
|
191
|
+
ok: true,
|
|
192
|
+
value: {
|
|
193
|
+
namespaces: descriptor === undefined ? [] : [toView(descriptor)],
|
|
194
|
+
writable: deps.settings.writable !== false,
|
|
195
|
+
},
|
|
196
|
+
})
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
// ----------------------------------------------------- settings mutate
|
|
200
|
+
{
|
|
201
|
+
kind: 'exact',
|
|
202
|
+
path: SETTINGS_API.mutate,
|
|
203
|
+
handler: async (req, res) => {
|
|
204
|
+
if (!guard(req, res, 'POST')) return
|
|
205
|
+
const body = await readJsonBody(req)
|
|
206
|
+
if (body === undefined) {
|
|
207
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
const ns = typeof body.ns === 'string' ? body.ns : ''
|
|
211
|
+
if (ns !== IMAGEGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
|
|
212
|
+
writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
216
|
+
try {
|
|
217
|
+
await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
writeJson(res, 200, failureOf(error))
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
const descriptor = deps.settings.describe({ redactSecrets: true })
|
|
223
|
+
.find(candidate => String(candidate.ns) === ns)
|
|
224
|
+
if (descriptor === undefined) {
|
|
225
|
+
writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
writeJson(res, 200, { ok: true, value: toView(descriptor) })
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
// ----------------------------------------------------------- generate
|
|
232
|
+
{
|
|
233
|
+
kind: 'exact',
|
|
234
|
+
path: GENERATE_API,
|
|
235
|
+
handler: async (req, res) => {
|
|
236
|
+
if (!guard(req, res, 'POST')) return
|
|
237
|
+
const body = await readJsonBody(req)
|
|
238
|
+
if (body === undefined) {
|
|
239
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
|
|
243
|
+
if (prompt === '') {
|
|
244
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
if (prompt.length > 2000) {
|
|
248
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt exceeds 2000 characters' })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
const request: GenerateRequest = {
|
|
252
|
+
mode: body.mode === 'edit' ? 'edit' : 'text',
|
|
253
|
+
model: typeof body.model === 'string' ? body.model : 'gpt-image-2',
|
|
254
|
+
prompt,
|
|
255
|
+
size: typeof body.size === 'string' ? body.size : 'auto',
|
|
256
|
+
quality: typeof body.quality === 'string' ? body.quality : 'auto',
|
|
257
|
+
n: typeof body.n === 'number' ? body.n : 1,
|
|
258
|
+
detail: typeof body.detail === 'string' ? body.detail : '',
|
|
259
|
+
...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
const result = await generateImage(deps.resolve(), request)
|
|
263
|
+
writeJson(res, 200, { ok: true, ...result })
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
266
|
+
const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
|
|
267
|
+
? (error as { code: string }).code
|
|
268
|
+
: 'generate-failed'
|
|
269
|
+
writeJson(res, 200, { ok: false, code, message })
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
// ----------------------------------------------------- history list
|
|
274
|
+
{
|
|
275
|
+
kind: 'exact',
|
|
276
|
+
path: HISTORY_API.list,
|
|
277
|
+
handler: async (req, res) => {
|
|
278
|
+
if (!guard(req, res, 'POST')) return
|
|
279
|
+
try {
|
|
280
|
+
writeJson(res, 200, { ok: true, entries: await listHistory() })
|
|
281
|
+
} catch (error) {
|
|
282
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
// --------------------------------------------------- history append
|
|
287
|
+
{
|
|
288
|
+
kind: 'exact',
|
|
289
|
+
path: HISTORY_API.append,
|
|
290
|
+
handler: async (req, res) => {
|
|
291
|
+
if (!guard(req, res, 'POST')) return
|
|
292
|
+
const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
|
|
293
|
+
if (body === undefined) {
|
|
294
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
const entry = parseHistoryEntryInput(body)
|
|
298
|
+
if (entry === undefined) {
|
|
299
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed history entry' })
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
writeJson(res, 200, { ok: true, entries: await appendHistory(entry) })
|
|
304
|
+
} catch (error) {
|
|
305
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
// --------------------------------------------------- history remove
|
|
310
|
+
{
|
|
311
|
+
kind: 'exact',
|
|
312
|
+
path: HISTORY_API.remove,
|
|
313
|
+
handler: async (req, res) => {
|
|
314
|
+
if (!guard(req, res, 'POST')) return
|
|
315
|
+
const body = await readJsonBody(req)
|
|
316
|
+
const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
|
|
317
|
+
if (id === '') {
|
|
318
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'history id is required' })
|
|
319
|
+
return
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
writeJson(res, 200, { ok: true, entries: await removeHistory(id) })
|
|
323
|
+
} catch (error) {
|
|
324
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
// ---------------------------------------------------- history clear
|
|
329
|
+
{
|
|
330
|
+
kind: 'exact',
|
|
331
|
+
path: HISTORY_API.clear,
|
|
332
|
+
handler: async (req, res) => {
|
|
333
|
+
if (!guard(req, res, 'POST')) return
|
|
334
|
+
try {
|
|
335
|
+
writeJson(res, 200, { ok: true, entries: await clearHistory() })
|
|
336
|
+
} catch (error) {
|
|
337
|
+
writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
// ------------------------------------------------ history image (prefix)
|
|
342
|
+
{
|
|
343
|
+
kind: 'prefix',
|
|
344
|
+
path: HISTORY_API.image,
|
|
345
|
+
handler: async (req, res) => {
|
|
346
|
+
if (!isLoopbackRequest(req)) {
|
|
347
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
if (req.method !== 'GET') {
|
|
351
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
const file = imageFileFrom(req.url, HISTORY_API.image)
|
|
355
|
+
if (file === undefined) {
|
|
356
|
+
writeJson(res, 404, { error: 'not found' })
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
const found = await readHistoryImage(file)
|
|
360
|
+
if (found === undefined) {
|
|
361
|
+
writeJson(res, 404, { error: 'not found' })
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
res.writeHead(200, {
|
|
365
|
+
'content-type': found.mime,
|
|
366
|
+
'content-length': found.data.length,
|
|
367
|
+
'cache-control': 'private, max-age=3600',
|
|
368
|
+
})
|
|
369
|
+
res.end(found.data)
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
]
|
|
373
|
+
}
|