@dickpy/dsh-imagegen 1.2.3 → 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.
Files changed (45) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +203 -181
  3. package/cordis.patch.yml +8 -8
  4. package/docs/images/multi-model-comparison.png +0 -0
  5. package/lib/client.js +2711 -1318
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +830 -155
  8. package/package.json +70 -68
  9. package/src/agent-image-tools.ts +418 -316
  10. package/src/client/ImageGenPanel.tsx +1703 -1476
  11. package/src/client/SettingsCard.tsx +936 -648
  12. package/src/client/TemplateLibrary.tsx +336 -336
  13. package/src/client/api.ts +193 -193
  14. package/src/client/channels-form.ts +263 -0
  15. package/src/client/controller.ts +46 -46
  16. package/src/client/conversation-sync.ts +14 -0
  17. package/src/client/css-modules.d.ts +5 -5
  18. package/src/client/helpers.ts +33 -33
  19. package/src/client/image-toolview.module.css +73 -73
  20. package/src/client/image-toolview.tsx +170 -152
  21. package/src/client/index.ts +32 -22
  22. package/src/client/locales.ts +610 -484
  23. package/src/client/mount.tsx +185 -96
  24. package/src/client/panel.module.css +1713 -1445
  25. package/src/client/settings-card.module.css +1023 -536
  26. package/src/client/settings-form.ts +336 -336
  27. package/src/client/settings-scope.ts +298 -250
  28. package/src/client/sidebar-entry.ts +148 -102
  29. package/src/client/templates.module.css +453 -453
  30. package/src/engine.ts +520 -464
  31. package/src/gallery-store.ts +286 -280
  32. package/src/generation-runtime.ts +79 -48
  33. package/src/history-store.ts +250 -238
  34. package/src/image-format.ts +11 -0
  35. package/src/image-models.ts +19 -19
  36. package/src/index.ts +318 -212
  37. package/src/model-catalog.ts +115 -0
  38. package/src/presets.ts +71 -0
  39. package/src/prompt-enhancer.ts +137 -79
  40. package/src/protocol.ts +338 -253
  41. package/src/routes.ts +916 -738
  42. package/src/task-queue.ts +113 -103
  43. package/src/templates/cases.json +10196 -10196
  44. package/src/templates-store.ts +278 -278
  45. package/src/updater.ts +117 -117
@@ -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
+ }