@dickpy/dsh-imagegen 1.1.0 → 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,14 +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'
13
- import { enhancePrompt, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
14
- import { GenerationTaskQueue } from './task-queue.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'
15
16
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
16
17
  import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
17
18
  import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
18
19
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
19
- import { GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, 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'
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'
20
21
 
21
22
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
22
23
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -39,6 +40,8 @@ export interface ImageGenRoutesDeps {
39
40
  resolve: () => UpstreamConfig
40
41
  /** Resolve the optional chat-model configuration for prompt enhancement. */
41
42
  resolvePrompt?: () => PromptModelConfig
43
+ /** Models explicitly selected for this image API endpoint. */
44
+ resolveImageModels?: () => string[]
42
45
  /** Overrideable history backend, primarily for host integration tests. */
43
46
  history?: {
44
47
  list: () => Promise<HistoryEntry[]>
@@ -62,6 +65,8 @@ export interface ImageGenRoutesDeps {
62
65
  refresh: () => Promise<TemplateRefreshResult>
63
66
  readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
64
67
  }
68
+ /** Shared host queue, used by Agent tools and browser task endpoints. */
69
+ runtime?: ImageGenerationRuntime
65
70
  }
66
71
 
67
72
  /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
@@ -122,7 +127,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
122
127
  if (prompt === '') return undefined
123
128
  return {
124
129
  mode: body.mode === 'edit' ? 'edit' : 'text',
125
- model: typeof body.model === 'string' ? body.model : 'gpt-image-2',
130
+ model: typeof body.model === 'string' ? body.model : '',
126
131
  prompt,
127
132
  size: typeof body.size === 'string' ? body.size : 'auto',
128
133
  quality: typeof body.quality === 'string' ? body.quality : 'auto',
@@ -234,28 +239,16 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
234
239
  readImage: readTemplateImage,
235
240
  }
236
241
  const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
237
- const runGeneration = async (request: GenerateRequest, signal?: AbortSignal) => {
238
- const result = await generateImage(deps.resolve(), request, { signal })
239
- try {
240
- const entries = await history.append({
241
- id: randomUUID(),
242
- createdAt: Date.now(),
243
- mode: request.mode,
244
- model: request.model,
245
- prompt: request.prompt,
246
- size: request.size,
247
- quality: request.quality,
248
- detail: request.detail,
249
- n: request.n,
250
- images: result.images,
251
- ...request.refName === undefined ? {} : { refName: request.refName },
252
- })
253
- return { ...result, history: entries }
254
- } catch (error) {
255
- return { ...result, historyError: messageOf(error) }
256
- }
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 }
257
250
  }
258
- const taskQueue = new GenerationTaskQueue((request, signal) => runGeneration(request, signal))
251
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history)
259
252
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
260
253
  if (!isLoopbackRequest(req)) {
261
254
  writeJson(res, 403, { error: 'forbidden: loopback-only' })
@@ -269,6 +262,19 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
269
262
  }
270
263
 
271
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
+ },
272
278
  // ----------------------------------------------- prompt enhancement
273
279
  {
274
280
  kind: 'exact',
@@ -360,13 +366,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
360
366
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
361
367
  return
362
368
  }
363
- const request = parseGenerateRequest(body)
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 }
364
371
  if (request === undefined) {
365
372
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
366
373
  return
367
374
  }
368
375
  try {
369
- writeJson(res, 200, { ok: true, ...await runGeneration(request) })
376
+ writeJson(res, 200, { ok: true, ...await runtime.run(request) })
370
377
  } catch (error) {
371
378
  const message = error instanceof Error ? error.message : String(error)
372
379
  const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
@@ -382,21 +389,22 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
382
389
  handler: async (req, res) => {
383
390
  if (!guard(req, res, 'POST')) return
384
391
  const body = await readJsonBody(req)
385
- const request = body === undefined ? undefined : parseGenerateRequest(body)
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 }
386
394
  if (request === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
387
- writeJson(res, 200, { ok: true, task: taskQueue.submit(request) })
395
+ writeJson(res, 200, { ok: true, task: runtime.queue.submit(request) })
388
396
  },
389
397
  },
