@dickpy/dsh-imagegen 1.3.0 → 1.4.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/LICENSE +201 -201
- package/README.md +203 -182
- package/cordis.patch.yml +8 -8
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1103 -837
- package/lib/client.js.map +1 -1
- package/lib/index.js +265 -135
- package/package.json +70 -68
- package/src/agent-image-tools.ts +418 -418
- package/src/client/ImageGenPanel.tsx +1699 -1508
- package/src/client/SettingsCard.tsx +936 -957
- package/src/client/TemplateLibrary.tsx +336 -336
- package/src/client/api.ts +193 -193
- package/src/client/channels-form.ts +263 -263
- package/src/client/controller.ts +46 -46
- package/src/client/conversation-sync.ts +14 -0
- package/src/client/css-modules.d.ts +5 -5
- package/src/client/helpers.ts +33 -33
- package/src/client/image-toolview.module.css +73 -73
- package/src/client/image-toolview.tsx +169 -158
- package/src/client/index.ts +32 -22
- package/src/client/locales.ts +610 -594
- package/src/client/mount.tsx +185 -96
- package/src/client/panel.module.css +1713 -1445
- package/src/client/settings-card.module.css +1023 -1023
- package/src/client/settings-form.ts +336 -336
- package/src/client/settings-scope.ts +298 -298
- package/src/client/sidebar-entry.ts +148 -102
- package/src/client/templates.module.css +453 -453
- package/src/engine.ts +520 -478
- package/src/gallery-store.ts +286 -286
- package/src/generation-runtime.ts +79 -75
- package/src/history-store.ts +250 -244
- package/src/image-format.ts +11 -11
- package/src/image-models.ts +19 -19
- package/src/index.ts +318 -318
- package/src/model-catalog.ts +115 -98
- package/src/presets.ts +71 -63
- package/src/prompt-enhancer.ts +137 -79
- package/src/protocol.ts +338 -326
- package/src/routes.ts +916 -906
- package/src/task-queue.ts +113 -103
- package/src/templates/cases.json +10196 -10196
- package/src/templates-store.ts +278 -278
- package/src/updater.ts +117 -117
package/src/templates-store.ts
CHANGED
|
@@ -1,278 +1,278 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Prompt-template library store (awesome-gpt-image-2 mirror).
|
|
3
|
-
*
|
|
4
|
-
* The case list ships as a bundled snapshot (src/templates/cases.json, inside
|
|
5
|
-
* the npm package) so the library works offline out of the box; a successful
|
|
6
|
-
* manual refresh writes a runtime copy under ~/.dsh/dsh-imagegen/templates/
|
|
7
|
-
* which then takes precedence. Reference images are not bundled (441 files,
|
|
8
|
-
* ≈100 MB) — they are fetched from the vibeui.top mirror on demand, cached on
|
|
9
|
-
* disk under ~/.dsh/dsh-imagegen/template-images/, and served from there on
|
|
10
|
-
* every later view.
|
|
11
|
-
*
|
|
12
|
-
* Framework-free (node:fs only) so the route layer and tests can drive it
|
|
13
|
-
* directly.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { promises as fs } from 'node:fs'
|
|
17
|
-
import { homedir } from 'node:os'
|
|
18
|
-
import path from 'node:path'
|
|
19
|
-
import { fileURLToPath } from 'node:url'
|
|
20
|
-
import type { TemplateCase, TemplateListResult, TemplateRefreshResult } from './protocol.ts'
|
|
21
|
-
|
|
22
|
-
/** Upstream mirror the library refreshes from (vibeui.top static mirror). */
|
|
23
|
-
const SOURCE_URL = 'https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json'
|
|
24
|
-
|
|
25
|
-
/** Remote image directory (file names come from the case list). */
|
|
26
|
-
const IMAGE_BASE_URL = 'https://vibeui.top/extra/awesome-gpt-image-2/data/images/'
|
|
27
|
-
|
|
28
|
-
/** Category label map mirrored from vibeui.top's site.js (zh display names). */
|
|
29
|
-
const CATEGORY_ZH: Record<string, string> = {
|
|
30
|
-
'Architecture & Spaces': '建筑与空间',
|
|
31
|
-
'Brand & Logos': '品牌与标志',
|
|
32
|
-
'Characters & People': '人物与角色',
|
|
33
|
-
'Charts & Infographics': '图表与信息可视化',
|
|
34
|
-
'Documents & Publishing': '文档与出版物',
|
|
35
|
-
'History & Classical Themes': '历史与古风题材',
|
|
36
|
-
'Illustration & Art': '插画与艺术',
|
|
37
|
-
'Other Use Cases': '其他应用场景',
|
|
38
|
-
'Photography & Realism': '摄影与写实',
|
|
39
|
-
'Posters & Typography': '海报与排版',
|
|
40
|
-
'Products & E-commerce': '商品与电商',
|
|
41
|
-
'Scenes & Storytelling': '场景与叙事',
|
|
42
|
-
'UI & Interfaces': 'UI 与界面',
|
|
43
|
-
'Portraits & Fashion': '人像与时尚',
|
|
44
|
-
'Celebrities & Sports': '名人与运动',
|
|
45
|
-
'Characters & IP': '角色与 IP',
|
|
46
|
-
'Food & Beverage': '美食与饮品',
|
|
47
|
-
'Brand & Icons': '品牌与图标',
|
|
48
|
-
'Social Media & Stickers': '社媒与表情包',
|
|
49
|
-
'Infographics & Diagrams': '信息图与图解',
|
|
50
|
-
'UI & App Screens': 'UI 与应用界面',
|
|
51
|
-
'Architecture & Interiors': '建筑与室内',
|
|
52
|
-
'Cinematic & Storytelling': '影视与叙事',
|
|
53
|
-
'Illustration & Comics': '插画与漫画',
|
|
54
|
-
'Historical & Fantasy': '历史与幻想',
|
|
55
|
-
'Animals & Nature': '动物与自然',
|
|
56
|
-
'Other Creative Uses': '其他创意用途',
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const DATA_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
60
|
-
const REFRESHED_CASES_PATH = path.join(DATA_DIR, 'templates', 'cases.json')
|
|
61
|
-
const IMAGE_CACHE_DIR = path.join(DATA_DIR, 'template-images')
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Bundled snapshot path. The host bundle emits to lib/index.js while this
|
|
65
|
-
* source file lives at src/templates-store.ts — both exactly one level below
|
|
66
|
-
* the package root — so `../src/templates/cases.json` resolves to the shipped
|
|
67
|
-
* snapshot in development and in the installed package alike.
|
|
68
|
-
*/
|
|
69
|
-
const BUNDLED_CASES_PATH = fileURLToPath(new URL('../src/templates/cases.json', import.meta.url))
|
|
70
|
-
|
|
71
|
-
/** Budget for one upstream fetch (list refresh or one image). */
|
|
72
|
-
const FETCH_TIMEOUT_MS = 60_000
|
|
73
|
-
|
|
74
|
-
/** Refuse to cache implausibly large "images". */
|
|
75
|
-
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
76
|
-
|
|
77
|
-
/** Strict reference-image file names this store writes and serves. */
|
|
78
|
-
const IMAGE_FILE_PATTERN = /^case\d+\.(jpg|jpeg|png|webp|gif)$/i
|
|
79
|
-
|
|
80
|
-
/** The on-disk / wire shape of the case-list snapshot. */
|
|
81
|
-
interface CasesSnapshot {
|
|
82
|
-
repository?: unknown
|
|
83
|
-
fetchedAt?: unknown
|
|
84
|
-
cases?: unknown
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** In-memory memo of the active list (avoid re-parsing on every request). */
|
|
88
|
-
let memo: TemplateListResult | undefined
|
|
89
|
-
|
|
90
|
-
/** Per-file in-flight downloads, so a gallery scroll never double-fetches. */
|
|
91
|
-
const inflightImages = new Map<string, Promise<{ data: Buffer; mime: string } | undefined>>()
|
|
92
|
-
|
|
93
|
-
/** Validate + normalize one raw upstream case; undefined when unusable. */
|
|
94
|
-
function normalizeCase(raw: unknown): TemplateCase | undefined {
|
|
95
|
-
if (raw === null || typeof raw !== 'object') return undefined
|
|
96
|
-
const record = raw as Record<string, unknown>
|
|
97
|
-
const id = Number(record.id)
|
|
98
|
-
const title = typeof record.title === 'string' ? record.title.trim() : ''
|
|
99
|
-
const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : ''
|
|
100
|
-
if (!Number.isInteger(id) || title === '' || prompt === '') return undefined
|
|
101
|
-
const category = typeof record.category === 'string' ? record.category : ''
|
|
102
|
-
const rawImage = typeof record.image === 'string' ? record.image : ''
|
|
103
|
-
const image = imageFileOf(rawImage)
|
|
104
|
-
return {
|
|
105
|
-
id,
|
|
106
|
-
title,
|
|
107
|
-
prompt,
|
|
108
|
-
category,
|
|
109
|
-
categoryZh: CATEGORY_ZH[category] ?? category,
|
|
110
|
-
styles: Array.isArray(record.styles) ? record.styles.map(String) : [],
|
|
111
|
-
scenes: Array.isArray(record.scenes) ? record.scenes.map(String) : [],
|
|
112
|
-
sourceLabel: typeof record.sourceLabel === 'string' ? record.sourceLabel : '',
|
|
113
|
-
sourceUrl: typeof record.sourceUrl === 'string' ? record.sourceUrl : '',
|
|
114
|
-
githubUrl: typeof record.githubUrl === 'string' ? record.githubUrl : '',
|
|
115
|
-
image,
|
|
116
|
-
featured: record.featured === true,
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Extract the bare file name from an upstream image path. */
|
|
121
|
-
function imageFileOf(value: string): string {
|
|
122
|
-
const name = value.replace(/^\/+/, '').split('/').pop() ?? ''
|
|
123
|
-
return IMAGE_FILE_PATTERN.test(name) ? name : ''
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Parse a snapshot payload (bundled, refreshed cache, or fresh download). */
|
|
127
|
-
function parseSnapshot(payload: unknown): { cases: TemplateCase[]; repository: string; fetchedAt: string } | undefined {
|
|
128
|
-
if (payload === null || typeof payload !== 'object') return undefined
|
|
129
|
-
const snapshot = payload as CasesSnapshot
|
|
130
|
-
if (!Array.isArray(snapshot.cases)) return undefined
|
|
131
|
-
const cases: TemplateCase[] = []
|
|
132
|
-
for (const raw of snapshot.cases) {
|
|
133
|
-
const normalized = normalizeCase(raw)
|
|
134
|
-
if (normalized !== undefined) cases.push(normalized)
|
|
135
|
-
}
|
|
136
|
-
if (cases.length === 0) return undefined
|
|
137
|
-
cases.sort((a, b) => b.id - a.id)
|
|
138
|
-
return {
|
|
139
|
-
cases,
|
|
140
|
-
repository: typeof snapshot.repository === 'string' && snapshot.repository !== '' ? snapshot.repository : 'freestylefly/awesome-gpt-image-2',
|
|
141
|
-
fetchedAt: typeof snapshot.fetchedAt === 'string' && snapshot.fetchedAt !== '' ? snapshot.fetchedAt : '',
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Read + parse a snapshot file; undefined when missing/corrupt. */
|
|
146
|
-
async function readSnapshotFile(file: string): Promise<ReturnType<typeof parseSnapshot>> {
|
|
147
|
-
try {
|
|
148
|
-
return parseSnapshot(JSON.parse(await fs.readFile(file, 'utf8')))
|
|
149
|
-
} catch {
|
|
150
|
-
return undefined
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* The active template list: the refreshed runtime copy wins, the bundled
|
|
156
|
-
* snapshot is the always-available fallback. Memoized; a successful refresh
|
|
157
|
-
* replaces the memo.
|
|
158
|
-
*/
|
|
159
|
-
export async function listTemplates(): Promise<TemplateListResult> {
|
|
160
|
-
if (memo !== undefined) return memo
|
|
161
|
-
const refreshed = await readSnapshotFile(REFRESHED_CASES_PATH)
|
|
162
|
-
if (refreshed !== undefined) {
|
|
163
|
-
memo = { ...refreshed, total: refreshed.cases.length, origin: 'refreshed' }
|
|
164
|
-
return memo
|
|
165
|
-
}
|
|
166
|
-
const bundled = await readSnapshotFile(BUNDLED_CASES_PATH)
|
|
167
|
-
if (bundled !== undefined) {
|
|
168
|
-
memo = { ...bundled, total: bundled.cases.length, origin: 'bundled' }
|
|
169
|
-
return memo
|
|
170
|
-
}
|
|
171
|
-
memo = { cases: [], total: 0, origin: 'bundled', repository: 'freestylefly/awesome-gpt-image-2', fetchedAt: '' }
|
|
172
|
-
return memo
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Re-download the case list from the upstream mirror and persist it as the
|
|
177
|
-
* runtime copy. Throws with a user-presentable message on failure; the
|
|
178
|
-
* previous list (refreshed or bundled) stays active.
|
|
179
|
-
*/
|
|
180
|
-
export async function refreshTemplates(): Promise<TemplateRefreshResult> {
|
|
181
|
-
let response: Response
|
|
182
|
-
try {
|
|
183
|
-
response = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
184
|
-
} catch (error) {
|
|
185
|
-
throw new Error(`无法连接模板库源站:${error instanceof Error ? error.message : String(error)}`)
|
|
186
|
-
}
|
|
187
|
-
if (!response.ok) throw new Error(`模板库源站拒绝请求(HTTP ${response.status})`)
|
|
188
|
-
let payload: unknown
|
|
189
|
-
try {
|
|
190
|
-
payload = await response.json()
|
|
191
|
-
} catch {
|
|
192
|
-
throw new Error('模板库源站返回了非 JSON 响应')
|
|
193
|
-
}
|
|
194
|
-
const parsed = parseSnapshot(payload)
|
|
195
|
-
if (parsed === undefined) throw new Error('模板库源站数据格式无效')
|
|
196
|
-
const fetchedAt = new Date().toISOString()
|
|
197
|
-
const snapshot = {
|
|
198
|
-
repository: parsed.repository,
|
|
199
|
-
sourceUrl: SOURCE_URL,
|
|
200
|
-
fetchedAt,
|
|
201
|
-
totalCases: parsed.cases.length,
|
|
202
|
-
cases: parsed.cases,
|
|
203
|
-
}
|
|
204
|
-
await fs.mkdir(path.dirname(REFRESHED_CASES_PATH), { recursive: true })
|
|
205
|
-
const tmp = `${REFRESHED_CASES_PATH}.tmp-${process.pid}`
|
|
206
|
-
await fs.writeFile(tmp, JSON.stringify(snapshot), 'utf8')
|
|
207
|
-
await fs.rename(tmp, REFRESHED_CASES_PATH)
|
|
208
|
-
memo = { cases: parsed.cases, total: parsed.cases.length, origin: 'refreshed', repository: parsed.repository, fetchedAt }
|
|
209
|
-
return { total: parsed.cases.length, fetchedAt }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** MIME type for a cached reference-image file name. */
|
|
213
|
-
function mimeOfFile(file: string): string {
|
|
214
|
-
switch (path.extname(file).toLowerCase()) {
|
|
215
|
-
case '.jpg':
|
|
216
|
-
case '.jpeg': return 'image/jpeg'
|
|
217
|
-
case '.webp': return 'image/webp'
|
|
218
|
-
case '.gif': return 'image/gif'
|
|
219
|
-
default: return 'image/png'
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** Download one reference image into the disk cache; undefined on failure. */
|
|
224
|
-
async function fetchTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
225
|
-
let response: Response
|
|
226
|
-
try {
|
|
227
|
-
response = await fetch(`${IMAGE_BASE_URL}${encodeURIComponent(file)}`, {
|
|
228
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
229
|
-
})
|
|
230
|
-
} catch {
|
|
231
|
-
return undefined
|
|
232
|
-
}
|
|
233
|
-
if (!response.ok) return undefined
|
|
234
|
-
const declared = Number(response.headers.get('content-length') ?? 0)
|
|
235
|
-
if (declared > MAX_IMAGE_BYTES) return undefined
|
|
236
|
-
const data = Buffer.from(await response.arrayBuffer())
|
|
237
|
-
if (data.byteLength === 0 || data.byteLength > MAX_IMAGE_BYTES) return undefined
|
|
238
|
-
const mime = mimeOfFile(file)
|
|
239
|
-
try {
|
|
240
|
-
await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true })
|
|
241
|
-
const tmp = path.join(IMAGE_CACHE_DIR, `${file}.tmp-${process.pid}`)
|
|
242
|
-
await fs.writeFile(tmp, data)
|
|
243
|
-
await fs.rename(tmp, path.join(IMAGE_CACHE_DIR, file))
|
|
244
|
-
} catch {
|
|
245
|
-
// A cache-write failure must not lose the already-fetched bytes.
|
|
246
|
-
}
|
|
247
|
-
return { data, mime }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Read one reference image for the template library. Cache hit → disk; miss →
|
|
252
|
-
* fetch from the upstream mirror, cache, and serve. Only file names present in
|
|
253
|
-
* the active case list are served, so the route can never act as an open
|
|
254
|
-
* proxy. Undefined when the name is unknown or the fetch failed.
|
|
255
|
-
*/
|
|
256
|
-
export async function readTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
257
|
-
if (!IMAGE_FILE_PATTERN.test(file) || file.includes('..')) return undefined
|
|
258
|
-
const list = await listTemplates()
|
|
259
|
-
if (!list.cases.some(entry => entry.image === file)) return undefined
|
|
260
|
-
const cached = path.join(IMAGE_CACHE_DIR, file)
|
|
261
|
-
try {
|
|
262
|
-
return { data: await fs.readFile(cached), mime: mimeOfFile(file) }
|
|
263
|
-
} catch { /* fall through to download */ }
|
|
264
|
-
const inflight = inflightImages.get(file)
|
|
265
|
-
if (inflight !== undefined) return inflight
|
|
266
|
-
const pending = fetchTemplateImage(file)
|
|
267
|
-
inflightImages.set(file, pending)
|
|
268
|
-
try {
|
|
269
|
-
return await pending
|
|
270
|
-
} finally {
|
|
271
|
-
inflightImages.delete(file)
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Drop the in-memory list memo (tests). */
|
|
276
|
-
export function clearTemplateMemo(): void {
|
|
277
|
-
memo = undefined
|
|
278
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Prompt-template library store (awesome-gpt-image-2 mirror).
|
|
3
|
+
*
|
|
4
|
+
* The case list ships as a bundled snapshot (src/templates/cases.json, inside
|
|
5
|
+
* the npm package) so the library works offline out of the box; a successful
|
|
6
|
+
* manual refresh writes a runtime copy under ~/.dsh/dsh-imagegen/templates/
|
|
7
|
+
* which then takes precedence. Reference images are not bundled (441 files,
|
|
8
|
+
* ≈100 MB) — they are fetched from the vibeui.top mirror on demand, cached on
|
|
9
|
+
* disk under ~/.dsh/dsh-imagegen/template-images/, and served from there on
|
|
10
|
+
* every later view.
|
|
11
|
+
*
|
|
12
|
+
* Framework-free (node:fs only) so the route layer and tests can drive it
|
|
13
|
+
* directly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import type { TemplateCase, TemplateListResult, TemplateRefreshResult } from './protocol.ts'
|
|
21
|
+
|
|
22
|
+
/** Upstream mirror the library refreshes from (vibeui.top static mirror). */
|
|
23
|
+
const SOURCE_URL = 'https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json'
|
|
24
|
+
|
|
25
|
+
/** Remote image directory (file names come from the case list). */
|
|
26
|
+
const IMAGE_BASE_URL = 'https://vibeui.top/extra/awesome-gpt-image-2/data/images/'
|
|
27
|
+
|
|
28
|
+
/** Category label map mirrored from vibeui.top's site.js (zh display names). */
|
|
29
|
+
const CATEGORY_ZH: Record<string, string> = {
|
|
30
|
+
'Architecture & Spaces': '建筑与空间',
|
|
31
|
+
'Brand & Logos': '品牌与标志',
|
|
32
|
+
'Characters & People': '人物与角色',
|
|
33
|
+
'Charts & Infographics': '图表与信息可视化',
|
|
34
|
+
'Documents & Publishing': '文档与出版物',
|
|
35
|
+
'History & Classical Themes': '历史与古风题材',
|
|
36
|
+
'Illustration & Art': '插画与艺术',
|
|
37
|
+
'Other Use Cases': '其他应用场景',
|
|
38
|
+
'Photography & Realism': '摄影与写实',
|
|
39
|
+
'Posters & Typography': '海报与排版',
|
|
40
|
+
'Products & E-commerce': '商品与电商',
|
|
41
|
+
'Scenes & Storytelling': '场景与叙事',
|
|
42
|
+
'UI & Interfaces': 'UI 与界面',
|
|
43
|
+
'Portraits & Fashion': '人像与时尚',
|
|
44
|
+
'Celebrities & Sports': '名人与运动',
|
|
45
|
+
'Characters & IP': '角色与 IP',
|
|
46
|
+
'Food & Beverage': '美食与饮品',
|
|
47
|
+
'Brand & Icons': '品牌与图标',
|
|
48
|
+
'Social Media & Stickers': '社媒与表情包',
|
|
49
|
+
'Infographics & Diagrams': '信息图与图解',
|
|
50
|
+
'UI & App Screens': 'UI 与应用界面',
|
|
51
|
+
'Architecture & Interiors': '建筑与室内',
|
|
52
|
+
'Cinematic & Storytelling': '影视与叙事',
|
|
53
|
+
'Illustration & Comics': '插画与漫画',
|
|
54
|
+
'Historical & Fantasy': '历史与幻想',
|
|
55
|
+
'Animals & Nature': '动物与自然',
|
|
56
|
+
'Other Creative Uses': '其他创意用途',
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DATA_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
60
|
+
const REFRESHED_CASES_PATH = path.join(DATA_DIR, 'templates', 'cases.json')
|
|
61
|
+
const IMAGE_CACHE_DIR = path.join(DATA_DIR, 'template-images')
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bundled snapshot path. The host bundle emits to lib/index.js while this
|
|
65
|
+
* source file lives at src/templates-store.ts — both exactly one level below
|
|
66
|
+
* the package root — so `../src/templates/cases.json` resolves to the shipped
|
|
67
|
+
* snapshot in development and in the installed package alike.
|
|
68
|
+
*/
|
|
69
|
+
const BUNDLED_CASES_PATH = fileURLToPath(new URL('../src/templates/cases.json', import.meta.url))
|
|
70
|
+
|
|
71
|
+
/** Budget for one upstream fetch (list refresh or one image). */
|
|
72
|
+
const FETCH_TIMEOUT_MS = 60_000
|
|
73
|
+
|
|
74
|
+
/** Refuse to cache implausibly large "images". */
|
|
75
|
+
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
76
|
+
|
|
77
|
+
/** Strict reference-image file names this store writes and serves. */
|
|
78
|
+
const IMAGE_FILE_PATTERN = /^case\d+\.(jpg|jpeg|png|webp|gif)$/i
|
|
79
|
+
|
|
80
|
+
/** The on-disk / wire shape of the case-list snapshot. */
|
|
81
|
+
interface CasesSnapshot {
|
|
82
|
+
repository?: unknown
|
|
83
|
+
fetchedAt?: unknown
|
|
84
|
+
cases?: unknown
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** In-memory memo of the active list (avoid re-parsing on every request). */
|
|
88
|
+
let memo: TemplateListResult | undefined
|
|
89
|
+
|
|
90
|
+
/** Per-file in-flight downloads, so a gallery scroll never double-fetches. */
|
|
91
|
+
const inflightImages = new Map<string, Promise<{ data: Buffer; mime: string } | undefined>>()
|
|
92
|
+
|
|
93
|
+
/** Validate + normalize one raw upstream case; undefined when unusable. */
|
|
94
|
+
function normalizeCase(raw: unknown): TemplateCase | undefined {
|
|
95
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
96
|
+
const record = raw as Record<string, unknown>
|
|
97
|
+
const id = Number(record.id)
|
|
98
|
+
const title = typeof record.title === 'string' ? record.title.trim() : ''
|
|
99
|
+
const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : ''
|
|
100
|
+
if (!Number.isInteger(id) || title === '' || prompt === '') return undefined
|
|
101
|
+
const category = typeof record.category === 'string' ? record.category : ''
|
|
102
|
+
const rawImage = typeof record.image === 'string' ? record.image : ''
|
|
103
|
+
const image = imageFileOf(rawImage)
|
|
104
|
+
return {
|
|
105
|
+
id,
|
|
106
|
+
title,
|
|
107
|
+
prompt,
|
|
108
|
+
category,
|
|
109
|
+
categoryZh: CATEGORY_ZH[category] ?? category,
|
|
110
|
+
styles: Array.isArray(record.styles) ? record.styles.map(String) : [],
|
|
111
|
+
scenes: Array.isArray(record.scenes) ? record.scenes.map(String) : [],
|
|
112
|
+
sourceLabel: typeof record.sourceLabel === 'string' ? record.sourceLabel : '',
|
|
113
|
+
sourceUrl: typeof record.sourceUrl === 'string' ? record.sourceUrl : '',
|
|
114
|
+
githubUrl: typeof record.githubUrl === 'string' ? record.githubUrl : '',
|
|
115
|
+
image,
|
|
116
|
+
featured: record.featured === true,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Extract the bare file name from an upstream image path. */
|
|
121
|
+
function imageFileOf(value: string): string {
|
|
122
|
+
const name = value.replace(/^\/+/, '').split('/').pop() ?? ''
|
|
123
|
+
return IMAGE_FILE_PATTERN.test(name) ? name : ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse a snapshot payload (bundled, refreshed cache, or fresh download). */
|
|
127
|
+
function parseSnapshot(payload: unknown): { cases: TemplateCase[]; repository: string; fetchedAt: string } | undefined {
|
|
128
|
+
if (payload === null || typeof payload !== 'object') return undefined
|
|
129
|
+
const snapshot = payload as CasesSnapshot
|
|
130
|
+
if (!Array.isArray(snapshot.cases)) return undefined
|
|
131
|
+
const cases: TemplateCase[] = []
|
|
132
|
+
for (const raw of snapshot.cases) {
|
|
133
|
+
const normalized = normalizeCase(raw)
|
|
134
|
+
if (normalized !== undefined) cases.push(normalized)
|
|
135
|
+
}
|
|
136
|
+
if (cases.length === 0) return undefined
|
|
137
|
+
cases.sort((a, b) => b.id - a.id)
|
|
138
|
+
return {
|
|
139
|
+
cases,
|
|
140
|
+
repository: typeof snapshot.repository === 'string' && snapshot.repository !== '' ? snapshot.repository : 'freestylefly/awesome-gpt-image-2',
|
|
141
|
+
fetchedAt: typeof snapshot.fetchedAt === 'string' && snapshot.fetchedAt !== '' ? snapshot.fetchedAt : '',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read + parse a snapshot file; undefined when missing/corrupt. */
|
|
146
|
+
async function readSnapshotFile(file: string): Promise<ReturnType<typeof parseSnapshot>> {
|
|
147
|
+
try {
|
|
148
|
+
return parseSnapshot(JSON.parse(await fs.readFile(file, 'utf8')))
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The active template list: the refreshed runtime copy wins, the bundled
|
|
156
|
+
* snapshot is the always-available fallback. Memoized; a successful refresh
|
|
157
|
+
* replaces the memo.
|
|
158
|
+
*/
|
|
159
|
+
export async function listTemplates(): Promise<TemplateListResult> {
|
|
160
|
+
if (memo !== undefined) return memo
|
|
161
|
+
const refreshed = await readSnapshotFile(REFRESHED_CASES_PATH)
|
|
162
|
+
if (refreshed !== undefined) {
|
|
163
|
+
memo = { ...refreshed, total: refreshed.cases.length, origin: 'refreshed' }
|
|
164
|
+
return memo
|
|
165
|
+
}
|
|
166
|
+
const bundled = await readSnapshotFile(BUNDLED_CASES_PATH)
|
|
167
|
+
if (bundled !== undefined) {
|
|
168
|
+
memo = { ...bundled, total: bundled.cases.length, origin: 'bundled' }
|
|
169
|
+
return memo
|
|
170
|
+
}
|
|
171
|
+
memo = { cases: [], total: 0, origin: 'bundled', repository: 'freestylefly/awesome-gpt-image-2', fetchedAt: '' }
|
|
172
|
+
return memo
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Re-download the case list from the upstream mirror and persist it as the
|
|
177
|
+
* runtime copy. Throws with a user-presentable message on failure; the
|
|
178
|
+
* previous list (refreshed or bundled) stays active.
|
|
179
|
+
*/
|
|
180
|
+
export async function refreshTemplates(): Promise<TemplateRefreshResult> {
|
|
181
|
+
let response: Response
|
|
182
|
+
try {
|
|
183
|
+
response = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw new Error(`无法连接模板库源站:${error instanceof Error ? error.message : String(error)}`)
|
|
186
|
+
}
|
|
187
|
+
if (!response.ok) throw new Error(`模板库源站拒绝请求(HTTP ${response.status})`)
|
|
188
|
+
let payload: unknown
|
|
189
|
+
try {
|
|
190
|
+
payload = await response.json()
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error('模板库源站返回了非 JSON 响应')
|
|
193
|
+
}
|
|
194
|
+
const parsed = parseSnapshot(payload)
|
|
195
|
+
if (parsed === undefined) throw new Error('模板库源站数据格式无效')
|
|
196
|
+
const fetchedAt = new Date().toISOString()
|
|
197
|
+
const snapshot = {
|
|
198
|
+
repository: parsed.repository,
|
|
199
|
+
sourceUrl: SOURCE_URL,
|
|
200
|
+
fetchedAt,
|
|
201
|
+
totalCases: parsed.cases.length,
|
|
202
|
+
cases: parsed.cases,
|
|
203
|
+
}
|
|
204
|
+
await fs.mkdir(path.dirname(REFRESHED_CASES_PATH), { recursive: true })
|
|
205
|
+
const tmp = `${REFRESHED_CASES_PATH}.tmp-${process.pid}`
|
|
206
|
+
await fs.writeFile(tmp, JSON.stringify(snapshot), 'utf8')
|
|
207
|
+
await fs.rename(tmp, REFRESHED_CASES_PATH)
|
|
208
|
+
memo = { cases: parsed.cases, total: parsed.cases.length, origin: 'refreshed', repository: parsed.repository, fetchedAt }
|
|
209
|
+
return { total: parsed.cases.length, fetchedAt }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** MIME type for a cached reference-image file name. */
|
|
213
|
+
function mimeOfFile(file: string): string {
|
|
214
|
+
switch (path.extname(file).toLowerCase()) {
|
|
215
|
+
case '.jpg':
|
|
216
|
+
case '.jpeg': return 'image/jpeg'
|
|
217
|
+
case '.webp': return 'image/webp'
|
|
218
|
+
case '.gif': return 'image/gif'
|
|
219
|
+
default: return 'image/png'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Download one reference image into the disk cache; undefined on failure. */
|
|
224
|
+
async function fetchTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
225
|
+
let response: Response
|
|
226
|
+
try {
|
|
227
|
+
response = await fetch(`${IMAGE_BASE_URL}${encodeURIComponent(file)}`, {
|
|
228
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
229
|
+
})
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
if (!response.ok) return undefined
|
|
234
|
+
const declared = Number(response.headers.get('content-length') ?? 0)
|
|
235
|
+
if (declared > MAX_IMAGE_BYTES) return undefined
|
|
236
|
+
const data = Buffer.from(await response.arrayBuffer())
|
|
237
|
+
if (data.byteLength === 0 || data.byteLength > MAX_IMAGE_BYTES) return undefined
|
|
238
|
+
const mime = mimeOfFile(file)
|
|
239
|
+
try {
|
|
240
|
+
await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true })
|
|
241
|
+
const tmp = path.join(IMAGE_CACHE_DIR, `${file}.tmp-${process.pid}`)
|
|
242
|
+
await fs.writeFile(tmp, data)
|
|
243
|
+
await fs.rename(tmp, path.join(IMAGE_CACHE_DIR, file))
|
|
244
|
+
} catch {
|
|
245
|
+
// A cache-write failure must not lose the already-fetched bytes.
|
|
246
|
+
}
|
|
247
|
+
return { data, mime }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read one reference image for the template library. Cache hit → disk; miss →
|
|
252
|
+
* fetch from the upstream mirror, cache, and serve. Only file names present in
|
|
253
|
+
* the active case list are served, so the route can never act as an open
|
|
254
|
+
* proxy. Undefined when the name is unknown or the fetch failed.
|
|
255
|
+
*/
|
|
256
|
+
export async function readTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
|
|
257
|
+
if (!IMAGE_FILE_PATTERN.test(file) || file.includes('..')) return undefined
|
|
258
|
+
const list = await listTemplates()
|
|
259
|
+
if (!list.cases.some(entry => entry.image === file)) return undefined
|
|
260
|
+
const cached = path.join(IMAGE_CACHE_DIR, file)
|
|
261
|
+
try {
|
|
262
|
+
return { data: await fs.readFile(cached), mime: mimeOfFile(file) }
|
|
263
|
+
} catch { /* fall through to download */ }
|
|
264
|
+
const inflight = inflightImages.get(file)
|
|
265
|
+
if (inflight !== undefined) return inflight
|
|
266
|
+
const pending = fetchTemplateImage(file)
|
|
267
|
+
inflightImages.set(file, pending)
|
|
268
|
+
try {
|
|
269
|
+
return await pending
|
|
270
|
+
} finally {
|
|
271
|
+
inflightImages.delete(file)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Drop the in-memory list memo (tests). */
|
|
276
|
+
export function clearTemplateMemo(): void {
|
|
277
|
+
memo = undefined
|
|
278
|
+
}
|