@dickpy/dsh-imagegen 1.0.20 → 1.1.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/index.ts CHANGED
@@ -26,7 +26,7 @@ export const inject = ['webServer', 'systemPrompt']
26
26
  // contract only requires name / inject / Config / apply.
27
27
  export { makeRoutes } from './routes.ts'
28
28
  export { generateImage, ImageGenError } from './engine.ts'
29
- export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery } from './gallery-store.ts'
29
+ export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
30
30
  export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
31
31
  export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
32
32
 
@@ -43,6 +43,12 @@ export interface Config {
43
43
  apiUrl?: string
44
44
  /** Bearer API key (stored as a secret field on the settings seam). */
45
45
  apiKey?: string
46
+ /** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
47
+ promptApiUrl?: string
48
+ /** Optional secret for the prompt enhancement endpoint. */
49
+ promptApiKey?: string
50
+ /** Chat model used to expand short image prompts. */
51
+ promptModel?: string
46
52
  }
47
53
 
48
54
  export const Config: z<Config> = z.object({
@@ -50,6 +56,9 @@ export const Config: z<Config> = z.object({
50
56
  announceToAgent: z.boolean().default(true),
51
57
  apiUrl: z.string().default(''),
52
58
  apiKey: z.string().role('secret').default(''),
59
+ promptApiUrl: z.string().default(''),
60
+ promptApiKey: z.string().role('secret').default(''),
61
+ promptModel: z.string().default(''),
53
62
  })
54
63
 
55
64
  /** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
@@ -68,6 +77,9 @@ interface EffectiveConfig {
68
77
  announceToAgent: boolean
69
78
  apiUrl: string
70
79
  apiKey: string
80
+ promptApiUrl: string
81
+ promptApiKey: string
82
+ promptModel: string
71
83
  }
72
84
 
73
85
  /**
@@ -86,6 +98,9 @@ export function apply(ctx: Context, config?: Config): void {
86
98
  announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
87
99
  apiUrl: value.apiUrl ?? '',
88
100
  apiKey: value.apiKey ?? '',
101
+ promptApiUrl: value.promptApiUrl ?? '',
102
+ promptApiKey: value.promptApiKey ?? '',
103
+ promptModel: value.promptModel ?? '',
89
104
  }
90
105
  }
91
106
 
@@ -104,6 +119,14 @@ export function apply(ctx: Context, config?: Config): void {
104
119
  const value = resolve()
105
120
  return { apiUrl: value.apiUrl, apiKey: value.apiKey }
106
121
  },
122
+ resolvePrompt: () => {
123
+ const value = resolve()
124
+ return {
125
+ apiUrl: value.promptApiUrl.trim() || value.apiUrl,
126
+ apiKey: value.promptApiKey.trim() || value.apiKey,
127
+ model: value.promptModel,
128
+ }
129
+ },
107
130
  })
108
131
  const disposers = routes.map(route => ctx.webServer.register(route))
109
132
  return () => { for (const dispose of disposers) dispose() }
@@ -0,0 +1,67 @@
1
+ /** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
2
+
3
+ export interface PromptModelConfig {
4
+ apiUrl: string
5
+ apiKey: string
6
+ model: string
7
+ }
8
+
9
+ function endpoint(base: string, suffix: string): string {
10
+ return `${base.replace(/\/+$/, '')}${suffix}`
11
+ }
12
+
13
+ function headers(apiKey: string): HeadersInit {
14
+ return {
15
+ 'content-type': 'application/json',
16
+ ...apiKey.trim() === '' ? {} : { authorization: `Bearer ${apiKey.trim()}` },
17
+ }
18
+ }
19
+
20
+ async function responseJson(response: Response): Promise<Record<string, unknown>> {
21
+ const body: unknown = await response.json().catch(() => undefined)
22
+ if (!response.ok || body === undefined || body === null || typeof body !== 'object') {
23
+ const message = body !== null && typeof body === 'object' && typeof (body as { error?: { message?: unknown } }).error?.message === 'string'
24
+ ? (body as { error: { message: string } }).error.message
25
+ : `HTTP ${response.status}`
26
+ throw new Error(message)
27
+ }
28
+ return body as Record<string, unknown>
29
+ }
30
+
31
+ /** List chat models exposed by an OpenAI-compatible endpoint. */
32
+ export async function listPromptModels(config: PromptModelConfig): Promise<string[]> {
33
+ if (config.apiUrl.trim() === '') throw new Error('prompt enhancement API URL is required')
34
+ const response = await fetch(endpoint(config.apiUrl, '/models'), { headers: headers(config.apiKey) })
35
+ const body = await responseJson(response)
36
+ const data = Array.isArray(body.data) ? body.data : []
37
+ return data
38
+ .flatMap(item => item !== null && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? [(item as { id: string }).id] : [])
39
+ .sort((a, b) => a.localeCompare(b))
40
+ }
41
+
42
+ /** Expand a concise image request into a production-ready image prompt. */
43
+ export async function enhancePrompt(config: PromptModelConfig, prompt: string): Promise<string> {
44
+ if (config.apiUrl.trim() === '' || config.model.trim() === '') throw new Error('prompt enhancement model is not configured')
45
+ const response = await fetch(endpoint(config.apiUrl, '/chat/completions'), {
46
+ method: 'POST',
47
+ headers: headers(config.apiKey),
48
+ body: JSON.stringify({
49
+ model: config.model.trim(),
50
+ temperature: 0.7,
51
+ messages: [
52
+ {
53
+ role: 'system',
54
+ content: 'You are an expert image-prompt editor. Expand the user request into one vivid, specific image-generation prompt. Preserve intent and language. Add only useful visual detail: subject, composition, lighting, materials, color, camera/style and quality. Return only the finished prompt, with no preface or markdown.',
55
+ },
56
+ { role: 'user', content: prompt },
57
+ ],
58
+ }),
59
+ })
60
+ const body = await responseJson(response)
61
+ const choices = Array.isArray(body.choices) ? body.choices : []
62
+ const content = choices[0] !== null && typeof choices[0] === 'object'
63
+ ? (choices[0] as { message?: { content?: unknown } }).message?.content
64
+ : undefined
65
+ if (typeof content !== 'string' || content.trim() === '') throw new Error('chat model returned an empty prompt')
66
+ return content.trim()
67
+ }
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.0.20'
11
+ export const PLUGIN_VERSION = '1.1.0'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -19,6 +19,20 @@ export const SETTINGS_API = {
19
19
  /** The image-generation proxy route. */
20
20
  export const GENERATE_API = '/api/dsh-imagegen/generate'
21
21
 
22
+ /** Host-mediated OpenAI-compatible prompt enhancement endpoints. */
23
+ export const PROMPT_ENHANCE_API = {
24
+ models: '/api/dsh-imagegen/prompt-enhance/models',
25
+ enhance: '/api/dsh-imagegen/prompt-enhance',
26
+ } as const
27
+
28
+ /** Host-resident generation queue endpoints. */
29
+ export const TASK_API = {
30
+ submit: '/api/dsh-imagegen/tasks/submit',
31
+ list: '/api/dsh-imagegen/tasks/list',
32
+ cancel: '/api/dsh-imagegen/tasks/cancel',
33
+ retry: '/api/dsh-imagegen/tasks/retry',
34
+ } as const
35
+
22
36
  /** Host-mediated GitHub Release update routes. */
23
37
  export const UPDATE_API = {
24
38
  check: '/api/dsh-imagegen/update/check',
@@ -49,6 +63,7 @@ export const GALLERY_API = {
49
63
  append: '/api/dsh-imagegen/gallery/append',
50
64
  remove: '/api/dsh-imagegen/gallery/remove',
51
65
  clear: '/api/dsh-imagegen/gallery/clear',
66
+ tags: '/api/dsh-imagegen/gallery/tags',
52
67
  image: '/api/dsh-imagegen/gallery/image',
53
68
  } as const
54
69
 
@@ -122,7 +137,7 @@ export interface GenerateRequest {
122
137
  mode: GenerateMode
123
138
  /** Upstream model name, e.g. gpt-image-2. */
124
139
  model: string
125
- /** The prompt (up to 2000 chars in the UI). */
140
+ /** The prompt. Upstream providers may impose their own length limits. */
126
141
  prompt: string
127
142
  /** Canvas size as an aspect ratio: 'auto' or e.g. '1:1' / '16:9' / '21:9'.
128
143
  * The host maps it onto each model's own vocabulary (aspect_ratio for Grok,
@@ -165,6 +180,19 @@ export interface GenerateResult {
165
180
  historyError?: string
166
181
  }
167
182
 
183
+ export type GenerationTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
184
+
185
+ export interface GenerationTask {
186
+ id: string
187
+ request: GenerateRequest
188
+ status: GenerationTaskStatus
189
+ createdAt: number
190
+ startedAt?: number
191
+ finishedAt?: number
192
+ result?: GenerateResult
193
+ error?: string
194
+ }
195
+
168
196
  /** GitHub Release update information shown by the client. */
169
197
  export interface UpdateInfo {
170
198
  currentVersion: string
@@ -198,6 +226,8 @@ export interface HistoryEntry {
198
226
  images: HistoryImageRef[]
199
227
  /** Reference-image filename (edit mode), kept for display only. */
200
228
  refName?: string
229
+ /** User-managed gallery labels (unused by history entries). */
230
+ tags?: string[]
201
231
  }
202
232
 
203
233
  /** A history entry the client submits for persistence (images still carry base64). */
package/src/routes.ts CHANGED
@@ -10,11 +10,13 @@ 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
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'
13
15
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
14
- import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery } from './gallery-store.ts'
16
+ import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
15
17
  import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
16
18
  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'
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'
18
20
 
19
21
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
20
22
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -35,6 +37,8 @@ export interface ImageGenRoutesDeps {
35
37
  settings: SettingsSeam
36
38
  /** Resolve the current upstream config (composition entry + settings). */
37
39
  resolve: () => UpstreamConfig
40
+ /** Resolve the optional chat-model configuration for prompt enhancement. */
41
+ resolvePrompt?: () => PromptModelConfig
38
42
  /** Overrideable history backend, primarily for host integration tests. */
39
43
  history?: {
40
44
  list: () => Promise<HistoryEntry[]>
@@ -49,6 +53,7 @@ export interface ImageGenRoutesDeps {
49
53
  append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
50
54
  remove: (id: string) => Promise<HistoryEntry[]>
51
55
  clear: () => Promise<HistoryEntry[]>
56
+ updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
52
57
  readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
53
58
  }
54
59
  /** Overrideable template-library backend, primarily for host integration tests. */
@@ -112,6 +117,22 @@ function messageOf(error: unknown): string {
112
117
  return error instanceof Error ? error.message : String(error)
113
118
  }
114
119
 
120
+ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
121
+ const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
122
+ if (prompt === '') return undefined
123
+ return {
124
+ mode: body.mode === 'edit' ? 'edit' : 'text',
125
+ model: typeof body.model === 'string' ? body.model : 'gpt-image-2',
126
+ prompt,
127
+ size: typeof body.size === 'string' ? body.size : 'auto',
128
+ quality: typeof body.quality === 'string' ? body.quality : 'auto',
129
+ n: typeof body.n === 'number' ? body.n : 1,
130
+ detail: typeof body.detail === 'string' ? body.detail : '',
131
+ ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
132
+ ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
133
+ }
134
+ }
135
+
115
136
  /** Validate a submitted history entry (images carry base64). */
116
137
  function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
117
138
  const raw = body.entry
@@ -199,11 +220,12 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
199
220
  clear: clearHistory,
200
221
  readImage: readHistoryImage,
201
222
  }
202
- const gallery = deps.gallery ?? {
223
+ const gallery: NonNullable<ImageGenRoutesDeps['gallery']> = deps.gallery ?? {
203
224
  list: listGallery,
204
225
  append: appendGallery,
205
226
  remove: removeGallery,
206
227
  clear: clearGallery,
228
+ updateTags: updateGalleryTags,
207
229
  readImage: readGalleryImage,
208
230
  }
209
231
  const templates = deps.templates ?? {
@@ -211,6 +233,29 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
211
233
  refresh: refreshTemplates,
212
234
  readImage: readTemplateImage,
213
235
  }
236
+ 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
+ }
257
+ }
258
+ const taskQueue = new GenerationTaskQueue((request, signal) => runGeneration(request, signal))
214
259
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
215
260
  if (!isLoopbackRequest(req)) {
216
261
  writeJson(res, 403, { error: 'forbidden: loopback-only' })
@@ -224,6 +269,37 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
224
269
  }
225
270
 
226
271
  return [
272
+ // ----------------------------------------------- prompt enhancement
273
+ {
274
+ kind: 'exact',
275
+ path: PROMPT_ENHANCE_API.models,
276
+ handler: async (req, res) => {
277
+ if (!guard(req, res, 'POST')) return
278
+ try {
279
+ writeJson(res, 200, { ok: true, models: await listPromptModels(resolvePrompt()) })
280
+ } catch (error) {
281
+ writeJson(res, 200, { ok: false, code: 'prompt-models-failed', message: messageOf(error) })
282
+ }
283
+ },
284
+ },
285
+ {
286
+ kind: 'exact',
287
+ path: PROMPT_ENHANCE_API.enhance,
288
+ handler: async (req, res) => {
289
+ if (!guard(req, res, 'POST')) return
290
+ const body = await readJsonBody(req)
291
+ const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
292
+ if (prompt === '') {
293
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
294
+ return
295
+ }
296
+ try {
297
+ writeJson(res, 200, { ok: true, prompt: await enhancePrompt(resolvePrompt(), prompt) })
298
+ } catch (error) {
299
+ writeJson(res, 200, { ok: false, code: 'prompt-enhance-failed', message: messageOf(error) })
300
+ }
301
+ },
302
+ },
227
303
  // -------------------------------------------------- settings describe
228
304
  {
229
305
  kind: 'exact',
@@ -284,46 +360,13 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
284
360
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
285
361
  return
286
362
  }
287
- const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
288
- if (prompt === '') {
363
+ const request = parseGenerateRequest(body)
364
+ if (request === undefined) {
289
365
  writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
290
366
  return
291
367
  }
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
368
  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
- }
369
+ writeJson(res, 200, { ok: true, ...await runGeneration(request) })
327
370
  } catch (error) {
328
371
  const message = error instanceof Error ? error.message : String(error)
329
372
  const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
@@ -333,6 +376,41 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
333
376
  }
334
377
  },
335
378
  },
379
+ // ------------------------------------------------ generation task queue
380
+ {
381
+ kind: 'exact', path: TASK_API.submit,
382
+ handler: async (req, res) => {
383
+ if (!guard(req, res, 'POST')) return
384
+ const body = await readJsonBody(req)
385
+ const request = body === undefined ? undefined : parseGenerateRequest(body)
386
+ 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) })
388
+ },
389
+ },
390
+ {
391
+ 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() }) },
393
+ },
394
+ {
395
+ kind: 'exact', path: TASK_API.cancel,
396
+ handler: async (req, res) => {
397
+ if (!guard(req, res, 'POST')) return
398
+ const body = await readJsonBody(req)
399
+ const task = typeof body?.id === 'string' ? taskQueue.cancel(body.id) : undefined
400
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
401
+ writeJson(res, 200, { ok: true, task })
402
+ },
403
+ },
404
+ {
405
+ kind: 'exact', path: TASK_API.retry,
406
+ handler: async (req, res) => {
407
+ if (!guard(req, res, 'POST')) return
408
+ const body = await readJsonBody(req)
409
+ const task = typeof body?.id === 'string' ? taskQueue.retry(body.id) : undefined
410
+ if (task === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'task not found' }); return }
411
+ writeJson(res, 200, { ok: true, task })
412
+ },
413
+ },
336
414
  // ----------------------------------------------- update check
