@dickpy/dsh-imagegen 1.0.9 → 1.0.19
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 +19 -4
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +1151 -464
- package/lib/client.js.map +1 -1
- package/lib/index.js +441 -36
- package/package.json +3 -3
- package/src/client/ImageGenPanel.tsx +452 -62
- package/src/client/api.ts +38 -1
- package/src/client/locales.ts +71 -22
- package/src/client/mount.tsx +11 -6
- package/src/client/panel.module.css +209 -7
- package/src/engine.ts +78 -11
- package/src/gallery-store.ts +266 -0
- package/src/index.ts +2 -1
- package/src/protocol.ts +21 -5
- package/src/routes.ts +119 -1
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-persisted gallery (user-curated favorites): mirrors the history store
|
|
3
|
+
* (image files + an index.json under ~/.dsh/dsh-imagegen/gallery/) but with no
|
|
4
|
+
* size cap — every entry is an explicit user choice. Appends are deduplicated
|
|
5
|
+
* by image content so adding the same generated image twice is a no-op.
|
|
6
|
+
*
|
|
7
|
+
* Framework-free (node:fs + node:crypto only) so the route layer can drive it
|
|
8
|
+
* directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import type { GenerateMode, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
16
|
+
|
|
17
|
+
const HISTORY_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
18
|
+
const GALLERY_DIR = path.join(HISTORY_DIR, 'gallery')
|
|
19
|
+
const INDEX_PATH = path.join(GALLERY_DIR, 'index.json')
|
|
20
|
+
const IMAGES_DIR = path.join(GALLERY_DIR, 'images')
|
|
21
|
+
|
|
22
|
+
/** One gallery entry carries the same wire shape as a history entry. */
|
|
23
|
+
export interface GalleryAppendResult {
|
|
24
|
+
/** The full gallery list after the append (newest first). */
|
|
25
|
+
entries: HistoryEntry[]
|
|
26
|
+
/** Whether a new entry was added (false = a content-identical image was
|
|
27
|
+
* already in the gallery, so the append was skipped). */
|
|
28
|
+
added: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Gallery mutations read and replace one shared index. Serialize them so
|
|
32
|
+
// overlapping requests cannot each read an old index and lose the other's row.
|
|
33
|
+
let pendingMutation: Promise<void> = Promise.resolve()
|
|
34
|
+
|
|
35
|
+
function mutateGallery<T>(operation: () => Promise<T>): Promise<T> {
|
|
36
|
+
const next = pendingMutation.then(operation, operation)
|
|
37
|
+
pendingMutation = next.then(() => undefined, () => undefined)
|
|
38
|
+
return next
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One image's on-disk record (file name + mime, never base64). */
|
|
42
|
+
interface StoredImage {
|
|
43
|
+
file: string
|
|
44
|
+
mime: string
|
|
45
|
+
revisedPrompt?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One entry's on-disk record. `hash` fingerprints the first image so the
|
|
49
|
+
* store can refuse duplicate appends cheaply. */
|
|
50
|
+
interface StoredEntry {
|
|
51
|
+
id: string
|
|
52
|
+
createdAt: number
|
|
53
|
+
mode: GenerateMode
|
|
54
|
+
model: string
|
|
55
|
+
prompt: string
|
|
56
|
+
size: string
|
|
57
|
+
quality: string
|
|
58
|
+
detail: string
|
|
59
|
+
n: number
|
|
60
|
+
images: StoredImage[]
|
|
61
|
+
hash?: string
|
|
62
|
+
refName?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The index.json shape. */
|
|
66
|
+
interface IndexFile {
|
|
67
|
+
entries: StoredEntry[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** File extension for a MIME type (image file names). */
|
|
71
|
+
function extensionOf(mime: string): string {
|
|
72
|
+
switch (mime.split(';')[0]!.trim()) {
|
|
73
|
+
case 'image/jpeg': return 'jpg'
|
|
74
|
+
case 'image/webp': return 'webp'
|
|
75
|
+
case 'image/gif': return 'gif'
|
|
76
|
+
default: return 'png'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** MIME type for a stored image file name (image route responses). */
|
|
81
|
+
function mimeOfFile(file: string): string {
|
|
82
|
+
const ext = path.extname(file).toLowerCase()
|
|
83
|
+
switch (ext) {
|
|
84
|
+
case '.jpg':
|
|
85
|
+
case '.jpeg': return 'image/jpeg'
|
|
86
|
+
case '.webp': return 'image/webp'
|
|
87
|
+
case '.gif': return 'image/gif'
|
|
88
|
+
default: return 'image/png'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Sanitize an entry id for use as a file-name prefix. */
|
|
93
|
+
function safeId(id: string): string {
|
|
94
|
+
const cleaned = id.replace(/[^a-zA-Z0-9-]/g, '-')
|
|
95
|
+
return cleaned === '' ? 'entry' : cleaned
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Short content fingerprint of the entry's first image. */
|
|
99
|
+
function fingerprint(input: HistoryEntryInput): string | undefined {
|
|
100
|
+
const first = input.images[0]
|
|
101
|
+
if (first === undefined) return undefined
|
|
102
|
+
return createHash('sha1').update(first.b64).digest('hex')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Ensure the storage directories exist. */
|
|
106
|
+
async function ensureDirs(): Promise<void> {
|
|
107
|
+
await fs.mkdir(IMAGES_DIR, { recursive: true })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read the index, tolerating a missing/corrupt file. */
|
|
111
|
+
async function readIndex(): Promise<StoredEntry[]> {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fs.readFile(INDEX_PATH, 'utf8')
|
|
114
|
+
const parsed: unknown = JSON.parse(raw)
|
|
115
|
+
if (parsed === null || typeof parsed !== 'object') return []
|
|
116
|
+
const entries = (parsed as { entries?: unknown }).entries
|
|
117
|
+
if (!Array.isArray(entries)) return []
|
|
118
|
+
return entries.filter(isStoredEntry)
|
|
119
|
+
} catch {
|
|
120
|
+
return []
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Persist the index. */
|
|
125
|
+
async function writeIndex(entries: StoredEntry[]): Promise<void> {
|
|
126
|
+
await ensureDirs()
|
|
127
|
+
const payload: IndexFile = { entries }
|
|
128
|
+
const tmp = `${INDEX_PATH}.tmp-${process.pid}`
|
|
129
|
+
await fs.writeFile(tmp, JSON.stringify(payload), 'utf8')
|
|
130
|
+
await fs.rename(tmp, INDEX_PATH)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Structural guard for a stored entry. */
|
|
134
|
+
function isStoredEntry(value: unknown): value is StoredEntry {
|
|
135
|
+
if (value === null || typeof value !== 'object') return false
|
|
136
|
+
const entry = value as Record<string, unknown>
|
|
137
|
+
return typeof entry.id === 'string'
|
|
138
|
+
&& typeof entry.createdAt === 'number'
|
|
139
|
+
&& (entry.mode === 'text' || entry.mode === 'edit')
|
|
140
|
+
&& typeof entry.prompt === 'string'
|
|
141
|
+
&& Array.isArray(entry.images)
|
|
142
|
+
&& entry.images.every(image => {
|
|
143
|
+
if (image === null || typeof image !== 'object') return false
|
|
144
|
+
const record = image as Record<string, unknown>
|
|
145
|
+
return typeof record.file === 'string' && typeof record.mime === 'string'
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Remove one entry's image files (best effort). */
|
|
150
|
+
async function removeEntryFiles(entry: StoredEntry): Promise<void> {
|
|
151
|
+
for (const image of entry.images) {
|
|
152
|
+
try { await fs.rm(path.join(IMAGES_DIR, image.file), { force: true }) } catch { /* ignore */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Project a stored entry onto the wire shape (image URLs). */
|
|
157
|
+
function toWire(entry: StoredEntry): HistoryEntry {
|
|
158
|
+
return {
|
|
159
|
+
id: entry.id,
|
|
160
|
+
createdAt: entry.createdAt,
|
|
161
|
+
mode: entry.mode,
|
|
162
|
+
model: entry.model,
|
|
163
|
+
prompt: entry.prompt,
|
|
164
|
+
size: entry.size,
|
|
165
|
+
quality: entry.quality,
|
|
166
|
+
detail: entry.detail,
|
|
167
|
+
n: entry.n,
|
|
168
|
+
images: entry.images.map(image => ({
|
|
169
|
+
url: `/api/dsh-imagegen/gallery/image/${image.file}`,
|
|
170
|
+
mime: image.mime,
|
|
171
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
172
|
+
})),
|
|
173
|
+
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** List the persisted gallery, newest first, as wire entries. */
|
|
178
|
+
export async function listGallery(): Promise<HistoryEntry[]> {
|
|
179
|
+
const entries = await readIndex()
|
|
180
|
+
return entries.map(toWire)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Append one image to the gallery. Deduplicates by first-image content —
|
|
184
|
+
* appending an image already in the gallery returns `added: false` with the
|
|
185
|
+
* list unchanged. No size cap: every entry is an explicit user choice. */
|
|
186
|
+
export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAppendResult> {
|
|
187
|
+
return mutateGallery(async () => {
|
|
188
|
+
await ensureDirs()
|
|
189
|
+
const hash = fingerprint(input)
|
|
190
|
+
if (hash !== undefined) {
|
|
191
|
+
const existing = await readIndex()
|
|
192
|
+
if (existing.some(entry => entry.hash === hash)) {
|
|
193
|
+
return { entries: existing.map(toWire), added: false }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const prefix = safeId(input.id)
|
|
197
|
+
const storedImages: StoredImage[] = []
|
|
198
|
+
try {
|
|
199
|
+
for (let index = 0; index < input.images.length; index++) {
|
|
200
|
+
const image = input.images[index]!
|
|
201
|
+
const file = `${prefix}-${index}.${extensionOf(image.mime)}`
|
|
202
|
+
await fs.writeFile(path.join(IMAGES_DIR, file), Buffer.from(image.b64, 'base64'))
|
|
203
|
+
storedImages.push({
|
|
204
|
+
file,
|
|
205
|
+
mime: image.mime,
|
|
206
|
+
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
} catch (error) {
|
|
210
|
+
await removeEntryFiles({ images: storedImages } as StoredEntry)
|
|
211
|
+
throw error
|
|
212
|
+
}
|
|
213
|
+
const entry: StoredEntry = {
|
|
214
|
+
id: input.id,
|
|
215
|
+
createdAt: input.createdAt,
|
|
216
|
+
mode: input.mode,
|
|
217
|
+
model: input.model,
|
|
218
|
+
prompt: input.prompt,
|
|
219
|
+
size: input.size,
|
|
220
|
+
quality: input.quality,
|
|
221
|
+
detail: input.detail,
|
|
222
|
+
n: input.n,
|
|
223
|
+
images: storedImages,
|
|
224
|
+
...hash === undefined ? {} : { hash },
|
|
225
|
+
...input.refName === undefined ? {} : { refName: input.refName },
|
|
226
|
+
}
|
|
227
|
+
const merged = [entry, ...await readIndex()]
|
|
228
|
+
await writeIndex(merged)
|
|
229
|
+
return { entries: merged.map(toWire), added: true }
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Remove one entry (and its image files). */
|
|
234
|
+
export async function removeGallery(id: string): Promise<HistoryEntry[]> {
|
|
235
|
+
return mutateGallery(async () => {
|
|
236
|
+
const previous = await readIndex()
|
|
237
|
+
const target = previous.find(entry => entry.id === id)
|
|
238
|
+
if (target !== undefined) await removeEntryFiles(target)
|
|
239
|
+
const kept = previous.filter(entry => entry.id !== id)
|
|
240
|
+
await writeIndex(kept)
|
|
241
|
+
return kept.map(toWire)
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Remove every entry (and all image files). */
|
|
246
|
+
export async function clearGallery(): Promise<HistoryEntry[]> {
|
|
247
|
+
return mutateGallery(async () => {
|
|
248
|
+
const previous = await readIndex()
|
|
249
|
+
for (const entry of previous) await removeEntryFiles(entry)
|
|
250
|
+
await writeIndex([])
|
|
251
|
+
return []
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Read one stored image file by its (validated) file name. */
|
|
256
|
+
export async function readGalleryImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
257
|
+
// Only accept <id>-<index>.<png|jpg|jpeg|webp|gif> — the exact names this
|
|
258
|
+
// store writes — so the route can never escape the images directory.
|
|
259
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*-[0-9]+\.(png|jpg|jpeg|webp|gif)$/.test(file)) return undefined
|
|
260
|
+
try {
|
|
261
|
+
const data = await fs.readFile(path.join(IMAGES_DIR, file))
|
|
262
|
+
return { data, mime: mimeOfFile(file) }
|
|
263
|
+
} catch {
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -26,6 +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
30
|
export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
|
|
30
31
|
export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
|
|
31
32
|
|
|
@@ -59,7 +60,7 @@ const DEFAULT_ANNOUNCE = true
|
|
|
59
60
|
const SECTION_ORDER = 150
|
|
60
61
|
|
|
61
62
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
62
|
-
export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2),支持文生图(/images/generations)与图生图(/images/edits
|
|
63
|
+
export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API(模型 gpt-image-2 / grok-imagine-image),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok 模型按官方 JSON image_url 协议发送);API 地址与密钥在 GUI「设置 → 插件 → 可配置」中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载;可一键把满意的图片加入「画廊」(工作台顶部 文生图 / 图生图 / 画廊 三个标签页,画廊在右侧展示,收藏持久化在本地 ~/.dsh/dsh-imagegen/gallery/)。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条 gpt-image-2 提示词案例(含中文标题、分类、参考图,可搜索/按分类筛选),参考图经宿主代理按需缓存到本地;用户可一键把模板提示词填入提示词框再生成。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / gpt-image-2 / grok-imagine-image / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
|
|
63
64
|
|
|
64
65
|
/** Effective config (schema defaults applied). */
|
|
65
66
|
interface EffectiveConfig {
|
package/src/protocol.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wire contract shared by the host and client halves of dsh-imagegen: the
|
|
3
3
|
* settings namespace, the route paths, and the generate payload/result shapes.
|
|
4
|
-
* Pure types + constants
|
|
4
|
+
* Pure types + constants 鈥?safe for the client bundle to inline.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
/** Settings namespace this plugin owns (host settings seam + bridge). */
|
|
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.0.19'
|
|
12
12
|
|
|
13
13
|
/** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
|
|
14
14
|
export const SETTINGS_API = {
|
|
@@ -39,6 +39,19 @@ export const HISTORY_API = {
|
|
|
39
39
|
image: '/api/dsh-imagegen/history/image',
|
|
40
40
|
} as const
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Same-origin route family for the user-curated gallery (favorites). Entries
|
|
44
|
+
* reuse the history wire shape and persist under ~/.dsh/dsh-imagegen/gallery/;
|
|
45
|
+
* unlike history there is no size cap 鈥?the user adds images on purpose.
|
|
46
|
+
*/
|
|
47
|
+
export const GALLERY_API = {
|
|
48
|
+
list: '/api/dsh-imagegen/gallery/list',
|
|
49
|
+
append: '/api/dsh-imagegen/gallery/append',
|
|
50
|
+
remove: '/api/dsh-imagegen/gallery/remove',
|
|
51
|
+
clear: '/api/dsh-imagegen/gallery/clear',
|
|
52
|
+
image: '/api/dsh-imagegen/gallery/image',
|
|
53
|
+
} as const
|
|
54
|
+
|
|
42
55
|
/** Maximum number of history entries retained host-side (oldest evicted). */
|
|
43
56
|
export const HISTORY_MAX = 50
|
|
44
57
|
|
|
@@ -103,7 +116,7 @@ export interface TemplateRefreshResult {
|
|
|
103
116
|
/** Generation modes. */
|
|
104
117
|
export type GenerateMode = 'text' | 'edit'
|
|
105
118
|
|
|
106
|
-
/** A client
|
|
119
|
+
/** A client 鈫?host generate request (what the panel collects). */
|
|
107
120
|
export interface GenerateRequest {
|
|
108
121
|
/** text-to-image (images/generations) or image-to-image (images/edits). */
|
|
109
122
|
mode: GenerateMode
|
|
@@ -111,9 +124,12 @@ export interface GenerateRequest {
|
|
|
111
124
|
model: string
|
|
112
125
|
/** The prompt (up to 2000 chars in the UI). */
|
|
113
126
|
prompt: string
|
|
114
|
-
/** Canvas size: 'auto' or
|
|
127
|
+
/** Canvas size as an aspect ratio: 'auto' or e.g. '1:1' / '16:9' / '21:9'.
|
|
128
|
+
* The host maps it onto each model's own vocabulary (aspect_ratio for Grok,
|
|
129
|
+
* the closest pixel size for OpenAI-compatible endpoints). */
|
|
115
130
|
size: string
|
|
116
|
-
/**
|
|
131
|
+
/** Clarity tier: 'auto' | '1k' | '2k' | '4k'. The host maps it onto the
|
|
132
|
+
* model's own vocabulary (resolution for Grok, quality for OpenAI). */
|
|
117
133
|
quality: string
|
|
118
134
|
/** Number of images, 1-4. */
|
|
119
135
|
n: number
|
package/src/routes.ts
CHANGED
|
@@ -11,9 +11,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
13
|
import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
|
|
14
|
+
import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery } from './gallery-store.ts'
|
|
14
15
|
import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
|
|
15
16
|
import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
|
|
16
|
-
import { 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'
|
|
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'
|
|
17
18
|
|
|
18
19
|
/** Cap on JSON request bodies (settings ops and generate payloads are small). */
|
|
19
20
|
const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
|
|
@@ -42,6 +43,14 @@ export interface ImageGenRoutesDeps {
|
|
|
42
43
|
clear: () => Promise<HistoryEntry[]>
|
|
43
44
|
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
44
45
|
}
|
|
46
|
+
/** Overrideable gallery backend, primarily for host integration tests. */
|
|
47
|
+
gallery?: {
|
|
48
|
+
list: () => Promise<HistoryEntry[]>
|
|
49
|
+
append: (entry: HistoryEntryInput) => Promise<{ entries: HistoryEntry[]; added: boolean }>
|
|
50
|
+
remove: (id: string) => Promise<HistoryEntry[]>
|
|
51
|
+
clear: () => Promise<HistoryEntry[]>
|
|
52
|
+
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
53
|
+
}
|
|
45
54
|
/** Overrideable template-library backend, primarily for host integration tests. */
|
|
46
55
|
templates?: {
|
|
47
56
|
list: () => Promise<TemplateListResult>
|
|
@@ -190,6 +199,13 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
190
199
|
clear: clearHistory,
|
|
191
200
|
readImage: readHistoryImage,
|
|
192
201
|
}
|
|
202
|
+
const gallery = deps.gallery ?? {
|
|
203
|
+
list: listGallery,
|
|
204
|
+
append: appendGallery,
|
|
205
|
+
remove: removeGallery,
|
|
206
|
+
clear: clearGallery,
|
|
207
|
+
readImage: readGalleryImage,
|
|
208
|
+
}
|
|
193
209
|
const templates = deps.templates ?? {
|
|
194
210
|
list: listTemplates,
|
|
195
211
|
refresh: refreshTemplates,
|
|
@@ -454,6 +470,108 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
454
470
|
res.end(found.data)
|
|
455
471
|
},
|
|
456
472
|
},
|
|
473
|
+
// ----------------------------------------------------- gallery list
|
|
474
|
+
{
|
|
475
|
+
kind: 'exact',
|
|
476
|
+
path: GALLERY_API.list,
|
|
477
|
+
handler: async (req, res) => {
|
|
478
|
+
if (!guard(req, res, 'POST')) return
|
|
479
|
+
try {
|
|
480
|
+
writeJson(res, 200, { ok: true, entries: await gallery.list() })
|
|
481
|
+
} catch (error) {
|
|
482
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
// --------------------------------------------------- gallery append
|
|
487
|
+
{
|
|
488
|
+
kind: 'exact',
|
|
489
|
+
path: GALLERY_API.append,
|
|
490
|
+
handler: async (req, res) => {
|
|
491
|
+
if (!guard(req, res, 'POST')) return
|
|
492
|
+
const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
|
|
493
|
+
if (body === undefined) {
|
|
494
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const entry = parseHistoryEntryInput(body)
|
|
498
|
+
if (entry === undefined) {
|
|
499
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'malformed gallery entry' })
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
// The host owns gallery ids: a fresh id per append keeps retries and
|
|
504
|
+
// duplicate submissions from ever reusing a stale filename prefix.
|
|
505
|
+
const result = await gallery.append({ ...entry, id: randomUUID() })
|
|
506
|
+
writeJson(res, 200, { ok: true, entries: result.entries, added: result.added })
|
|
507
|
+
} catch (error) {
|
|
508
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
},
|
|
512
|
+
// --------------------------------------------------- gallery remove
|
|
513
|
+
{
|
|
514
|
+
kind: 'exact',
|
|
515
|
+
path: GALLERY_API.remove,
|
|
516
|
+
handler: async (req, res) => {
|
|
517
|
+
if (!guard(req, res, 'POST')) return
|
|
518
|
+
const body = await readJsonBody(req)
|
|
519
|
+
const id = body !== undefined && typeof body.id === 'string' ? body.id : ''
|
|
520
|
+
if (id === '') {
|
|
521
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'gallery id is required' })
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
writeJson(res, 200, { ok: true, entries: await gallery.remove(id) })
|
|
526
|
+
} catch (error) {
|
|
527
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
// ---------------------------------------------------- gallery clear
|
|
532
|
+
{
|
|
533
|
+
kind: 'exact',
|
|
534
|
+
path: GALLERY_API.clear,
|
|
535
|
+
handler: async (req, res) => {
|
|
536
|
+
if (!guard(req, res, 'POST')) return
|
|
537
|
+
try {
|
|
538
|
+
writeJson(res, 200, { ok: true, entries: await gallery.clear() })
|
|
539
|
+
} catch (error) {
|
|
540
|
+
writeJson(res, 200, { ok: false, code: 'gallery-failed', message: messageOf(error) })
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
// ------------------------------------------------ gallery image (prefix)
|
|
545
|
+
{
|
|
546
|
+
kind: 'prefix',
|
|
547
|
+
path: GALLERY_API.image,
|
|
548
|
+
handler: async (req, res) => {
|
|
549
|
+
if (!isLoopbackRequest(req)) {
|
|
550
|
+
writeJson(res, 403, { error: 'forbidden: loopback-only' })
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
if (req.method !== 'GET') {
|
|
554
|
+
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const file = imageFileFrom(req.url, GALLERY_API.image)
|
|
558
|
+
if (file === undefined) {
|
|
559
|
+
writeJson(res, 404, { error: 'not found' })
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const found = await gallery.readImage(file)
|
|
563
|
+
if (found === undefined) {
|
|
564
|
+
writeJson(res, 404, { error: 'not found' })
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
res.writeHead(200, {
|
|
568
|
+
'content-type': found.mime,
|
|
569
|
+
'content-length': found.data.length,
|
|
570
|
+
'cache-control': 'private, max-age=3600',
|
|
571
|
+
})
|
|
572
|
+
res.end(found.data)
|
|
573
|
+
},
|
|
574
|
+
},
|
|
457
575
|
// --------------------------------------------------- templates list
|
|
458
576
|
{
|
|
459
577
|
kind: 'exact',
|