@dickpy/dsh-imagegen 1.0.20 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -9,12 +9,15 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
11
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
12
- import { generateImage, type UpstreamConfig } from './engine.ts'
12
+ import type { UpstreamConfig } from './engine.ts'
13
+ import { enhancePrompt, listOpenAIModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
14
+ import { normalizeImageModels } from './image-models.ts'
15
+ import { ImageGenerationRuntime } from './generation-runtime.ts'
13
16
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
14
- import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery } from './gallery-store.ts'
17
+ import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
15
18
  import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
16
19
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
17
- import { GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, SETTINGS_API, TEMPLATES_API, UPDATE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
20
+ import { GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
18
21
 
19
22
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
20
23
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -35,6 +38,10 @@ export interface ImageGenRoutesDeps {
35
38
  settings: SettingsSeam
36
39
  /** Resolve the current upstream config (composition entry + settings). */
37
40
  resolve: () => UpstreamConfig
41
+ /** Resolve the optional chat-model configuration for prompt enhancement. */
42
+ resolvePrompt?: () => PromptModelConfig
43
+ /** Models explicitly selected for this image API endpoint. */
44
+ resolveImageModels?: () => string[]
38
45
  /** Overrideable history backend, primarily for host integration tests. */
39
46
  history?: {
40
47
  list: () => Promise<HistoryEntry[]>
@@ -49,6 +56,7 @@ export interface ImageGenRoutesDeps {
49
56
  append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
50
57
  remove: (id: string) => Promise<HistoryEntry[]>
51
58
  clear: () => Promise<HistoryEntry[]>
59
+ updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
52
60
  readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
53
61
  }
54
62
  /** Overrideable template-library backend, primarily for host integration tests. */
@@ -57,6 +65,8 @@ export interface ImageGenRoutesDeps {
57
65
  refresh: () => Promise<TemplateRefreshResult>
58
66
  readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
59
67
  }
68
+ /** Shared host queue, used by Agent tools and browser task endpoints. */
69
+ runtime?: ImageGenerationRuntime
60
70
  }
61
71
 
62
72
  /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
@@ -112,6 +122,22 @@ function messageOf(error: unknown): string {
112
122
  return error instanceof Error ? error.message : String(error)
113
123
  }
114
124
 
125
+ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
126
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
127
+ if (prompt === '') return undefined
128
+ return {
129
+ mode: body.mode === 'edit' ? 'edit' : 'text',
130
+ model: typeof body.model === 'string' ? body.model : '',
131
+ prompt,
132
+ size: typeof body.size === 'string' ? body.size : 'auto',
133
+ quality: typeof body.quality === 'string' ? body.quality : 'auto',
134
+ n: typeof body.n === 'number' ? body.n : 1,
135
+ detail: typeof body.detail === 'string' ? body.detail : '',
136
+ ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
137
+ ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
138
+ }
139
+ }
140
+
115
141
  /** Validate a submitted history entry (images carry base64). */
116
142
  function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
117
143
  const raw = body.entry
@@ -199,11 +225,12 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
199
225
  clear: clearHistory,
200
226
  readImage: readHistoryImage,
201
227
  }
202
- const gallery = deps.gallery ?? {
228
+ const gallery: NonNullable<ImageGenRoutesDeps['gallery']> = deps.gallery ?? {
203
229
  list: listGallery,
204
230
  append: appendGallery,
205
231
  remove: removeGallery,
206
232
  clear: clearGallery,
233
+ updateTags: updateGalleryTags,
207
234
  readImage: readGalleryImage,
208
235
  }
209
236
  const templates = deps.templates ?? {
@@ -211,6 +238,17 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
211
238
  refresh: refreshTemplates,
212
239
  readImage: readTemplateImage,
213
240
  }
241
+ const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
242
+ const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
243
+ const parseConfiguredRequest = (body: Record<string, unknown>): GenerateRequest | undefined => {
244
+ const request = parseGenerateRequest(body)
245
+ if (request === undefined) return undefined
246
+ const models = normalizeImageModels(resolveImageModels())
247
+ const model = request.model.trim() === '' ? models[0] : request.model.trim()
248
+ if (!models.includes(model)) throw new Error(`image model "${model}" is not configured; choose one of: ${models.join(', ')}`)
249
+ return { ...request, model }
250
+ }
251
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history)
214
252
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
215
253
  if (!isLoopbackRequest(req)) {
216
254
  writeJson(res, 403, { error: 'forbidden: loopback-only' })
@@ -224,6 +262,50 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
224
262
  }
225
263
 
226
264
  return [
265
+ // -------------------------------------------- image model discovery
266
+ {
267
+ kind: 'exact',
268
+ path: IMAGE_MODEL_API.models,
269
+ handler: async (req, res) => {
270
+ if (!guard(req, res, 'POST')) return
271
+ try {
272
+ writeJson(res, 200, { ok: true, models: await listOpenAIModels(deps.resolve()) })
273
+ } catch (error) {
274
+ writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
275
+ }
276
+ },
277
+ },
278
+ // ----------------------------------------------- prompt enhancement
279
+ {
280
+ kind: 'exact',
281
+ path: PROMPT_ENHANCE_API.models,
282
+ handler: async (req, res) => {
283
+ if (!guard(req, res, 'POST')) return
284
+ try {
285
+ writeJson(res, 200, { ok: true, models: await listPromptModels(resolvePrompt()) })
286
+ } catch (error) {
287
+ writeJson(res, 200, { ok: false, code: 'prompt-models-failed', message: messageOf(error) })
288
+ }
289
+ },
290
+ },
291
+ {
292
+ kind: 'exact',
293
+ path: PROMPT_ENHANCE_API.enhance,
294
+ handler: async (req, res) => {
295
+ if (!guard(req, res, 'POST')) return
296
+ const body = await readJsonBody(req)
297
+ const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
298
+ if (prompt === '') {
299
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
300
+ return
301
+ }
302
+ try {
303
+ writeJson(res, 200, { ok: true, prompt: await enhancePrompt(resolvePrompt(), prompt) })
304
+ } catch (error) {
305
+ writeJson(res, 200, { ok: false, code: 'prompt-enhance-failed', message: messageOf(error) })
306
+ }
307
+ },
308
+ },
227
309
  // -------------------------------------------------- settings describe
228
310
  {
229
311
  kind: 'exact',
@@ -284,46 +366,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
284
366
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
285
367
  return
286
368
  }
287
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
288
- if (prompt === '') {
369
+ let request: GenerateRequest | undefined
370
+ try { request = parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
371
+ if (request === undefined) {
289
372
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
290
373
  return
291
374
  }
292
- if (prompt.length > 2000) {
293
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt exceeds 2000 characters' })
294
- return
295
- }
296
- const request: GenerateRequest = {
297
- mode: body.mode === 'edit' ? 'edit' : 'text',
298
- model: typeof body.model === 'string' ? body.model : 'gpt-image-2',
299
- prompt,
300
- size: typeof body.size === 'string' ? body.size : 'auto',
301
- quality: typeof body.quality === 'string' ? body.quality : 'auto',
302
- n: typeof body.n === 'number' ? body.n : 1,
303
- detail: typeof body.detail === 'string' ? body.detail : '',
304
- ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
305
- ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
306
- }
307
375
  try {
308
- const result = await generateImage(deps.resolve(), request)
309
- try {
310
- const entries = await history.append({
311
- id: randomUUID(),
312
- createdAt: Date.now(),
313
- mode: request.mode,
314
- model: request.model,
315
- prompt: request.prompt,
316
- size: request.size,
317
- quality: request.quality,
318
- detail: request.detail,
319
- n: request.n,
320
- images: result.images,
321
- ...request.refName === undefined ? {} : { refName: request.refName },
322
- })
323
- writeJson(res, 200, { ok: true, ...result, history: entries })
324
- } catch (error) {
325
- writeJson(res, 200, { ok: true, ...result, historyError: messageOf(error) })
326
- }
376
+ writeJson(res, 200, { ok: true, ...await runtime.run(request) })
327
377
  } catch (error) {
328
378
  const message = error instanceof Error ? error.message : String(error)
329
379
  const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
@@ -333,6 +383,42 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
333
383
  }
334
384
  },
335
385
  },
386
+ // ------------------------------------------------ generation task queue
387
+ {
388
+ kind: 'exact', path: TASK_API.submit,
389
+ handler: async (req, res) => {
390
+ if (!guard(req, res, 'POST')) return
391
+ const body = await readJsonBody(req)
392
+ let request: GenerateRequest | undefined
393
+ try { request = body === undefined ? undefined : parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
394
+ if (request === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
395
+ writeJson(res, 200, { ok: true, task: runtime.queue.submit(request) })
396
+ },
397
+ },
398
+ {
399
+ kind: 'exact', path: TASK_API.list,
400
+ handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: runtime.queue.list() }) },
401
+ },
402
+ {
403
+ kind: 'exact', path: TASK_API.cancel,
404
+ handler: async (req, res) => {
405
+ if (!guard(req, res, 'POST')) return
406
+ const body = await readJsonBody(req)
407
+ const task = typeof body?.id === 'string' ? runtime.queue.cancel(body.id) : undefined
408
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
409
+ writeJson(res, 200, { ok: true, task })
410
+ },
411
+ },
412
+ {
413
+ kind: 'exact', path: TASK_API.retry,
414
+ handler: async (req, res) => {
415
+ if (!guard(req, res, 'POST')) return
416
+ const body = await readJsonBody(req)
417
+ const task = typeof body?.id === 'string' ? runtime.queue.retry(body.id) : undefined
418
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
419
+ writeJson(res, 200, { ok: true, task })
420
+ },
421
+ },
336
422
  // ----------------------------------------------- update check
337
423
  {
338
424
  kind: 'exact',
@@ -528,6 +614,22 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
528
614
  }
529
615
  },
