@dickpy/dsh-imagegen 1.5.0 → 1.5.2

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 (36) hide show
  1. package/README.md +11 -8
  2. package/docs/images/ecommerce-mode.png +0 -0
  3. package/docs/images/gallery-workspace.png +0 -0
  4. package/docs/images/image-generation-studio-three-column.png +0 -0
  5. package/docs/images/imagegen-overview.png +0 -0
  6. package/docs/images/plugin-settings.png +0 -0
  7. package/docs/images/prompt-template-library.png +0 -0
  8. package/lib/client.js +824 -449
  9. package/lib/client.js.map +1 -1
  10. package/lib/index.js +496 -80
  11. package/package.json +23 -17
  12. package/src/client/ImageGenPanel.tsx +10 -8
  13. package/src/client/InspirationGallery.tsx +106 -0
  14. package/src/client/SettingsCard.tsx +1 -1
  15. package/src/client/TemplateLibrary.tsx +159 -48
  16. package/src/client/api.ts +41 -8
  17. package/src/client/channels-form.ts +1 -1
  18. package/src/client/conversation-sync.ts +1 -1
  19. package/src/client/image-toolview.tsx +16 -10
  20. package/src/client/index.ts +5 -4
  21. package/src/client/inspiration.module.css +148 -0
  22. package/src/client/locales.ts +34 -8
  23. package/src/client/mount.tsx +1 -1
  24. package/src/client/settings-form.ts +2 -1
  25. package/src/client/settings-scope.ts +9 -5
  26. package/src/client/templates.module.css +95 -0
  27. package/src/index.ts +26 -6
  28. package/src/protocol.ts +81 -7
  29. package/src/routes.ts +128 -12
  30. package/src/settings-compat.ts +60 -0
  31. package/src/template-favorites.ts +108 -0
  32. package/src/templates/canghe-cases.json +11126 -0
  33. package/src/templates-store.ts +179 -68
  34. package/docs/images/image-generation-studio-single.png +0 -0
  35. package/docs/images/image-generation-studio.png +0 -0
  36. package/docs/images/poster-features-16x9.png +0 -0
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Prompt-template library store (awesome-gpt-image-2 mirror).
2
+ * Prompt-template library store (multi-source).
3
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
4
+ * The library is a registry of independent sources (see TEMPLATE_SOURCES in
5
+ * protocol.ts): each source has its own upstream JSON list, its own bundled
6
+ * snapshot, its own refreshed runtime copy, and its own on-disk image pool.
7
+ * Sources never mix the overlay shows one tab per source and every request
8
+ * names the source explicitly.
9
+ *
10
+ * Each case list ships as a bundled snapshot (src/templates/<file>, inside the
11
+ * npm package) so every library works offline out of the box; a successful
12
+ * refresh (manual, or the periodic background sync) writes a runtime copy
13
+ * under ~/.dsh/dsh-imagegen/templates/<sourceId>/ which then takes precedence.
14
+ * Reference images are not bundled (hundreds of files, ≈100 MB per source) —
15
+ * they are fetched from the source's mirror on demand, cached on disk under
16
+ * ~/.dsh/dsh-imagegen/template-images/<sourceId>/, and served from there on
10
17
  * every later view.
11
18
  *
12
19
  * Framework-free (node:fs only) so the route layer and tests can drive it
@@ -17,15 +24,42 @@ import { promises as fs } from 'node:fs'
17
24
  import { homedir } from 'node:os'
18
25
  import path from 'node:path'
19
26
  import { fileURLToPath } from 'node:url'
