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