530
616
  },
617
+ // ----------------------------------------------------- gallery tags
618
+ {
619
+ kind: 'exact',
620
+ path: GALLERY_API.tags,
621
+ handler: async (req, res) => {
622
+ if (!guard(req, res, 'POST')) return
623
+ const body = await readJsonBody(req)
624
+ const id = typeof body?.id === 'string' ? body.id : ''
625
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined
626
+ if (id === '' || tags === undefined || gallery.updateTags === undefined) {
627
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id and tags are required' })
628
+ return
629
+ }
630
+ try { writeJson(res, 200, { ok: true, entries: await gallery.updateTags(id, tags) }) } catch (error) { writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) }) }
631
+ },
632
+ },
531
633
  // ---------------------------------------------------- gallery clear
532
634
  {
533
635
  kind: 'exact',
@@ -0,0 +1,103 @@
1
+ /** In-memory, host-resident image generation queue. */
2
+
3
+ import { randomUUID } from 'node:crypto'
4
+ import type { GenerateRequest, GenerateResult, GenerationTask } from './protocol.ts'
5
+
6
+ export type GenerationTaskListener = (task: GenerationTask) => void
7
+
8
+ export class GenerationTaskQueue {
9
+ private readonly tasks: GenerationTask[] = []
10
+ private readonly controllers = new Map<string, AbortController>()
11
+ private readonly listeners = new Set<GenerationTaskListener>()
12
+ private running = false
13
+
14
+ constructor(private readonly run: (request: GenerateRequest, signal: AbortSignal) => Promise<GenerateResult>) {}
15
+
16
+ list(): GenerationTask[] {
17
+ return this.tasks.map(task => this.snapshot(task))
18
+ }
19
+
20
+ /** Observe queue state changes. Listener failures never disrupt generation. */
21
+ subscribe(listener: GenerationTaskListener): () => void {
22
+ this.listeners.add(listener)
23
+ return () => { this.listeners.delete(listener) }
24
+ }
25
+
26
+ submit(request: GenerateRequest): GenerationTask {
27
+ const task: GenerationTask = { id: randomUUID(), request: { ...request }, status: 'queued', createdAt: Date.now() }
28
+ this.tasks.unshift(task)
29
+ this.publish(task)
30
+ void this.drain()
31
+ return this.snapshot(task)
32
+ }
33
+
34
+ cancel(id: string): GenerationTask | undefined {
35
+ const task = this.tasks.find(item => item.id === id)
36
+ if (task === undefined || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') return task
37
+ task.status = 'cancelled'
38
+ task.finishedAt = Date.now()
39
+ this.controllers.get(id)?.abort()
40
+ this.publish(task)
41
+ return this.snapshot(task)
42
+ }
43
+
44
+ retry(id: string): GenerationTask | undefined {
45
+ const previous = this.tasks.find(item => item.id === id)
46
+ return previous === undefined ? undefined : this.submit(previous.request)
47
+ }
48
+
49
+ private async drain(): Promise<void> {
50
+ if (this.running) return
51
+ this.running = true
52
+ try {
53
+ for (;;) {
54
+ const task = this.tasks.find(item => item.status === 'queued')
55
+ if (task === undefined) return
56
+ task.status = 'running'
57
+ task.startedAt = Date.now()
58
+ this.publish(task)
59
+ const controller = new AbortController()
60
+ this.controllers.set(task.id, controller)
61
+ try {
62
+ const result = await this.run(task.request, controller.signal)
63
+ if (this.tasks.find(item => item.id === task.id)?.status !== 'cancelled') {
64
+ task.status = 'completed'
65
+ task.result = result
66
+ task.finishedAt = Date.now()
67
+ this.publish(task)
68
+ }
69
+ } catch (error) {
70
+ if (this.tasks.find(item => item.id === task.id)?.status !== 'cancelled') {
71
+ task.status = 'failed'
72
+ task.error = error instanceof Error ? error.message : String(error)
73
+ task.finishedAt = Date.now()
74
+ this.publish(task)
75
+ }
76
+ } finally {
77
+ this.controllers.delete(task.id)
78
+ }
79
+ }
80
+ } finally {
81
+ this.running = false
82
+ }
83
+ }
84
+
85
+ private publish(task: GenerationTask): void {
86
+ const snapshot = this.snapshot(task)
87
+ for (const listener of this.listeners) {
88
+ try {
89
+ listener(snapshot)
90
+ } catch {
91
+ // Observers must not be able to interrupt the queue pump.
92
+ }
93
+ }
94
+ }
95
+
96
+ private snapshot(task: GenerationTask): GenerationTask {
97
+ return {
98
+ ...task,
99
+ request: { ...task.request },
100
+ ...task.result === undefined ? {} : { result: task.result },
101
+ }
102
+ }
103
+ }