20
- import type { TemplateCase, TemplateListResult, TemplateRefreshResult } from './protocol.ts'
27
+ import { DEFAULT_TEMPLATE_SOURCE_ID, TEMPLATE_SOURCES, type TemplateCase, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
21
28
 
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'
29
+ /** Host-side fetch definition of one source (ids mirror TEMPLATE_SOURCES). */
30
+ interface TemplateSourceDef {
31
+ /** Upstream JSON list refreshed from. */
32
+ listUrl: string
33
+ /** Remote image directory (file names come from the case list). */
34
+ imageBaseUrl: string
35
+ /** Bundled snapshot shipped inside the package. */
36
+ bundledPath: string
37
+ /**
38
+ * Pre-1.6 single-source layout: refreshed copies and images lived in
39
+ * unscoped paths. Read as a fallback so existing users keep their state
40
+ * until the next refresh rewrites it into the scoped location.
41
+ */
42
+ legacySnapshotPath?: string
43
+ legacyImageDir?: string
44
+ }
24
45
 
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/'
46
+ /** Source registry (host half): where each TEMPLATE_SOURCES entry loads from. */
47
+ const SOURCE_DEFS: Record<string, TemplateSourceDef> = {
48
+ vibeui: {
49
+ listUrl: 'https://vibeui.top/extra/awesome-gpt-image-2/data/cases.json',
50
+ imageBaseUrl: 'https://vibeui.top/extra/awesome-gpt-image-2/data/images/',
51
+ bundledPath: fileURLToPath(new URL('../src/templates/cases.json', import.meta.url)),
52
+ legacySnapshotPath: 'legacy',
53
+ legacyImageDir: 'legacy',
54
+ },
55
+ canghe: {
56
+ listUrl: 'https://gpt-image2.canghe.ai/cases.json',
57
+ imageBaseUrl: 'https://gpt-image2.canghe.ai/images/',
58
+ bundledPath: fileURLToPath(new URL('../src/templates/canghe-cases.json', import.meta.url)),
59
+ },
60
+ }
27
61
 
28
- /** Category label map mirrored from vibeui.top's site.js (zh display names). */
62
+ /** Category label map mirrored from the upstream sites' site.js (zh names). */
29
63
  const CATEGORY_ZH: Record<string, string> = {
30
64
  'Architecture & Spaces': '建筑与空间',
31
65
  'Brand & Logos': '品牌与标志',
@@ -57,16 +91,11 @@ const CATEGORY_ZH: Record<string, string> = {
57
91
  }
58
92
 
59
93
  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))
94
+ const REFRESHED_DIR = path.join(DATA_DIR, 'templates')
95
+ const IMAGE_CACHE_ROOT = path.join(DATA_DIR, 'template-images')
96
+ /** Pre-1.6 single-source locations (vibeui fallbacks). */
97
+ const LEGACY_SNAPSHOT_PATH = path.join(REFRESHED_DIR, 'cases.json')
98
+ const LEGACY_IMAGE_DIR = IMAGE_CACHE_ROOT
70
99
 
71
100
  /** Budget for one upstream fetch (list refresh or one image). */
72
101
  const FETCH_TIMEOUT_MS = 60_000
@@ -84,12 +113,17 @@ interface CasesSnapshot {
84
113
  cases?: unknown
85
114
  }
86
115
 
87
- /** In-memory memo of the active list (avoid re-parsing on every request). */
88
- let memo: TemplateListResult | undefined
116
+ /** Per-source in-memory memo of the active list (avoid re-parsing per request). */
117
+ const memos = new Map<string, TemplateListResult>()
89
118
 
90
119
  /** Per-file in-flight downloads, so a gallery scroll never double-fetches. */
91
120
  const inflightImages = new Map<string, Promise<{ data: Buffer; mime: string } | undefined>>()
92
121
 
122
+ /** Resolve a registered source id to its fetch definition. */
123
+ function sourceDefOf(sourceId: string): TemplateSourceDef | undefined {
124
+ return SOURCE_DEFS[sourceId]
125
+ }
126
+
93
127
  /** Validate + normalize one raw upstream case; undefined when unusable. */
94
128
  function normalizeCase(raw: unknown): TemplateCase | undefined {
95
129
  if (raw === null || typeof raw !== 'object') return undefined
@@ -152,37 +186,46 @@ async function readSnapshotFile(file: string): Promise<ReturnType<typeof parseSn
152
186
  }
153
187
 
