@dickpy/dsh-imagegen 1.0.19 → 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/README.md +25 -7
- package/docs/images/gallery-workspace.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1267 -460
- package/lib/client.js.map +1 -1
- package/lib/index.js +376 -56
- package/package.json +1 -1
- package/src/client/ImageGenPanel.tsx +306 -27
- package/src/client/SettingsCard.tsx +79 -0
- package/src/client/api.ts +37 -1
- package/src/client/locales.ts +110 -2
- package/src/client/panel.module.css +129 -1
- package/src/client/settings-card.module.css +42 -0
- package/src/client/settings-scope.ts +22 -1
- package/src/engine.ts +4 -3
- package/src/gallery-store.ts +15 -1
- package/src/index.ts +24 -1
- package/src/prompt-enhancer.ts +67 -0
- package/src/protocol.ts +32 -2
- package/src/routes.ts +133 -39
- package/src/task-queue.ts +70 -0
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
|
|
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
|
|
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
|
|
288
|
-
if (
|
|
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
|
-
|
|
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
|
+
}
|