390
398
  {
391
399
  kind: 'exact', path: TASK_API.list,
392
- handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: taskQueue.list() }) },
400
+ handler: async (req, res) => { if (!guard(req, res, 'POST')) return; writeJson(res, 200, { ok: true, tasks: runtime.queue.list() }) },
393
401
  },
394
402
  {
395
403
  kind: 'exact', path: TASK_API.cancel,
396
404
  handler: async (req, res) => {
397
405
  if (!guard(req, res, 'POST')) return
398
406
  const body = await readJsonBody(req)
399
- const task = typeof body?.id === 'string' ? taskQueue.cancel(body.id) : undefined
407
+ const task = typeof body?.id === 'string' ? runtime.queue.cancel(body.id) : undefined
400
408
  if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
401
409
  writeJson(res, 200, { ok: true, task })
402
410
  },
@@ -406,7 +414,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
406
414
  handler: async (req, res) => {
407
415
  if (!guard(req, res, 'POST')) return
408
416
  const body = await readJsonBody(req)
409
- const task = typeof body?.id === 'string' ? taskQueue.retry(body.id) : undefined
417
+ const task = typeof body?.id === 'string' ? runtime.queue.retry(body.id) : undefined
410
418
  if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
411
419
  writeJson(res, 200, { ok: true, task })
412
420
  },
package/src/task-queue.ts CHANGED
@@ -3,22 +3,32 @@
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import type { GenerateRequest, GenerateResult, GenerationTask } from './protocol.ts'
5
5
 
6
+ export type GenerationTaskListener = (task: GenerationTask) => void
7
+
6
8
  export class GenerationTaskQueue {
7
9
  private readonly tasks: GenerationTask[] = []
8
10
  private readonly controllers = new Map<string, AbortController>()
11
+ private readonly listeners = new Set<GenerationTaskListener>()
9
12
  private running = false
10
13
 
11
14
  constructor(private readonly run: (request: GenerateRequest, signal: AbortSignal) => Promise<GenerateResult>) {}
12
15
 
13
16
  list(): GenerationTask[] {
14
- return this.tasks.map(task => ({ ...task, request: { ...task.request }, ...(task.result === undefined ? {} : { result: task.result }) }))
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) }
15
24
  }
16
25
 
17
26
  submit(request: GenerateRequest): GenerationTask {
18
27
  const task: GenerationTask = { id: randomUUID(), request: { ...request }, status: 'queued', createdAt: Date.now() }
19
28
  this.tasks.unshift(task)
29
+ this.publish(task)
20
30
  void this.drain()
21
- return task
31
+ return this.snapshot(task)
22
32
  }
23
33
 
24
34
  cancel(id: string): GenerationTask | undefined {
@@ -27,7 +37,8 @@ export class GenerationTaskQueue {
27
37
  task.status = 'cancelled'
28
38
  task.finishedAt = Date.now()
29
39
  this.controllers.get(id)?.abort()
30
- return task
40
+ this.publish(task)
41
+ return this.snapshot(task)
31
42
  }
32
43
 
33
44
  retry(id: string): GenerationTask | undefined {
@@ -44,6 +55,7 @@ export class GenerationTaskQueue {
44
55
  if (task === undefined) return
45
56
  task.status = 'running'
46
57
  task.startedAt = Date.now()
58
+ this.publish(task)
47
59
  const controller = new AbortController()
48
60
  this.controllers.set(task.id, controller)
49
61
  try {
@@ -52,12 +64,14 @@ export class GenerationTaskQueue {
52
64
  task.status = 'completed'
53
65
  task.result = result
54
66
  task.finishedAt = Date.now()
67
+ this.publish(task)
55
68
  }
56
69
  } catch (error) {
57
70
  if (this.tasks.find(item => item.id === task.id)?.status !== 'cancelled') {
58
71
  task.status = 'failed'
59
72
  task.error = error instanceof Error ? error.message : String(error)
60
73
  task.finishedAt = Date.now()
74
+ this.publish(task)
61
75
  }
62
76
  } finally {
63
77
  this.controllers.delete(task.id)
@@ -67,4 +81,23 @@ export class GenerationTaskQueue {
67
81
  this.running = false
68
82
  }
69
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
+ }
70
103
  }