@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/routes.ts CHANGED
@@ -1,738 +1,916 @@
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 { randomUUID } from 'node:crypto'
10
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
- import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
12
- import type { UpstreamConfig } from './engine.ts'
13
- import { enhancePrompt, listOpenAIModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
14
- import { normalizeImageModels } from './image-models.ts'
15
- import { ImageGenerationRuntime } from './generation-runtime.ts'
16
- import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
17
- import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
18
- import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
19
- import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
20
- import { GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
21
-
22
- /** Cap on JSON request bodies (settings ops and generate payloads are small). */
23
- const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
24
-
25
- /** Cap on history append bodies (base64 result images can be much larger). */
26
- const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024
27
-
28
- /** Settings seam face the bridge needs (the host settings provider). */
29
- export interface SettingsSeam {
30
- describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
31
- mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
32
- readonly writable?: boolean
33
- }
34
-
35
- /** Route dependencies. */
36
- export interface ImageGenRoutesDeps {
37
- /** The settings seam (namespace storage). */
38
- settings: SettingsSeam
39
- /** Resolve the current upstream config (composition entry + settings). */
40
- resolve: () => UpstreamConfig
41
- /** Resolve the optional chat-model configuration for prompt enhancement. */
42
- resolvePrompt?: () => PromptModelConfig
43
- /** Models explicitly selected for this image API endpoint. */
44
- resolveImageModels?: () => string[]
45
- /** Overrideable history backend, primarily for host integration tests. */
46
- history?: {
47
- list: () => Promise<HistoryEntry[]>
48
- append: (entry: HistoryEntryInput) => Promise<HistoryEntry[]>
49
- remove: (id: string) => Promise<HistoryEntry[]>
50
- clear: () => Promise<HistoryEntry[]>
51
- readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
52
- }
53
- /** Overrideable gallery backend, primarily for host integration tests. */
54
- gallery?: {
55
- list: () => Promise<HistoryEntry[]>
56
- append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
57
- remove: (id: string) => Promise<HistoryEntry[]>
58
- clear: () => Promise<HistoryEntry[]>
59
- updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
60
- readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
61
- }
62
- /** Overrideable template-library backend, primarily for host integration tests. */
63
- templates?: {
64
- list: () => Promise<TemplateListResult>
65
- refresh: () => Promise<TemplateRefreshResult>
66
- readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
67
- }
68
- /** Shared host queue, used by Agent tools and browser task endpoints. */
69
- runtime?: ImageGenerationRuntime
70
- }
71
-
72
- /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
73
- function isLoopbackRequest(request: IncomingMessage): boolean {
74
- const address = request.socket.remoteAddress
75
- if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
76
- const host = request.headers.host
77
- if (typeof host !== 'string') return false
78
- let hostUrl: URL
79
- try {
80
- hostUrl = new URL(`http://${host}`)
81
- } catch {
82
- return false
83
- }
84
- if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
85
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
86
- const origin = request.headers.origin
87
- if (origin === undefined) return true
88
- try {
89
- return new URL(origin).host === hostUrl.host
90
- } catch {
91
- return false
92
- }
93
- }
94
-
95
- /** One JSON response. */
96
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
97
- const payload = JSON.stringify(body)
98
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
99
- res.end(payload)
100
- }
101
-
102
- /** Read a JSON request body (undefined when too large or unparseable). */
103
- async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
104
- const chunks: Buffer[] = []
105
- let size = 0
106
- for await (const chunk of req) {
107
- const buffer = chunk as Buffer
108
- size += buffer.length
109
- if (size > maxBytes) return undefined
110
- chunks.push(buffer)
111
- }
112
- try {
113
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
114
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
115
- } catch {
116
- return undefined
117
- }
118
- }
119
-
120
- /** Human-readable text from an unknown thrown value. */
121
- function messageOf(error: unknown): string {
122
- return error instanceof Error ? error.message : String(error)
123
- }
124
-
125
- function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
126
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
127
- if (prompt === '') return undefined
128
- return {
129
- mode: body.mode === 'edit' ? 'edit' : 'text',
130
- model: typeof body.model === 'string' ? body.model : '',
131
- prompt,
132
- size: typeof body.size === 'string' ? body.size : 'auto',
133
- quality: typeof body.quality === 'string' ? body.quality : 'auto',
134
- n: typeof body.n === 'number' ? body.n : 1,
135
- detail: typeof body.detail === 'string' ? body.detail : '',
136
- ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
137
- ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
138
- }
139
- }
140
-
141
- /** Validate a submitted history entry (images carry base64). */
142
- function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
143
- const raw = body.entry
144
- if (raw === null || typeof raw !== 'object') return undefined
145
- const entry = raw as Record<string, unknown>
146
- if (typeof entry.id !== 'string' || typeof entry.createdAt !== 'number') return undefined
147
- if (entry.mode !== 'text' && entry.mode !== 'edit') return undefined
148
- if (typeof entry.model !== 'string' || typeof entry.prompt !== 'string') return undefined
149
- if (typeof entry.size !== 'string' || typeof entry.quality !== 'string' || typeof entry.detail !== 'string') return undefined
150
- if (typeof entry.n !== 'number') return undefined
151
- if (!Array.isArray(entry.images)) return undefined
152
- const images: GeneratedImage[] = []
153
- for (const item of entry.images) {
154
- if (item === null || typeof item !== 'object') return undefined
155
- const image = item as Record<string, unknown>
156
- if (typeof image.b64 !== 'string' || typeof image.mime !== 'string') return undefined
157
- images.push({
158
- b64: image.b64,
159
- mime: image.mime,
160
- ...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
161
- })
162
- }
163
- return {
164
- id: entry.id,
165
- createdAt: entry.createdAt,
166
- mode: entry.mode,
167
- model: entry.model,
168
- prompt: entry.prompt,
169
- size: entry.size,
170
- quality: entry.quality,
171
- detail: entry.detail,
172
- n: entry.n,
173
- images,
174
- ...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
175
- }
176
- }
177
-
178
- /** Extract the image file name from a history-image request URL. */
179
- function imageFileFrom(rawUrl: string | undefined, basePath: string): string | undefined {
180
- if (rawUrl === undefined) return undefined
181
- let pathname: string
182
- try {
183
- pathname = new URL(rawUrl, 'http://localhost').pathname
184
- } catch {
185
- return undefined
186
- }
187
- if (!pathname.startsWith(`${basePath}/`)) return undefined
188
- return decodeURIComponent(pathname.slice(basePath.length + 1))
189
- }
190
-
191
- /** Project one settings descriptor onto the bridge wire view. */
192
- function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
193
- return {
194
- ns: String(descriptor.ns),
195
- schema: descriptor.schema,
196
- value: descriptor.value,
197
- ...descriptor.base === undefined ? {} : { base: descriptor.base },
198
- ...descriptor.user === undefined ? {} : { user: descriptor.user },
199
- ...descriptor.secrets === undefined ? {} : {
200
- secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
201
- },
202
- revision: descriptor.revision,
203
- }
204
- }
205
-
206
- /** Map a seam failure onto the bridge refusal envelope. */
207
- function failureOf(error: unknown): { ok: false; code: string; message: string } {
208
- if (error instanceof SettingsConflictError) {
209
- return { ok: false, code: 'settings-conflict', message: error.message }
210
- }
211
- const message = error instanceof Error ? error.message : String(error)
212
- return { ok: false, code: 'settings-rejected', message }
213
- }
214
-
215
- /**
216
- * Build every /api/dsh-imagegen route.
217
- * @param deps - settings seam + config resolver.
218
- * @returns the route registrations.
219
- */
220
- export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
221
- const history = deps.history ?? {
222
- list: listHistory,
223
- append: appendHistory,
224
- remove: removeHistory,
225
- clear: clearHistory,
226
- readImage: readHistoryImage,
227
- }
228
- const gallery: NonNullable<ImageGenRoutesDeps['gallery']> = deps.gallery ?? {
229
- list: listGallery,
230
- append: appendGallery,
231
- remove: removeGallery,
232
- clear: clearGallery,
233
- updateTags: updateGalleryTags,
234
- readImage: readGalleryImage,
235
- }
236
- const templates = deps.templates ?? {
237
- list: listTemplates,
238
- refresh: refreshTemplates,
239
- readImage: readTemplateImage,
240
- }
241
- const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
242
- const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
243
- const parseConfiguredRequest = (body: Record<string, unknown>): GenerateRequest | undefined => {
244
- const request = parseGenerateRequest(body)
245
- if (request === undefined) return undefined
246
- const models = normalizeImageModels(resolveImageModels())
247
- const model = request.model.trim() === '' ? models[0] : request.model.trim()
248
- if (!models.includes(model)) throw new Error(`image model "${model}" is not configured; choose one of: ${models.join(', ')}`)
249
- return { ...request, model }
250
- }
251
- const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history)
252
- const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
253
- if (!isLoopbackRequest(req)) {
254
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
255
- return false
256
- }
257
- if (req.method !== method) {
258
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
259
- return false
260
- }
261
- return true
262
- }
263
-
264
- return [
265
- // -------------------------------------------- image model discovery
266
- {
267
- kind: 'exact',
268
- path: IMAGE_MODEL_API.models,
269
- handler: async (req, res) => {
270
- if (!guard(req, res, 'POST')) return
271
- try {
272
- writeJson(res, 200, { ok: true, models: await listOpenAIModels(deps.resolve()) })
273
- } catch (error) {
274
- writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
275
- }
276
- },
277
- },
278
- // ----------------------------------------------- prompt enhancement
279
- {
280
- kind: 'exact',
281
- path: PROMPT_ENHANCE_API.models,
282
- handler: async (req, res) => {
283
- if (!guard(req, res, 'POST')) return
284
- try {
285
- writeJson(res, 200, { ok: true, models: await listPromptModels(resolvePrompt()) })
286
- } catch (error) {
287
- writeJson(res, 200, { ok: false, code: 'prompt-models-failed', message: messageOf(error) })
288
- }
289
- },
290
- },
291
- {
292
- kind: 'exact',
293
- path: PROMPT_ENHANCE_API.enhance,
294
- handler: async (req, res) => {
295
- if (!guard(req, res, 'POST')) return
296
- const body = await readJsonBody(req)
297
- const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
298
- if (prompt === '') {
299
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
300
- return
301
- }
302
- try {
303
- writeJson(res, 200, { ok: true, prompt: await enhancePrompt(resolvePrompt(), prompt) })
304
- } catch (error) {
305
- writeJson(res, 200, { ok: false, code: 'prompt-enhance-failed', message: messageOf(error) })
306
- }
307
- },
308
- },
309
- // -------------------------------------------------- settings describe
310
- {
311
- kind: 'exact',
312
- path: SETTINGS_API.describe,
313
- handler: async (req, res) => {
314
- if (!guard(req, res, 'POST')) return
315
- const descriptor = deps.settings.describe({ redactSecrets: true })
316
- .find(candidate => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE)
317
- writeJson(res, 200, {
318
- ok: true,
319
- value: {
320
- namespaces: descriptor === undefined ? [] : [toView(descriptor)],
321
- writable: deps.settings.writable !== false,
322
- },
323
- })
324
- },
325
- },
326
- // ----------------------------------------------------- settings mutate
327
- {
328
- kind: 'exact',
329
- path: SETTINGS_API.mutate,
330
- handler: async (req, res) => {
331
- if (!guard(req, res, 'POST')) return
332
- const body = await readJsonBody(req)
333
- if (body === undefined) {
334
- writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
335
- return
336
- }
337
- const ns = typeof body.ns === 'string' ? body.ns : ''
338
- if (ns !== IMAGEGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
339
- writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
340
- return
341
- }
342
- const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
343
- try {
344
- await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
345
- } catch (error) {
346
- writeJson(res, 200, failureOf(error))
347
- return
348
- }
349
- const descriptor = deps.settings.describe({ redactSecrets: true })
350
- .find(candidate => String(candidate.ns) === ns)
351
- if (descriptor === undefined) {
352
- writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
353
- return
354
- }
355
- writeJson(res, 200, { ok: true, value: toView(descriptor) })
356
- },
357
- },
358
- // ----------------------------------------------------------- generate
359
- {
360
- kind: 'exact',
361
- path: GENERATE_API,
362
- handler: async (req, res) => {
363
- if (!guard(req, res, 'POST')) return
364
- const body = await readJsonBody(req)
365
- if (body === undefined) {
366
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
367
- return
368
- }
369
- let request: GenerateRequest | undefined
370
- try { request = parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
371
- if (request === undefined) {
372
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
373
- return
374
- }
375
- try {
376
- writeJson(res, 200, { ok: true, ...await runtime.run(request) })
377
- } catch (error) {
378
- const message = error instanceof Error ? error.message : String(error)
379
- const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
380
- ? (error as { code: string }).code
381
- : 'generate-failed'
382
- writeJson(res, 200, { ok: false, code, message })
383
- }
384
- },
385
- },
386
- // ------------------------------------------------ generation task queue
387
- {
388
- kind: 'exact', path: TASK_API.submit,
389
- handler: async (req, res) => {
390
- if (!guard(req, res, 'POST')) return
391
- const body = await readJsonBody(req)
392
- let request: GenerateRequest | undefined
393
- try { request = body === undefined ? undefined : parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
394
- if (request === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
395
- writeJson(res, 200, { ok: true, task: runtime.queue.submit(request) })
396
- },
397
- },
398
- {
399
- kind: 'exact', path: TASK_API.list,
400
- handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: runtime.queue.list() }) },
401
- },
402
- {
403
- kind: 'exact', path: TASK_API.cancel,
404
- handler: async (req, res) => {
405
- if (!guard(req, res, 'POST')) return
406
- const body = await readJsonBody(req)
407
- const task = typeof body?.id === 'string' ? runtime.queue.cancel(body.id) : undefined
408
- if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
409
- writeJson(res, 200, { ok: true, task })
410
- },
411
- },
412
- {
413
- kind: 'exact', path: TASK_API.retry,
414
- handler: async (req, res) => {
415
- if (!guard(req, res, 'POST')) return
416
- const body = await readJsonBody(req)
417
- const task = typeof body?.id === 'string' ? runtime.queue.retry(body.id) : undefined
418
- if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
419
- writeJson(res, 200, { ok: true, task })
420
- },
421
- },
422
- // ----------------------------------------------- update check
423
- {
424
- kind: 'exact',
425
- path: UPDATE_API.check,
426
- handler: async (req, res) => {
427
- if (!guard(req, res, 'POST')) return
428
- try {
429
- writeJson(res, 200, { ok: true, update: await checkForUpdate() })
430
- } catch (error) {
431
- writeJson(res, 200, { ok: false, code: 'update-check-failed', message: messageOf(error) })
432
- }
433
- },
434
- },
435
- // ----------------------------------------------- update apply
436
- {
437
- kind: 'exact',
438
- path: UPDATE_API.apply,
439
- handler: async (req, res) => {
440
- if (!guard(req, res, 'POST')) return
441
- const body = await readJsonBody(req)
442
- const version = body !== undefined && typeof body.version === 'string' ? body.version.trim() : ''
443
- if (version === '') {
444
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'update version is required' })
445
- return
446
- }
447
- try {
448
- const latest = await checkForUpdate()
449
- if (!latest.updateAvailable || latest.latestVersion !== version) {
450
- writeJson(res, 200, { ok: false, code: 'update-not-available', message: `version ${version} is not the latest available release` })
451
- return
452
- }
453
- await installUpdate(version)
454
- writeJson(res, 200, { ok: true, currentVersion: CURRENT_VERSION, updatedVersion: version, restartRequired: true })
455
- } catch (error) {
456
- writeJson(res, 200, { ok: false, code: 'update-failed', message: messageOf(error) })
457
- }
458
- },
459
- },
460
- // ----------------------------------------------------- history list
461
- {
462
- kind: 'exact',
463
- path: HISTORY_API.list,
464
- handler: async (req, res) => {
465
- if (!guard(req, res, 'POST')) return
466
- try {
467
- writeJson(res, 200, { ok: true, entries: await history.list() })
468
- } catch (error) {
469
- writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
470
- }
471
- },
472
- },
473
- // --------------------------------------------------- history append
474
- {
475
- kind: 'exact',
476
- path: HISTORY_API.append,
477
- handler: async (req, res) => {
478
- if (!guard(req, res, 'POST')) return
479
- const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
480
- if (body === undefined) {
481
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
482
- return
483
- }
484
- const entry = parseHistoryEntryInput(body)
485
- if (entry === undefined) {
486
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed history entry' })
487
- return
488
- }
489
- try {
490
- writeJson(res, 200, { ok: true, entries: await history.append(entry) })
491
- } catch (error) {
492
- writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
493
- }
494
- },
495
- },
496
- // --------------------------------------------------- history remove
497
- {
498
- kind: 'exact',
499
- path: HISTORY_API.remove,
500
- handler: async (req, res) => {
501
- if (!guard(req, res, 'POST')) return
502
- const body = await readJsonBody(req)
503
- const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
504
- if (id === '') {
505
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'history id is required' })
506
- return
507
- }
508
- try {
509
- writeJson(res, 200, { ok: true, entries: await history.remove(id) })
510
- } catch (error) {
511
- writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
512
- }
513
- },
514
- },
515
- // ---------------------------------------------------- history clear
516
- {
517
- kind: 'exact',
518
- path: HISTORY_API.clear,
519
- handler: async (req, res) => {
520
- if (!guard(req, res, 'POST')) return
521
- try {
522
- writeJson(res, 200, { ok: true, entries: await history.clear() })
523
- } catch (error) {
524
- writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
525
- }
526
- },
527
- },
528
- // ------------------------------------------------ history image (prefix)
529
- {
530
- kind: 'prefix',
531
- path: HISTORY_API.image,
532
- handler: async (req, res) => {
533
- if (!isLoopbackRequest(req)) {
534
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
535
- return
536
- }
537
- if (req.method !== 'GET') {
538
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
539
- return
540
- }
541
- const file = imageFileFrom(req.url, HISTORY_API.image)
542
- if (file === undefined) {
543
- writeJson(res, 404, { error: 'not found' })
544
- return
545
- }
546
- const found = await history.readImage(file)
547
- if (found === undefined) {
548
- writeJson(res, 404, { error: 'not found' })
549
- return
550
- }
551
- res.writeHead(200, {
552
- 'content-type': found.mime,
553
- 'content-length': found.data.length,
554
- 'cache-control': 'private, max-age=3600',
555
- })
556
- res.end(found.data)
557
- },
558
- },
559
- // ----------------------------------------------------- gallery list
560
- {
561
- kind: 'exact',
562
- path: GALLERY_API.list,
563
- handler: async (req, res) => {
564
- if (!guard(req, res, 'POST')) return
565
- try {
566
- writeJson(res, 200, { ok: true, entries: await gallery.list() })
567
- } catch (error) {
568
- writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
569
- }
570
- },
571
- },
572
- // --------------------------------------------------- gallery append
573
- {
574
- kind: 'exact',
575
- path: GALLERY_API.append,
576
- handler: async (req, res) => {
577
- if (!guard(req, res, 'POST')) return
578
- const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
579
- if (body === undefined) {
580
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
581
- return
582
- }
583
- const entry = parseHistoryEntryInput(body)
584
- if (entry === undefined) {
585
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed gallery entry' })
586
- return
587
- }
588
- try {
589
- // The host owns gallery ids: a fresh id per append keeps retries and
590
- // duplicate submissions from ever reusing a stale filename prefix.
591
- const result = await gallery.append({ ...entry, id: randomUUID() })
592
- writeJson(res, 200, { ok: true, entries: result.entries, added: result.added })
593
- } catch (error) {
594
- writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
595
- }
596
- },
597
- },
598
- // --------------------------------------------------- gallery remove
599
- {
600
- kind: 'exact',
601
- path: GALLERY_API.remove,
602
- handler: async (req, res) => {
603
- if (!guard(req, res, 'POST')) return
604
- const body = await readJsonBody(req)
605
- const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
606
- if (id === '') {
607
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id is required' })
608
- return
609
- }
610
- try {
611
- writeJson(res, 200, { ok: true, entries: await gallery.remove(id) })
612
- } catch (error) {
613
- writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
614
- }
615
- },
616
- },
617
- // ----------------------------------------------------- gallery tags
618
- {
619
- kind: 'exact',
620
- path: GALLERY_API.tags,
621
- handler: async (req, res) => {
622
- if (!guard(req, res, 'POST')) return
623
- const body = await readJsonBody(req)
624
- const id = typeof body?.id === 'string' ? body.id : ''
625
- const tags = Array.isArray(body?.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined
626
- if (id === '' || tags === undefined || gallery.updateTags === undefined) {
627
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id and tags are required' })
628
- return
629
- }
630
- try { writeJson(res, 200, { ok: true, entries: await gallery.updateTags(id, tags) }) } catch (error) { writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) }) }
631
- },
632
- },
633
- // ---------------------------------------------------- gallery clear
634
- {
635
- kind: 'exact',
636
- path: GALLERY_API.clear,
637
- handler: async (req, res) => {
638
- if (!guard(req, res, 'POST')) return
639
- try {
640
- writeJson(res, 200, { ok: true, entries: await gallery.clear() })
641
- } catch (error) {
642
- writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
643
- }
644
- },
645
- },
646
- // ------------------------------------------------ gallery image (prefix)
647
- {
648
- kind: 'prefix',
649
- path: GALLERY_API.image,
650
- handler: async (req, res) => {
651
- if (!isLoopbackRequest(req)) {
652
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
653
- return
654
- }
655
- if (req.method !== 'GET') {
656
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
657
- return
658
- }
659
- const file = imageFileFrom(req.url, GALLERY_API.image)
660
- if (file === undefined) {
661
- writeJson(res, 404, { error: 'not found' })
662
- return
663
- }
664
- const found = await gallery.readImage(file)
665
- if (found === undefined) {
666
- writeJson(res, 404, { error: 'not found' })
667
- return
668
- }
669
- res.writeHead(200, {
670
- 'content-type': found.mime,
671
- 'content-length': found.data.length,
672
- 'cache-control': 'private, max-age=3600',
673
- })
674
- res.end(found.data)
675
- },
676
- },
677
- // --------------------------------------------------- templates list
678
- {
679
- kind: 'exact',
680
- path: TEMPLATES_API.list,
681
- handler: async (req, res) => {
682
- if (!guard(req, res, 'POST')) return
683
- try {
684
- const result = await templates.list()
685
- writeJson(res, 200, { ok: true, ...result })
686
- } catch (error) {
687
- writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
688
- }
689
- },
690
- },
691
- // ------------------------------------------------- templates refresh
692
- {
693
- kind: 'exact',
694
- path: TEMPLATES_API.refresh,
695
- handler: async (req, res) => {
696
- if (!guard(req, res, 'POST')) return
697
- try {
698
- const result = await templates.refresh()
699
- writeJson(res, 200, { ok: true, ...result })
700
- } catch (error) {
701
- writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
702
- }
703
- },
704
- },
705
- // -------------------------------------- templates image (prefix, proxied)
706
- {
707
- kind: 'prefix',
708
- path: TEMPLATES_API.image,
709
- handler: async (req, res) => {
710
- if (!isLoopbackRequest(req)) {
711
- writeJson(res, 403, { error: 'forbidden: loopback-only' })
712
- return
713
- }
714
- if (req.method !== 'GET') {
715
- writeJson(res, 405, { error: `method not allowed: ${req.method}` })
716
- return
717
- }
718
- const file = imageFileFrom(req.url, TEMPLATES_API.image)
719
- if (file === undefined) {
720
- writeJson(res, 404, { error: 'not found' })
721
- return
722
- }
723
- const found = await templates.readImage(file)
724
- if (found === undefined) {
725
- writeJson(res, 404, { error: 'not found' })
726
- return
727
- }
728
- res.writeHead(200, {
729
- 'content-type': found.mime,
730
- 'content-length': found.data.length,
731
- // Cached on disk by the host; reference images are immutable per name.
732
- 'cache-control': 'private, max-age=86400',
733
- })
734
- res.end(found.data)
735
- },
736
- },
737
- ]
738
- }
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 { randomUUID } from 'node:crypto'
10
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
+ import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
12
+ import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
+ import type { UpstreamConfig } from './engine.ts'
14
+ import { enhancePrompt, listImageModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
15
+ import { normalizeImageModels } from './image-models.ts'
16
+ import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
17
+ import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
18
+ import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
19
+ import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
20
+ import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
21
+ import { IMAGE_PRESETS } from './presets.ts'
22
+ import { AGENT_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
23
+
24
+ /** Cap on JSON request bodies (settings ops and generate payloads are small). */
25
+ const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
26
+
27
+ /** Cap on history append bodies (base64 result images can be much larger). */
28
+ const MAX_HISTORY_BODY_BYTES = 64 * 1024 * 1024
29
+
30
+ /** Settings seam face the bridge needs (the host settings provider). */
31
+ export interface SettingsSeam {
32
+ describe(options?: { redactSecrets?: boolean }): SettingsDescriptor[]
33
+ mutate(ns: unknown, ops: unknown, expectedRevision?: number): Promise<void>
34
+ readonly writable?: boolean
35
+ }
36
+
37
+ /** Route dependencies. */
38
+ export interface ImageGenRoutesDeps {
39
+ /** The settings seam (namespace storage). */
40
+ settings: SettingsSeam
41
+ /** Resolve the current upstream config (legacy single-endpoint path). */
42
+ resolve: () => UpstreamConfig
43
+ /** Resolve the current channel view (the channel-aware path). */
44
+ resolveChannels?: () => ChannelsView
45
+ /** Resolve the optional chat-model configuration for prompt enhancement. */
46
+ resolvePrompt?: () => PromptModelConfig
47
+ /** Models explicitly selected for this image API endpoint (legacy path). */
48
+ resolveImageModels?: () => string[]
49
+ /** Host attachment storage used by Agent tool-result previews. */
50
+ attachments?: {
51
+ readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>
52
+ }
53
+ /** Overrideable history backend, primarily for host integration tests. */
54
+ history?: {
55
+ list: () => Promise<HistoryEntry[]>
56
+ append: (entry: HistoryEntryInput) => Promise<HistoryEntry[]>
57
+ remove: (id: string) => Promise<HistoryEntry[]>
58
+ clear: () => Promise<HistoryEntry[]>
59
+ readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
60
+ }
61
+ /** Overrideable gallery backend, primarily for host integration tests. */
62
+ gallery?: {
63
+ list: () => Promise<HistoryEntry[]>
64
+ append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
65
+ remove: (id: string) => Promise<HistoryEntry[]>
66
+ clear: () => Promise<HistoryEntry[]>
67
+ updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
68
+ readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
69
+ }
70
+ /** Overrideable template-library backend, primarily for host integration tests. */
71
+ templates?: {
72
+ list: () => Promise<TemplateListResult>
73
+ refresh: () => Promise<TemplateRefreshResult>
74
+ readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
75
+ }
76
+ /** Shared host queue, used by Agent tools and browser task endpoints. */
77
+ runtime?: ImageGenerationRuntime
78
+ }
79
+
80
+ /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
81
+ function isLoopbackRequest(request: IncomingMessage): boolean {
82
+ const address = request.socket.remoteAddress
83
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
84
+ const host = request.headers.host
85
+ if (typeof host !== 'string') return false
86
+ let hostUrl: URL
87
+ try {
88
+ hostUrl = new URL(`http://${host}`)
89
+ } catch {
90
+ return false
91
+ }
92
+ if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
93
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
94
+ const origin = request.headers.origin
95
+ if (origin === undefined) return true
96
+ try {
97
+ return new URL(origin).host === hostUrl.host
98
+ } catch {
99
+ return false
100
+ }
101
+ }
102
+
103
+ /** One JSON response. */
104
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
105
+ const payload = JSON.stringify(body)
106
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
107
+ res.end(payload)
108
+ }
109
+
110
+ /** Read a JSON request body (undefined when too large or unparseable). */
111
+ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<Record<string, unknown> | undefined> {
112
+ const chunks: Buffer[] = []
113
+ let size = 0
114
+ for await (const chunk of req) {
115
+ const buffer = chunk as Buffer
116
+ size += buffer.length
117
+ if (size > maxBytes) return undefined
118
+ chunks.push(buffer)
119
+ }
120
+ try {
121
+ const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
122
+ return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
123
+ } catch {
124
+ return undefined
125
+ }
126
+ }
127
+
128
+ /** Human-readable text from an unknown thrown value. */
129
+ function messageOf(error: unknown): string {
130
+ return error instanceof Error ? error.message : String(error)
131
+ }
132
+
133
+ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
134
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
135
+ if (prompt === '') return undefined
136
+ const comparisonModels = Array.isArray(body.comparisonModels)
137
+ ? [...new Set(body.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
138
+ : []
139
+ return {
140
+ mode: body.mode === 'edit' ? 'edit' : 'text',
141
+ model: typeof body.model === 'string' ? body.model : '',
142
+ prompt,
143
+ size: typeof body.size === 'string' ? body.size : 'auto',
144
+ quality: typeof body.quality === 'string' ? body.quality : 'auto',
145
+ n: typeof body.n === 'number' ? body.n : 1,
146
+ detail: typeof body.detail === 'string' ? body.detail : '',
147
+ ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
148
+ ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
149
+ ...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
150
+ ...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
151
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
152
+ }
153
+ }
154
+
155
+ /** Validate a submitted history entry (images carry base64). */
156
+ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
157
+ const raw = body.entry
158
+ if (raw === null || typeof raw !== 'object') return undefined
159
+ const entry = raw as Record<string, unknown>
160
+ if (typeof entry.id !== 'string' || typeof entry.createdAt !== 'number') return undefined
161
+ if (entry.mode !== 'text' && entry.mode !== 'edit') return undefined
162
+ if (typeof entry.model !== 'string' || typeof entry.prompt !== 'string') return undefined
163
+ if (typeof entry.size !== 'string' || typeof entry.quality !== 'string' || typeof entry.detail !== 'string') return undefined
164
+ if (typeof entry.n !== 'number') return undefined
165
+ if (!Array.isArray(entry.images)) return undefined
166
+ const images: GeneratedImage[] = []
167
+ for (const item of entry.images) {
168
+ if (item === null || typeof item !== 'object') return undefined
169
+ const image = item as Record<string, unknown>
170
+ if (typeof image.b64 !== 'string' || typeof image.mime !== 'string') return undefined
171
+ images.push({
172
+ b64: image.b64,
173
+ mime: image.mime,
174
+ ...typeof image.revisedPrompt === 'string' ? { revisedPrompt: image.revisedPrompt } : {},
175
+ })
176
+ }
177
+ const comparisonModels = Array.isArray(entry.comparisonModels)
178
+ ? [...new Set(entry.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
179
+ : []
180
+ return {
181
+ id: entry.id,
182
+ createdAt: entry.createdAt,
183
+ mode: entry.mode,
184
+ model: entry.model,
185
+ prompt: entry.prompt,
186
+ size: entry.size,
187
+ quality: entry.quality,
188
+ detail: entry.detail,
189
+ n: entry.n,
190
+ images,
191
+ ...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
192
+ ...typeof entry.channelId === 'string' ? { channelId: entry.channelId } : {},
193
+ ...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
194
+ ...typeof entry.comparisonId === 'string' ? { comparisonId: entry.comparisonId } : {},
195
+ ...comparisonModels.length > 1 ? { comparisonModels } : {},
196
+ }
197
+ }
198
+
199
+ /** Extract the image file name from a history-image request URL. */
200
+ function imageFileFrom(rawUrl: string | undefined, basePath: string): string | undefined {
201
+ if (rawUrl === undefined) return undefined
202
+ let pathname: string
203
+ try {
204
+ pathname = new URL(rawUrl, 'http://localhost').pathname
205
+ } catch {
206
+ return undefined
207
+ }
208
+ if (!pathname.startsWith(`${basePath}/`)) return undefined
209
+ return decodeURIComponent(pathname.slice(basePath.length + 1))
210
+ }
211
+
212
+ /** Parse the durable image reference carried by an Agent tool-result view. */
213
+ function agentImageRefFrom(rawUrl: string | undefined): ImageAttachmentRef | undefined {
214
+ if (rawUrl === undefined) return undefined
215
+ let url: URL
216
+ try {
217
+ url = new URL(rawUrl, 'http://localhost')
218
+ } catch {
219
+ return undefined
220
+ }
221
+ if (url.pathname !== AGENT_IMAGE_API) return undefined
222
+ const attachmentId = url.searchParams.get('attachment_id') ?? ''
223
+ const mediaType = url.searchParams.get('media_type') ?? ''
224
+ const bytes = Number(url.searchParams.get('bytes'))
225
+ const width = Number(url.searchParams.get('width'))
226
+ const height = Number(url.searchParams.get('height'))
227
+ if (attachmentId === '' || !isImageMediaType(mediaType)
228
+ || !Number.isSafeInteger(bytes) || bytes < 1
229
+ || !Number.isSafeInteger(width) || width < 1
230
+ || !Number.isSafeInteger(height) || height < 1) return undefined
231
+ return {
232
+ attachmentId: attachmentId as ImageAttachmentRef['attachmentId'],
233
+ mediaType,
234
+ bytes,
235
+ width,
236
+ height,
237
+ }
238
+ }
239
+
240
+ function isImageMediaType(value: string): value is ImageMediaType {
241
+ return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
242
+ }
243
+
244
+ /** Project one settings descriptor onto the bridge wire view. */
245
+ function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
246
+ return {
247
+ ns: String(descriptor.ns),
248
+ schema: descriptor.schema,
249
+ value: descriptor.value,
250
+ ...descriptor.base === undefined ? {} : { base: descriptor.base },
251
+ ...descriptor.user === undefined ? {} : { user: descriptor.user },
252
+ ...descriptor.secrets === undefined ? {} : {
253
+ secrets: descriptor.secrets.map(secret => ({ path: [...secret.path], set: secret.set })),
254
+ },
255
+ revision: descriptor.revision,
256
+ }
257
+ }
258
+
259
+ /** Map a seam failure onto the bridge refusal envelope. */
260
+ function failureOf(error: unknown): { ok: false; code: string; message: string } {
261
+ if (error instanceof SettingsConflictError) {
262
+ return { ok: false, code: 'settings-conflict', message: error.message }
263
+ }
264
+ const message = error instanceof Error ? error.message : String(error)
265
+ return { ok: false, code: 'settings-rejected', message }
266
+ }
267
+
268
+ /**
269
+ * Build every /api/dsh-imagegen route.
270
+ * @param deps - settings seam + config resolver.
271
+ * @returns the route registrations.
272
+ */
273
+ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
274
+ const history = deps.history ?? {
275
+ list: listHistory,
276
+ append: appendHistory,
277
+ remove: removeHistory,
278
+ clear: clearHistory,
279
+ readImage: readHistoryImage,
280
+ }
281
+ const gallery: NonNullable<ImageGenRoutesDeps['gallery']> = deps.gallery ?? {
282
+ list: listGallery,
283
+ append: appendGallery,
284
+ remove: removeGallery,
285
+ clear: clearGallery,
286
+ updateTags: updateGalleryTags,
287
+ readImage: readGalleryImage,
288
+ }
289
+ const templates = deps.templates ?? {
290
+ list: listTemplates,
291
+ refresh: refreshTemplates,
292
+ readImage: readTemplateImage,
293
+ }
294
+ const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
295
+ const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
296
+
297
+ /** The current channel view: the channel-aware resolver, or a synthesized
298
+ * single default channel from the legacy flat upstream config (tests and
299
+ * older hosts). */
300
+ const channelViewOf = (): ChannelsView => {
301
+ if (deps.resolveChannels !== undefined) return deps.resolveChannels()
302
+ const upstream = deps.resolve()
303
+ const models: ModelMapping[] = normalizeImageModels(resolveImageModels()).map(id => ({ alias: id, id }))
304
+ if (upstream.apiUrl.trim() === '' && models.length === 0) return { channels: [], defaultChannelId: '' }
305
+ return {
306
+ channels: [{ id: 'default', preset: '', name: '默认渠道', apiUrl: upstream.apiUrl, apiKey: upstream.apiKey, models }],
307
+ defaultChannelId: 'default',
308
+ }
309
+ }
310
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf, history)
311
+
312
+ /** Resolve an alias (or the channel fallback) into a concrete generation
313
+ * request: picks the channel (explicit then default), maps alias → upstream
314
+ * id, and fills the channel snapshot kept on history entries. */
315
+ const resolveChannelRequest = (request: GenerateRequest): { ok: true; request: GenerateRequest } | { ok: false; code: string; message: string } => {
316
+ const view = channelViewOf()
317
+ if (view.channels.length === 0) {
318
+ return { ok: false, code: 'no-channels', message: '尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥' }
319
+ }
320
+ const explicit = view.channels.find(candidate => candidate.id === request.channelId)
321
+ const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
322
+ const target = explicit ?? defaults
323
+ const asked = request.model.trim()
324
+ if (asked === '') {
325
+ const alias = target?.models[0]?.alias ?? ''
326
+ if (alias === '') {
327
+ return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型,请先在设置中添加` }
328
+ }
329
+ const mapping = target!.models.find(model => model.alias === alias)!
330
+ return { ok: true, request: { ...request, model: alias, upstream: mapping.id, channelId: target!.id, channel: target!.name } }
331
+ }
332
+ const hosting = view.channels.filter(channel => channel.models.some(model => model.alias === asked))
333
+ if (hosting.length === 0) {
334
+ const available = [...new Set(view.channels.flatMap(channel => channel.models.map(model => model.alias)))]
335
+ return { ok: false, code: 'image-model-not-configured', message: `模型「${asked}」未在任一渠道配置;可用模型:${available.join('、') || '(无)'}` }
336
+ }
337
+ const picked = target !== undefined && target.models.some(model => model.alias === asked) ? target : hosting[0]!
338
+ const mapping = picked.models.find(model => model.alias === asked)!
339
+ return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
340
+ }
341
+ const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
342
+ if (!isLoopbackRequest(req)) {
343
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
344
+ return false
345
+ }
346
+ if (req.method !== method) {
347
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
348
+ return false
349
+ }
350
+ return true
351
+ }
352
+
353
+ return [
354
+ // ------------------------------------ Agent tool-result image (prefix)
355
+ ...(deps.attachments === undefined ? [] : [{
356
+ kind: 'prefix' as const,
357
+ path: AGENT_IMAGE_API,
358
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
359
+ if (!isLoopbackRequest(req)) {
360
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
361
+ return
362
+ }
363
+ if (req.method !== 'GET') {
364
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
365
+ return
366
+ }
367
+ const ref = agentImageRefFrom(req.url)
368
+ if (ref === undefined) {
369
+ writeJson(res, 400, { error: 'invalid image reference' })
370
+ return
371
+ }
372
+ try {
373
+ const stored = await deps.attachments!.readImage(ref)
374
+ res.writeHead(200, {
375
+ 'content-type': stored.ref.mediaType,
376
+ 'content-length': stored.data.byteLength,
377
+ 'cache-control': 'private, max-age=3600',
378
+ })
379
+ res.end(Buffer.from(stored.data))
380
+ } catch {
381
+ // Do not expose attachment-store details through the browser route.
382
+ writeJson(res, 404, { error: 'image attachment not found' })
383
+ }
384
+ },
385
+ } satisfies WebRoute]),
386
+ // -------------------------------------------- image model discovery
387
+ // Accepts optional temporary per-channel credentials so the settings card
388
+ // can probe the endpoint the user is *typing* without saving first:
389
+ // { channelId?, apiUrl?, apiKey? } — the channel's stored values are the
390
+ // fallback, and the body's apiUrl/apiKey override them for this call.
391
+ {
392
+ kind: 'exact',
393
+ path: IMAGE_MODEL_API.models,
394
+ handler: async (req, res) => {
395
+ if (!guard(req, res, 'POST')) return
396
+ const body = await readJsonBody(req)
397
+ const view = channelViewOf()
398
+ const stored = view.channels.find(candidate => candidate.id === (typeof body?.channelId === 'string' ? body.channelId : undefined))
399
+ ?? view.channels.find(candidate => candidate.id === view.defaultChannelId)
400
+ ?? view.channels[0]
401
+ const upstream: UpstreamConfig = {
402
+ apiUrl: typeof body?.apiUrl === 'string' && body.apiUrl.trim() !== '' ? body.apiUrl.trim() : (stored?.apiUrl ?? ''),
403
+ apiKey: typeof body?.apiKey === 'string' && body.apiKey.trim() !== '' ? body.apiKey.trim() : (stored?.apiKey ?? ''),
404
+ }
405
+ try {
406
+ writeJson(res, 200, { ok: true, models: await listImageModels(upstream) })
407
+ } catch (error) {
408
+ writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
409
+ }
410
+ },
411
+ },
412
+ // ---------------------------------------------------------- presets
413
+ {
414
+ kind: 'exact',
415
+ path: PRESETS_API,
416
+ handler: async (req, res) => {
417
+ if (!guard(req, res, 'POST')) return
418
+ const presets: PresetProviderView[] = IMAGE_PRESETS.map(preset => ({
419
+ id: preset.id,
420
+ name: preset.name,
421
+ apiUrl: preset.apiUrl,
422
+ hint: preset.hint,
423
+ models: preset.models,
424
+ }))
425
+ writeJson(res, 200, { ok: true, presets })
426
+ },
427
+ },
428
+ // ----------------------------------------------------------- usage
429
+ {
430
+ kind: 'exact',
431
+ path: USAGE_API,
432
+ handler: async (req, res) => {
433
+ if (!guard(req, res, 'POST')) return
434
+ try {
435
+ const entries = [...await history.list(), ...await gallery.list()]
436
+ // byChannel[channelId | 'name:<name>' | ''] → { alias: count }.
437
+ const byChannel: Record<string, Record<string, number>> = {}
438
+ const totals: Record<string, number> = {}
439
+ for (const entry of entries) {
440
+ const channelKey = entry.channelId !== undefined ? entry.channelId : (entry.channel !== undefined ? `name:${entry.channel}` : '')
441
+ const alias = entry.model
442
+ const bucket = byChannel[channelKey] ?? (byChannel[channelKey] = {})
443
+ bucket[alias] = (bucket[alias] ?? 0) + 1
444
+ totals[alias] = (totals[alias] ?? 0) + 1
445
+ }
446
+ writeJson(res, 200, { ok: true, usage: { byChannel, totals } })
447
+ } catch (error) {
448
+ writeJson(res, 200, { ok: false, code: 'usage-failed', message: messageOf(error) })
449
+ }
450
+ },
451
+ },
452
+ // ----------------------------------------------- prompt enhancement
453
+ {
454
+ kind: 'exact',
455
+ path: PROMPT_ENHANCE_API.models,
456
+ handler: async (req, res) => {
457
+ if (!guard(req, res, 'POST')) return
458
+ try {
459
+ writeJson(res, 200, { ok: true, models: await listPromptModels(resolvePrompt()) })
460
+ } catch (error) {
461
+ writeJson(res, 200, { ok: false, code: 'prompt-models-failed', message: messageOf(error) })
462
+ }
463
+ },
464
+ },
465
+ {
466
+ kind: 'exact',
467
+ path: PROMPT_ENHANCE_API.enhance,
468
+ handler: async (req, res) => {
469
+ if (!guard(req, res, 'POST')) return
470
+ const body = await readJsonBody(req)
471
+ const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
472
+ if (prompt === '') {
473
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
474
+ return
475
+ }
476
+ try {
477
+ writeJson(res, 200, { ok: true, prompt: await enhancePrompt(resolvePrompt(), prompt) })
478
+ } catch (error) {
479
+ writeJson(res, 200, { ok: false, code: 'prompt-enhance-failed', message: messageOf(error) })
480
+ }
481
+ },
482
+ },
483
+ // -------------------------------------------------- settings describe
484
+ {
485
+ kind: 'exact',
486
+ path: SETTINGS_API.describe,
487
+ handler: async (req, res) => {
488
+ if (!guard(req, res, 'POST')) return
489
+ const descriptor = deps.settings.describe({ redactSecrets: true })
490
+ .find(candidate => String(candidate.ns) === IMAGEGEN_SETTINGS_NAMESPACE)
491
+ writeJson(res, 200, {
492
+ ok: true,
493
+ value: {
494
+ namespaces: descriptor === undefined ? [] : [toView(descriptor)],
495
+ writable: deps.settings.writable !== false,
496
+ },
497
+ })
498
+ },
499
+ },
500
+ // ----------------------------------------------------- settings mutate
501
+ {
502
+ kind: 'exact',
503
+ path: SETTINGS_API.mutate,
504
+ handler: async (req, res) => {
505
+ if (!guard(req, res, 'POST')) return
506
+ const body = await readJsonBody(req)
507
+ if (body === undefined) {
508
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'unreadable JSON body' })
509
+ return
510
+ }
511
+ const ns = typeof body.ns === 'string' ? body.ns : ''
512
+ if (ns !== IMAGEGEN_SETTINGS_NAMESPACE || !Array.isArray(body.ops)) {
513
+ writeJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge settings request' })
514
+ return
515
+ }
516
+ const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
517
+ try {
518
+ await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
519
+ } catch (error) {
520
+ writeJson(res, 200, failureOf(error))
521
+ return
522
+ }
523
+ const descriptor = deps.settings.describe({ redactSecrets: true })
524
+ .find(candidate => String(candidate.ns) === ns)
525
+ if (descriptor === undefined) {
526
+ writeJson(res, 200, { ok: false, code: 'internal', message: `settings namespace "${ns}" was disposed after the mutate` })
527
+ return
528
+ }
529
+ writeJson(res, 200, { ok: true, value: toView(descriptor) })
530
+ },
531
+ },
532
+ // ----------------------------------------------------------- generate
533
+ {
534
+ kind: 'exact',
535
+ path: GENERATE_API,
536
+ handler: async (req, res) => {
537
+ if (!guard(req, res, 'POST')) return
538
+ const body = await readJsonBody(req)
539
+ const parsed = body === undefined ? undefined : parseGenerateRequest(body)
540
+ if (parsed === undefined) {
541
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
542
+ return
543
+ }
544
+ const resolved = resolveChannelRequest(parsed)
545
+ if (!resolved.ok) {
546
+ writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
547
+ return
548
+ }
549
+ try {
550
+ writeJson(res, 200, { ok: true, ...await runtime.run(resolved.request) })
551
+ } catch (error) {
552
+ const message = error instanceof Error ? error.message : String(error)
553
+ const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
554
+ ? (error as { code: string }).code
555
+ : 'generate-failed'
556
+ writeJson(res, 200, { ok: false, code, message })
557
+ }
558
+ },
559
+ },
560
+ // ------------------------------------------------ generation task queue
561
+ {
562
+ kind: 'exact', path: TASK_API.submit,
563
+ handler: async (req, res) => {
564
+ if (!guard(req, res, 'POST')) return
565
+ const body = await readJsonBody(req)
566
+ const parsed = body === undefined ? undefined : parseGenerateRequest(body)
567
+ if (parsed === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
568
+ const resolved = resolveChannelRequest(parsed)
569
+ if (!resolved.ok) {
570
+ writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
571
+ return
572
+ }
573
+ writeJson(res, 200, { ok: true, task: runtime.queue.submit(resolved.request) })
574
+ },
575
+ },
576
+ {
577
+ kind: 'exact', path: TASK_API.list,
578
+ handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: runtime.queue.list() }) },
579
+ },
580
+ {
581
+ kind: 'exact', path: TASK_API.cancel,
582
+ handler: async (req, res) => {
583
+ if (!guard(req, res, 'POST')) return
584
+ const body = await readJsonBody(req)
585
+ const task = typeof body?.id === 'string' ? runtime.queue.cancel(body.id) : undefined
586
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
587
+ writeJson(res, 200, { ok: true, task })
588
+ },
589
+ },
590
+ {
591
+ kind: 'exact', path: TASK_API.retry,
592
+ handler: async (req, res) => {
593
+ if (!guard(req, res, 'POST')) return
594
+ const body = await readJsonBody(req)
595
+ const task = typeof body?.id === 'string' ? runtime.queue.retry(body.id) : undefined
596
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
597
+ writeJson(res, 200, { ok: true, task })
598
+ },
599
+ },
600
+ // ----------------------------------------------- update check
601
+ {
602
+ kind: 'exact',
603
+ path: UPDATE_API.check,
604
+ handler: async (req, res) => {
605
+ if (!guard(req, res, 'POST')) return
606
+ try {
607
+ writeJson(res, 200, { ok: true, update: await checkForUpdate() })
608
+ } catch (error) {
609
+ writeJson(res, 200, { ok: false, code: 'update-check-failed', message: messageOf(error) })
610
+ }
611
+ },
612
+ },
613
+ // ----------------------------------------------- update apply
614
+ {
615
+ kind: 'exact',
616
+ path: UPDATE_API.apply,
617
+ handler: async (req, res) => {
618
+ if (!guard(req, res, 'POST')) return
619
+ const body = await readJsonBody(req)
620
+ const version = body !== undefined && typeof body.version === 'string' ? body.version.trim() : ''
621
+ if (version === '') {
622
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'update version is required' })
623
+ return
624
+ }
625
+ try {
626
+ const latest = await checkForUpdate()
627
+ if (!latest.updateAvailable || latest.latestVersion !== version) {
628
+ writeJson(res, 200, { ok: false, code: 'update-not-available', message: `version ${version} is not the latest available release` })
629
+ return
630
+ }
631
+ await installUpdate(version)
632
+ writeJson(res, 200, { ok: true, currentVersion: CURRENT_VERSION, updatedVersion: version, restartRequired: true })
633
+ } catch (error) {
634
+ writeJson(res, 200, { ok: false, code: 'update-failed', message: messageOf(error) })
635
+ }
636
+ },
637
+ },
638
+ // ----------------------------------------------------- history list
639
+ {
640
+ kind: 'exact',
641
+ path: HISTORY_API.list,
642
+ handler: async (req, res) => {
643
+ if (!guard(req, res, 'POST')) return
644
+ try {
645
+ writeJson(res, 200, { ok: true, entries: await history.list() })
646
+ } catch (error) {
647
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
648
+ }
649
+ },
650
+ },
651
+ // --------------------------------------------------- history append
652
+ {
653
+ kind: 'exact',
654
+ path: HISTORY_API.append,
655
+ handler: async (req, res) => {
656
+ if (!guard(req, res, 'POST')) return
657
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
658
+ if (body === undefined) {
659
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
660
+ return
661
+ }
662
+ const entry = parseHistoryEntryInput(body)
663
+ if (entry === undefined) {
664
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed history entry' })
665
+ return
666
+ }
667
+ try {
668
+ writeJson(res, 200, { ok: true, entries: await history.append(entry) })
669
+ } catch (error) {
670
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
671
+ }
672
+ },
673
+ },
674
+ // --------------------------------------------------- history remove
675
+ {
676
+ kind: 'exact',
677
+ path: HISTORY_API.remove,
678
+ handler: async (req, res) => {
679
+ if (!guard(req, res, 'POST')) return
680
+ const body = await readJsonBody(req)
681
+ const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
682
+ if (id === '') {
683
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'history id is required' })
684
+ return
685
+ }
686
+ try {
687
+ writeJson(res, 200, { ok: true, entries: await history.remove(id) })
688
+ } catch (error) {
689
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
690
+ }
691
+ },
692
+ },
693
+ // ---------------------------------------------------- history clear
694
+ {
695
+ kind: 'exact',
696
+ path: HISTORY_API.clear,
697
+ handler: async (req, res) => {
698
+ if (!guard(req, res, 'POST')) return
699
+ try {
700
+ writeJson(res, 200, { ok: true, entries: await history.clear() })
701
+ } catch (error) {
702
+ writeJson(res, 200, { ok: false, code: 'history-failed', message: messageOf(error) })
703
+ }
704
+ },
705
+ },
706
+ // ------------------------------------------------ history image (prefix)
707
+ {
708
+ kind: 'prefix',
709
+ path: HISTORY_API.image,
710
+ handler: async (req, res) => {
711
+ if (!isLoopbackRequest(req)) {
712
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
713
+ return
714
+ }
715
+ if (req.method !== 'GET') {
716
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
717
+ return
718
+ }
719
+ const file = imageFileFrom(req.url, HISTORY_API.image)
720
+ if (file === undefined) {
721
+ writeJson(res, 404, { error: 'not found' })
722
+ return
723
+ }
724
+ const found = await history.readImage(file)
725
+ if (found === undefined) {
726
+ writeJson(res, 404, { error: 'not found' })
727
+ return
728
+ }
729
+ res.writeHead(200, {
730
+ 'content-type': found.mime,
731
+ 'content-length': found.data.length,
732
+ 'cache-control': 'private, max-age=3600',
733
+ })
734
+ res.end(found.data)
735
+ },
736
+ },
737
+ // ----------------------------------------------------- gallery list
738
+ {
739
+ kind: 'exact',
740
+ path: GALLERY_API.list,
741
+ handler: async (req, res) => {
742
+ if (!guard(req, res, 'POST')) return
743
+ try {
744
+ writeJson(res, 200, { ok: true, entries: await gallery.list() })
745
+ } catch (error) {
746
+ writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
747
+ }
748
+ },
749
+ },
750
+ // --------------------------------------------------- gallery append
751
+ {
752
+ kind: 'exact',
753
+ path: GALLERY_API.append,
754
+ handler: async (req, res) => {
755
+ if (!guard(req, res, 'POST')) return
756
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
757
+ if (body === undefined) {
758
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
759
+ return
760
+ }
761
+ const entry = parseHistoryEntryInput(body)
762
+ if (entry === undefined) {
763
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed gallery entry' })
764
+ return
765
+ }
766
+ try {
767
+ // The host owns gallery ids: a fresh id per append keeps retries and
768
+ // duplicate submissions from ever reusing a stale filename prefix.
769
+ const result = await gallery.append({ ...entry, id: randomUUID() })
770
+ writeJson(res, 200, { ok: true, entries: result.entries, added: result.added })
771
+ } catch (error) {
772
+ writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
773
+ }
774
+ },
775
+ },
776
+ // --------------------------------------------------- gallery remove
777
+ {
778
+ kind: 'exact',
779
+ path: GALLERY_API.remove,
780
+ handler: async (req, res) => {
781
+ if (!guard(req, res, 'POST')) return
782
+ const body = await readJsonBody(req)
783
+ const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
784
+ if (id === '') {
785
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id is required' })
786
+ return
787
+ }
788
+ try {
789
+ writeJson(res, 200, { ok: true, entries: await gallery.remove(id) })
790
+ } catch (error) {
791
+ writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
792
+ }
793
+ },
794
+ },
795
+ // ----------------------------------------------------- gallery tags
796
+ {
797
+ kind: 'exact',
798
+ path: GALLERY_API.tags,
799
+ handler: async (req, res) => {
800
+ if (!guard(req, res, 'POST')) return
801
+ const body = await readJsonBody(req)
802
+ const id = typeof body?.id === 'string' ? body.id : ''
803
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined
804
+ if (id === '' || tags === undefined || gallery.updateTags === undefined) {
805
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id and tags are required' })
806
+ return
807
+ }
808
+ try { writeJson(res, 200, { ok: true, entries: await gallery.updateTags(id, tags) }) } catch (error) { writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) }) }
809
+ },
810
+ },
811
+ // ---------------------------------------------------- gallery clear
812
+ {
813
+ kind: 'exact',
814
+ path: GALLERY_API.clear,
815
+ handler: async (req, res) => {
816
+ if (!guard(req, res, 'POST')) return
817
+ try {
818
+ writeJson(res, 200, { ok: true, entries: await gallery.clear() })
819
+ } catch (error) {
820
+ writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
821
+ }
822
+ },
823
+ },
824
+ // ------------------------------------------------ gallery image (prefix)
825
+ {
826
+ kind: 'prefix',
827
+ path: GALLERY_API.image,
828
+ handler: async (req, res) => {
829
+ if (!isLoopbackRequest(req)) {
830
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
831
+ return
832
+ }
833
+ if (req.method !== 'GET') {
834
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
835
+ return
836
+ }
837
+ const file = imageFileFrom(req.url, GALLERY_API.image)
838
+ if (file === undefined) {
839
+ writeJson(res, 404, { error: 'not found' })
840
+ return
841
+ }
842
+ const found = await gallery.readImage(file)
843
+ if (found === undefined) {
844
+ writeJson(res, 404, { error: 'not found' })
845
+ return
846
+ }
847
+ res.writeHead(200, {
848
+ 'content-type': found.mime,
849
+ 'content-length': found.data.length,
850
+ 'cache-control': 'private, max-age=3600',
851
+ })
852
+ res.end(found.data)
853
+ },
854
+ },
855
+ // --------------------------------------------------- templates list
856
+ {
857
+ kind: 'exact',
858
+ path: TEMPLATES_API.list,
859
+ handler: async (req, res) => {
860
+ if (!guard(req, res, 'POST')) return
861
+ try {
862
+ const result = await templates.list()
863
+ writeJson(res, 200, { ok: true, ...result })
864
+ } catch (error) {
865
+ writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
866
+ }
867
+ },
868
+ },
869
+ // ------------------------------------------------- templates refresh
870
+ {
871
+ kind: 'exact',
872
+ path: TEMPLATES_API.refresh,
873
+ handler: async (req, res) => {
874
+ if (!guard(req, res, 'POST')) return
875
+ try {
876
+ const result = await templates.refresh()
877
+ writeJson(res, 200, { ok: true, ...result })
878
+ } catch (error) {
879
+ writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
880
+ }
881
+ },
882
+ },
883
+ // -------------------------------------- templates image (prefix, proxied)
884
+ {
885
+ kind: 'prefix',
886
+ path: TEMPLATES_API.image,
887
+ handler: async (req, res) => {
888
+ if (!isLoopbackRequest(req)) {
889
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
890
+ return
891
+ }
892
+ if (req.method !== 'GET') {
893
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
894
+ return
895
+ }
896
+ const file = imageFileFrom(req.url, TEMPLATES_API.image)
897
+ if (file === undefined) {
898
+ writeJson(res, 404, { error: 'not found' })
899
+ return
900
+ }
901
+ const found = await templates.readImage(file)
902
+ if (found === undefined) {
903
+ writeJson(res, 404, { error: 'not found' })
904
+ return
905
+ }
906
+ res.writeHead(200, {
907
+ 'content-type': found.mime,
908
+ 'content-length': found.data.length,
909
+ // Cached on disk by the host; reference images are immutable per name.
910
+ 'cache-control': 'private, max-age=86400',
911
+ })
912
+ res.end(found.data)
913
+ },
914
+ },
915
+ ]
916
+ }