@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.
- package/README.md +11 -8
- package/docs/images/ecommerce-mode.png +0 -0
- package/docs/images/gallery-workspace.png +0 -0
- package/docs/images/image-generation-studio-three-column.png +0 -0
- package/docs/images/imagegen-overview.png +0 -0
- package/docs/images/plugin-settings.png +0 -0
- package/docs/images/prompt-template-library.png +0 -0
- package/lib/client.js +824 -449
- package/lib/client.js.map +1 -1
- package/lib/index.js +496 -80
- package/package.json +23 -17
- package/src/client/ImageGenPanel.tsx +10 -8
- package/src/client/InspirationGallery.tsx +106 -0
- package/src/client/SettingsCard.tsx +1 -1
- package/src/client/TemplateLibrary.tsx +159 -48
- package/src/client/api.ts +41 -8
- package/src/client/channels-form.ts +1 -1
- package/src/client/conversation-sync.ts +1 -1
- package/src/client/image-toolview.tsx +16 -10
- package/src/client/index.ts +5 -4
- package/src/client/inspiration.module.css +148 -0
- package/src/client/locales.ts +34 -8
- package/src/client/mount.tsx +1 -1
- package/src/client/settings-form.ts +2 -1
- package/src/client/settings-scope.ts +9 -5
- package/src/client/templates.module.css +95 -0
- package/src/index.ts +26 -6
- package/src/protocol.ts +81 -7
- package/src/routes.ts +128 -12
- package/src/settings-compat.ts +60 -0
- package/src/template-favorites.ts +108 -0
- package/src/templates/canghe-cases.json +11126 -0
- package/src/templates-store.ts +179 -68
- package/docs/images/image-generation-studio-single.png +0 -0
- package/docs/images/image-generation-studio.png +0 -0
- package/docs/images/poster-features-16x9.png +0 -0
package/src/templates-store.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prompt-template library store (
|
|
2
|
+
* Prompt-template library store (multi-source).
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
27
|
+
import { DEFAULT_TEMPLATE_SOURCE_ID, TEMPLATE_SOURCES, type TemplateCase, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
|
|
21
28
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
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
|
-
/**
|
|
26
|
-
const
|
|
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
|
|
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
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
/**
|
|
88
|
-
|
|
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
|
|
156
|
-
* snapshot is the always-available fallback. Memoized; a
|
|
157
|
-
* replaces
|
|
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(
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
205
|
+
const bundled = await readSnapshotFile(def.bundledPath)
|
|
167
206
|
if (bundled !== undefined) {
|
|
168
|
-
|
|
169
|
-
|
|
207
|
+
const result: TemplateListResult = { sourceId, ...bundled, total: bundled.cases.length, origin: 'bundled' }
|
|
208
|
+
memos.set(sourceId, result)
|
|
209
|
+
return result
|
|
170
210
|
}
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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(
|
|
226
|
+
response = await fetch(def.listUrl, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
184
227
|
} catch (error) {
|
|
185
|
-
throw new 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:
|
|
242
|
+
sourceUrl: def.listUrl,
|
|
200
243
|
fetchedAt,
|
|
201
244
|
totalCases: parsed.cases.length,
|
|
202
245
|
cases: parsed.cases,
|
|
203
246
|
}
|
|
204
|
-
|
|
205
|
-
|
|
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,
|
|
208
|
-
|
|
209
|
-
|
|
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(`${
|
|
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(
|
|
241
|
-
const tmp = path.join(
|
|
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(
|
|
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
|
|
252
|
-
* fetch from
|
|
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
|
|
365
|
+
const cacheDir = path.join(IMAGE_CACHE_ROOT, sourceId)
|
|
261
366
|
try {
|
|
262
|
-
return { data: await fs.readFile(
|
|
263
|
-
} catch { /* fall through to download */ }
|
|
264
|
-
|
|
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(
|
|
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(
|
|
382
|
+
inflightImages.delete(cacheKey)
|
|
272
383
|
}
|
|
273
384
|
}
|
|
274
385
|
|
|
275
|
-
/** Drop the in-memory list
|
|
386
|
+
/** Drop the in-memory list memos (tests). */
|
|
276
387
|
export function clearTemplateMemo(): void {
|
|
277
|
-
|
|
388
|
+
memos.clear()
|
|
278
389
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|