337
415
  {
338
416
  kind: 'exact',
@@ -528,6 +606,22 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
528
606
  }
529
607
  },
530
608
  },
609
+ // ----------------------------------------------------- gallery tags
610
+ {
611
+ kind: 'exact',
612
+ path: GALLERY_API.tags,
613
+ handler: async (req, res) => {
614
+ if (!guard(req, res, 'POST')) return
615
+ const body = await readJsonBody(req)
616
+ const id = typeof body?.id === 'string' ? body.id : ''
617
+ const tags = Array.isArray(body?.tags) ? body.tags.filter((tag): tag is string => typeof tag === 'string') : undefined
618
+ if (id === '' || tags === undefined || gallery.updateTags === undefined) {
619
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id and tags are required' })
620
+ return
621
+ }
622
+ 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) }) }
623
+ },
624
+ },
531
625
  // ---------------------------------------------------- gallery clear
532
626
  {
533
627
  kind: 'exact',
@@ -0,0 +1,70 @@
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 class GenerationTaskQueue {
7
+ private readonly tasks: GenerationTask[] = []
8
+ private readonly controllers = new Map<string, AbortController>()
9
+ private running = false
10
+
11
+ constructor(private readonly run: (request: GenerateRequest, signal: AbortSignal) => Promise<GenerateResult>) {}
12
+
13
+ list(): GenerationTask[] {
14
+ return this.tasks.map(task => ({ ...task, request: { ...task.request }, ...(task.result === undefined ? {} : { result: task.result }) }))
15
+ }
16
+
17
+ submit(request: GenerateRequest): GenerationTask {
18
+ const task: GenerationTask = { id: randomUUID(), request: { ...request }, status: 'queued', createdAt: Date.now() }
19
+ this.tasks.unshift(task)
20
+ void this.drain()
21
+ return task
22
+ }
23
+
24
+ cancel(id: string): GenerationTask | undefined {
25
+ const task = this.tasks.find(item => item.id === id)
26
+ if (task === undefined || task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') return task
27
+ task.status = 'cancelled'
28
+ task.finishedAt = Date.now()
29
+ this.controllers.get(id)?.abort()
30
+ return task
31
+ }
32
+
33
+ retry(id: string): GenerationTask | undefined {
34
+ const previous = this.tasks.find(item => item.id === id)
35
+ return previous === undefined ? undefined : this.submit(previous.request)
36
+ }
37
+
38
+ private async drain(): Promise<void> {
39
+ if (this.running) return
40
+ this.running = true
41
+ try {
42
+ for (;;) {
43
+ const task = this.tasks.find(item => item.status === 'queued')
44
+ if (task === undefined) return
45
+ task.status = 'running'
46
+ task.startedAt = Date.now()
47
+ const controller = new AbortController()
48
+ this.controllers.set(task.id, controller)
49
+ try {
50
+ const result = await this.run(task.request, controller.signal)
51
+ if (this.tasks.find(item => item.id === task.id)?.status !== 'cancelled') {
52
+ task.status = 'completed'
53
+ task.result = result
54
+ task.finishedAt = Date.now()
55
+ }
56
+ } catch (error) {
57
+ if (this.tasks.find(item => item.id === task.id)?.status !== 'cancelled') {
58
+ task.status = 'failed'
59
+ task.error = error instanceof Error ? error.message : String(error)
60
+ task.finishedAt = Date.now()
61
+ }
62
+ } finally {
63
+ this.controllers.delete(task.id)
64
+ }
65
+ }
66
+ } finally {
67
+ this.running = false
68
+ }
69
+ }
70
+ }