@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/README.md +46 -13
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1677 -538
- package/lib/client.js.map +1 -1
- package/lib/index.js +877 -57
- package/package.json +4 -1
- package/src/agent-image-tools.ts +289 -0
- package/src/client/ImageGenPanel.tsx +333 -46
- package/src/client/SettingsCard.tsx +293 -23
- package/src/client/api.ts +37 -1
- package/src/client/locales.ts +150 -2
- package/src/client/panel.module.css +129 -1
- package/src/client/settings-card.module.css +222 -0
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-scope.ts +24 -1
- package/src/engine.ts +32 -4
- package/src/gallery-store.ts +15 -1
- package/src/generation-runtime.ts +48 -0
- package/src/image-models.ts +19 -0
- package/src/index.ts +72 -3
- package/src/prompt-enhancer.ts +79 -0
- package/src/protocol.ts +37 -2
- package/src/routes.ts +142 -40
- package/src/task-queue.ts +103 -0
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 {
|
|
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
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
+
}
|