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