154
188
  /**
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.
189
+ * The active template list of one source: the refreshed runtime copy wins, the
190
+ * bundled snapshot is the always-available fallback. Memoized per source; a
191
+ * successful refresh replaces that source's memo.
158
192
  */
159
- export async function listTemplates(): Promise<TemplateListResult> {
193
+ export async function listTemplates(sourceId: string = DEFAULT_TEMPLATE_SOURCE_ID): Promise<TemplateListResult> {
194
+ const def = sourceDefOf(sourceId)
195
+ if (def === undefined) throw new Error(`未知的模板库来源:${sourceId}`)
196
+ const memo = memos.get(sourceId)
160
197
  if (memo !== undefined) return memo
161
- const refreshed = await readSnapshotFile(REFRESHED_CASES_PATH)
198
+ const refreshed = await readSnapshotFile(path.join(REFRESHED_DIR, sourceId, 'cases.json'))
199
+ ?? (def.legacySnapshotPath !== undefined ? await readSnapshotFile(LEGACY_SNAPSHOT_PATH) : undefined)
162
200
  if (refreshed !== undefined) {
163
- memo = { ...refreshed, total: refreshed.cases.length, origin: 'refreshed' }
164
- return memo
201
+ const result: TemplateListResult = { sourceId, ...refreshed, total: refreshed.cases.length, origin: 'refreshed' }
202
+ memos.set(sourceId, result)
203
+ return result
165
204
  }
166
- const bundled = await readSnapshotFile(BUNDLED_CASES_PATH)
205
+ const bundled = await readSnapshotFile(def.bundledPath)
167
206
  if (bundled !== undefined) {
168
- memo = { ...bundled, total: bundled.cases.length, origin: 'bundled' }
169
- return memo
207
+ const result: TemplateListResult = { sourceId, ...bundled, total: bundled.cases.length, origin: 'bundled' }
208
+ memos.set(sourceId, result)
209
+ return result
170
210
  }
171
- memo = { cases: [], total: 0, origin: 'bundled', repository: 'freestylefly/awesome-gpt-image-2', fetchedAt: '' }
172
- return memo
211
+ const result: TemplateListResult = { sourceId, cases: [], total: 0, origin: 'bundled', repository: 'freestylefly/awesome-gpt-image-2', fetchedAt: '' }
212
+ memos.set(sourceId, result)
213
+ return result
173
214
  }
174
215
 
175
216
  /**
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
217
+ * Re-download one source's case list from its upstream mirror and persist it
218
+ * as the runtime copy. Throws with a user-presentable message on failure; the
178
219
  * previous list (refreshed or bundled) stays active.
179
220
  */
180
- export async function refreshTemplates(): Promise<TemplateRefreshResult> {
221
+ export async function refreshTemplates(sourceId: string = DEFAULT_TEMPLATE_SOURCE_ID): Promise<TemplateRefreshResult> {
222
+ const def = sourceDefOf(sourceId)
223
+ if (def === undefined) throw new Error(`未知的模板库来源:${sourceId}`)
181
224
  let response: Response
182
225
  try {
183
- response = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
226
+ response = await fetch(def.listUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
184
227
  } catch (error) {
185
- throw new Error(`无法连接模板库源站:${error instanceof Error ? error.message : String(error)}`)
228
+ throw new Error(`无法连接模板库源站(${sourceId}):${error instanceof Error ? error.message : String(error)}`)
186
229
  }
187
230
  if (!response.ok) throw new Error(`模板库源站拒绝请求(HTTP ${response.status})`)
188
231
  let payload: unknown
@@ -196,17 +239,77 @@ export async function refreshTemplates(): Promise<TemplateRefreshResult> {
196
239
  const fetchedAt = new Date().toISOString()
197
240
  const snapshot = {
198
241
  repository: parsed.repository,
199
- sourceUrl: SOURCE_URL,
242
+ sourceUrl: def.listUrl,
200
243
  fetchedAt,
201
244
  totalCases: parsed.cases.length,
202
245
  cases: parsed.cases,
203
246
  }
204
- await fs.mkdir(path.dirname(REFRESHED_CASES_PATH), { recursive: true })
205
- const tmp = `${REFRESHED_CASES_PATH}.tmp-${process.pid}`
247
+ const target = path.join(REFRESHED_DIR, sourceId, 'cases.json')
248
+ await fs.mkdir(path.dirname(target), { recursive: true })
249
+ const tmp = `${target}.tmp-${process.pid}`
206
250
  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 }
251
+ await fs.rename(tmp, target)
252
+ const result: TemplateListResult = { sourceId, cases: parsed.cases, total: parsed.cases.length, origin: 'refreshed', repository: parsed.repository, fetchedAt }
253
+ memos.set(sourceId, result)
254
+ return { sourceId, total: parsed.cases.length, fetchedAt }
255
+ }
256
+
257
+ /** Per-source outcome of one background sync pass. */
258
+ export interface TemplateSyncReport {
259
+ sourceId: string
260
+ ok: boolean
261
+ total: number
262
+ error?: string
263
+ }
264
+
265
+ /** Fisher–Yates shuffle (returns a copy; never mutates the pool). */
266
+ function shuffled<T>(items: T[]): T[] {
267
+ const out = [...items]
268
+ for (let i = out.length - 1; i > 0; i -= 1) {
269
+ const j = Math.floor(Math.random() * (i + 1))
270
+ const a = out[i]!
271
+ out[i] = out[j]!
272
+ out[j] = a
273
+ }
274
+ return out
275
+ }
276
+
277
+ /**
278
+ * Draw up to `count` random cases across every source that has data,
279
+ * round-robin between sources so one huge library cannot crowd out the
280
+ * others, then shuffle the final pick. Reads memoized lists, so this never
281
+ * touches the network — cheap enough for every shuffle click.
282
+ */
283
+ export async function sampleTemplates(count: number = 9): Promise<TemplateSample[]> {
284
+ const size = Math.min(12, Math.max(1, Math.floor(count) || 9))
285
+ const pools: Array<{ sourceId: string; cases: TemplateCase[] }> = []
286
+ for (const source of TEMPLATE_SOURCES) {
287
+ try {
288
+ const list = await listTemplates(source.id)
289
+ if (list.cases.length > 0) pools.push({ sourceId: source.id, cases: shuffled(list.cases) })
290
+ } catch { /* an unusable source just contributes nothing */ }
291
+ }
292
+ const picks: TemplateSample[] = []
293
+ for (let round = 0; picks.length < size && pools.some(pool => pool.cases.length > 0); round += 1) {
294
+ const pool = pools[round % pools.length]!
295
+ const picked = pool.cases.pop()
296
+ if (picked !== undefined) picks.push({ sourceId: pool.sourceId, case: picked })
297
+ }
298
+ return shuffled(picks)
299
+ }
300
+
301
+ /** Serially refresh every registered source; one failure never stops the rest. */
302
+ export async function syncAllTemplates(): Promise<TemplateSyncReport[]> {
303
+ const reports: TemplateSyncReport[] = []
304
+ for (const source of TEMPLATE_SOURCES) {
305
+ try {
306
+ const result = await refreshTemplates(source.id)
307
+ reports.push({ sourceId: source.id, ok: true, total: result.total })
308
+ } catch (error) {
309
+ reports.push({ sourceId: source.id, ok: false, total: 0, error: error instanceof Error ? error.message : String(error) })
310
+ }
311
+ }
312
+ return reports
210
313
  }
211
314
 
212
315
  /** MIME type for a cached reference-image file name. */
@@ -220,11 +323,11 @@ function mimeOfFile(file: string): string {
220
323
  }
221
324
  }
222
325
 
223
- /** Download one reference image into the disk cache; undefined on failure. */
224
- async function fetchTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
326
+ /** Download one reference image into the source's disk cache; undefined on failure. */
327
+ async function fetchTemplateImage(def: TemplateSourceDef, file: string, cacheDir: string): Promise<{ data: Buffer; mime: string } | undefined> {
225
328
  let response: Response
226
329
  try {
227
- response = await fetch(`${IMAGE_BASE_URL}${encodeURIComponent(file)}`, {
330
+ response = await fetch(`${def.imageBaseUrl}${encodeURIComponent(file)}`, {
228
331
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
229
332
  })
230
333
  } catch {
@@ -237,10 +340,10 @@ async function fetchTemplateImage(file: string): Promise<{ data: Buffer; mime: s
237
340
  if (data.byteLength === 0 || data.byteLength > MAX_IMAGE_BYTES) return undefined
238
341
  const mime = mimeOfFile(file)
239
342
  try {
240
- await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true })
241
- const tmp = path.join(IMAGE_CACHE_DIR, `${file}.tmp-${process.pid}`)
343
+ await fs.mkdir(cacheDir, { recursive: true })
344
+ const tmp = path.join(cacheDir, `${file}.tmp-${process.pid}`)
242
345
  await fs.writeFile(tmp, data)
243
- await fs.rename(tmp, path.join(IMAGE_CACHE_DIR, file))
346
+ await fs.rename(tmp, path.join(cacheDir, file))
244
347
  } catch {
245
348
  // A cache-write failure must not lose the already-fetched bytes.
246
349
  }
@@ -248,31 +351,39 @@ async function fetchTemplateImage(file: string): Promise<{ data: Buffer; mime: s
248
351
  }
249
352
 
250
353
  /**
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
354
+ * Read one reference image for a source's library. Cache hit → disk; miss →
355
+ * fetch from that source's mirror, cache, and serve. Only file names present
356
+ * in the active case list are served, so the route can never act as an open
254
357
  * proxy. Undefined when the name is unknown or the fetch failed.
255
358
  */
256
- export async function readTemplateImage(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
359
+ export async function readTemplateImage(sourceId: string, file: string): Promise<{ data: Buffer; mime: string } | undefined> {
360
+ const def = sourceDefOf(sourceId)
361
+ if (def === undefined) return undefined
257
362
  if (!IMAGE_FILE_PATTERN.test(file) || file.includes('..')) return undefined
258
- const list = await listTemplates()
363
+ const list = await listTemplates(sourceId)
259
364
  if (!list.cases.some(entry => entry.image === file)) return undefined
260
- const cached = path.join(IMAGE_CACHE_DIR, file)
365
+ const cacheDir = path.join(IMAGE_CACHE_ROOT, sourceId)
261
366
  try {
262
- return { data: await fs.readFile(cached), mime: mimeOfFile(file) }
263
- } catch { /* fall through to download */ }
264
- const inflight = inflightImages.get(file)
367
+ return { data: await fs.readFile(path.join(cacheDir, file)), mime: mimeOfFile(file) }
368
+ } catch { /* fall through to legacy cache / download */ }
369
+ if (def.legacyImageDir !== undefined) {
370
+ try {
371
+ return { data: await fs.readFile(path.join(LEGACY_IMAGE_DIR, file)), mime: mimeOfFile(file) }
372
+ } catch { /* fall through to download */ }
373
+ }
374
+ const cacheKey = `${sourceId}/${file}`
375
+ const inflight = inflightImages.get(cacheKey)
265
376
  if (inflight !== undefined) return inflight
266
- const pending = fetchTemplateImage(file)
267
- inflightImages.set(file, pending)
377
+ const pending = fetchTemplateImage(def, file, cacheDir)
378
+ inflightImages.set(cacheKey, pending)
268
379
  try {
269
380
  return await pending
270
381
  } finally {
271
- inflightImages.delete(file)
382
+ inflightImages.delete(cacheKey)
272
383
  }
273
384
  }
274
385
 
275
- /** Drop the in-memory list memo (tests). */
386
+ /** Drop the in-memory list memos (tests). */
276
387
  export function clearTemplateMemo(): void {
277
- memo = undefined
388
+ memos.clear()
278
389
  }