@dickpy/dsh-imagegen 1.5.1 → 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 +807 -435
- package/lib/client.js.map +1 -1
- package/lib/index.js +468 -76
- package/package.json +1 -1
- package/src/client/ImageGenPanel.tsx +10 -9
- package/src/client/InspirationGallery.tsx +106 -0
- package/src/client/TemplateLibrary.tsx +159 -48
- package/src/client/api.ts +41 -8
- package/src/client/inspiration.module.css +148 -0
- package/src/client/locales.ts +34 -8
- package/src/client/templates.module.css +95 -0
- package/src/index.ts +23 -3
- package/src/protocol.ts +81 -7
- package/src/routes.ts +124 -10
- 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/routes.ts
CHANGED
|
@@ -16,10 +16,11 @@ import { normalizeImageModels } from './image-models.ts'
|
|
|
16
16
|
import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
|
|
17
17
|
import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
|
|
18
18
|
import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
|
|
19
|
-
import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
|
|
19
|
+
import { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates } from './templates-store.ts'
|
|
20
|
+
import { addTemplateFavorite, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
|
|
20
21
|
import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
|
|
21
22
|
import { IMAGE_PRESETS } from './presets.ts'
|
|
22
|
-
import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
|
|
23
|
+
import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, DEFAULT_TEMPLATE_SOURCE_ID, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, USAGE_API, isTemplateSourceId, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
|
|
23
24
|
|
|
24
25
|
/** Cap on JSON request bodies (settings ops and generate payloads are small). */
|
|
25
26
|
const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
|
|
@@ -74,9 +75,16 @@ export interface ImageGenRoutesDeps {
|
|
|
74
75
|
}
|
|
75
76
|
/** Overrideable template-library backend, primarily for host integration tests. */
|
|
76
77
|
templates?: {
|
|
77
|
-
list: () => Promise<TemplateListResult>
|
|
78
|
-
refresh: () => Promise<TemplateRefreshResult>
|
|
79
|
-
|
|
78
|
+
list: (sourceId: string) => Promise<TemplateListResult>
|
|
79
|
+
refresh: (sourceId: string) => Promise<TemplateRefreshResult>
|
|
80
|
+
sample: (count: number) => Promise<TemplateSample[]>
|
|
81
|
+
readImage: (sourceId: string, file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
82
|
+
}
|
|
83
|
+
/** Overrideable template-favorites backend, primarily for host integration tests. */
|
|
84
|
+
favorites?: {
|
|
85
|
+
list: () => Promise<TemplateFavorite[]>
|
|
86
|
+
add: (sourceId: string, item: TemplateFavorite['case']) => Promise<TemplateFavorite[]>
|
|
87
|
+
remove: (key: string) => Promise<TemplateFavorite[]>
|
|
80
88
|
}
|
|
81
89
|
/** Shared host queue, used by Agent tools and browser task endpoints. */
|
|
82
90
|
runtime?: ImageGenerationRuntime
|
|
@@ -135,6 +143,13 @@ function messageOf(error: unknown): string {
|
|
|
135
143
|
return error instanceof Error ? error.message : String(error)
|
|
136
144
|
}
|
|
137
145
|
|
|
146
|
+
/** Validate the { source } body of a template-library request. */
|
|
147
|
+
function templateSourceOf(body: Record<string, unknown> | undefined): string | undefined {
|
|
148
|
+
const raw = body?.source
|
|
149
|
+
if (raw === undefined || raw === '') return DEFAULT_TEMPLATE_SOURCE_ID
|
|
150
|
+
return typeof raw === 'string' && isTemplateSourceId(raw) ? raw : undefined
|
|
151
|
+
}
|
|
152
|
+
|
|
138
153
|
function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest | undefined {
|
|
139
154
|
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
|
|
140
155
|
if (prompt === '') return undefined
|
|
@@ -311,8 +326,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
311
326
|
const templates = deps.templates ?? {
|
|
312
327
|
list: listTemplates,
|
|
313
328
|
refresh: refreshTemplates,
|
|
329
|
+
sample: sampleTemplates,
|
|
314
330
|
readImage: readTemplateImage,
|
|
315
331
|
}
|
|
332
|
+
const favorites = deps.favorites ?? {
|
|
333
|
+
list: listTemplateFavorites,
|
|
334
|
+
add: addTemplateFavorite,
|
|
335
|
+
remove: removeTemplateFavorite,
|
|
336
|
+
}
|
|
316
337
|
const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
|
|
317
338
|
const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
|
|
318
339
|
|
|
@@ -908,8 +929,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
908
929
|
path: TEMPLATES_API.list,
|
|
909
930
|
handler: async (req, res) => {
|
|
910
931
|
if (!guard(req, res, 'POST')) return
|
|
932
|
+
const body = await readJsonBody(req)
|
|
933
|
+
const sourceId = templateSourceOf(body)
|
|
934
|
+
if (sourceId === undefined) {
|
|
935
|
+
writeJson(res, 200, { ok: false, code: 'templates-source-unknown', message: `未知的模板库来源:${String(body?.source ?? '')}` })
|
|
936
|
+
return
|
|
937
|
+
}
|
|
911
938
|
try {
|
|
912
|
-
const result = await templates.list()
|
|
939
|
+
const result = await templates.list(sourceId)
|
|
913
940
|
writeJson(res, 200, { ok: true, ...result })
|
|
914
941
|
} catch (error) {
|
|
915
942
|
writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
|
|
@@ -922,14 +949,36 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
922
949
|
path: TEMPLATES_API.refresh,
|
|
923
950
|
handler: async (req, res) => {
|
|
924
951
|
if (!guard(req, res, 'POST')) return
|
|
952
|
+
const body = await readJsonBody(req)
|
|
953
|
+
const sourceId = templateSourceOf(body)
|
|
954
|
+
if (sourceId === undefined) {
|
|
955
|
+
writeJson(res, 200, { ok: false, code: 'templates-source-unknown', message: `未知的模板库来源:${String(body?.source ?? '')}` })
|
|
956
|
+
return
|
|
957
|
+
}
|
|
925
958
|
try {
|
|
926
|
-
const result = await templates.refresh()
|
|
959
|
+
const result = await templates.refresh(sourceId)
|
|
927
960
|
writeJson(res, 200, { ok: true, ...result })
|
|
928
961
|
} catch (error) {
|
|
929
962
|
writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
|
|
930
963
|
}
|
|
931
964
|
},
|
|
932
965
|
},
|
|
966
|
+
// --------------------------------------------- templates random sample
|
|
967
|
+
{
|
|
968
|
+
kind: 'exact',
|
|
969
|
+
path: TEMPLATES_API.sample,
|
|
970
|
+
handler: async (req, res) => {
|
|
971
|
+
if (!guard(req, res, 'POST')) return
|
|
972
|
+
const body = await readJsonBody(req)
|
|
973
|
+
const requested = Number(body?.count)
|
|
974
|
+
const count = Number.isFinite(requested) ? requested : 9
|
|
975
|
+
try {
|
|
976
|
+
writeJson(res, 200, { ok: true, samples: await templates.sample(count) })
|
|
977
|
+
} catch (error) {
|
|
978
|
+
writeJson(res, 200, { ok: false, code: 'templates-sample-failed', message: messageOf(error) })
|
|
979
|
+
}
|
|
980
|
+
},
|
|
981
|
+
},
|
|
933
982
|
// -------------------------------------- templates image (prefix, proxied)
|
|
934
983
|
{
|
|
935
984
|
kind: 'prefix',
|
|
@@ -943,12 +992,17 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
943
992
|
writeJson(res, 405, { error: `method not allowed: ${req.method}` })
|
|
944
993
|
return
|
|
945
994
|
}
|
|
946
|
-
|
|
947
|
-
|
|
995
|
+
// Source-scoped: /image/<sourceId>/<file> (file names collide across
|
|
996
|
+
// sources, so the pool on disk is per source).
|
|
997
|
+
const raw = imageFileFrom(req.url, TEMPLATES_API.image)
|
|
998
|
+
const slash = raw?.indexOf('/') ?? -1
|
|
999
|
+
const sourceId = slash > 0 ? raw!.slice(0, slash) : ''
|
|
1000
|
+
const file = slash > 0 ? raw!.slice(slash + 1) : ''
|
|
1001
|
+
if (sourceId === '' || !isTemplateSourceId(sourceId) || file === '') {
|
|
948
1002
|
writeJson(res, 404, { error: 'not found' })
|
|
949
1003
|
return
|
|
950
1004
|
}
|
|
951
|
-
const found = await templates.readImage(file)
|
|
1005
|
+
const found = await templates.readImage(sourceId, file)
|
|
952
1006
|
if (found === undefined) {
|
|
953
1007
|
writeJson(res, 404, { error: 'not found' })
|
|
954
1008
|
return
|
|
@@ -962,5 +1016,65 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
962
1016
|
res.end(found.data)
|
|
963
1017
|
},
|
|
964
1018
|
},
|
|
1019
|
+
// ------------------------------------------ template favorites: list
|
|
1020
|
+
{
|
|
1021
|
+
kind: 'exact',
|
|
1022
|
+
path: TEMPLATE_FAVORITES_API.list,
|
|
1023
|
+
handler: async (req, res) => {
|
|
1024
|
+
if (!guard(req, res, 'POST')) return
|
|
1025
|
+
try {
|
|
1026
|
+
writeJson(res, 200, { ok: true, favorites: await favorites.list() })
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
|
|
1029
|
+
}
|
|
1030
|
+
},
|
|
1031
|
+
},
|
|
1032
|
+
// ------------------------------------------- template favorites: add
|
|
1033
|
+
{
|
|
1034
|
+
kind: 'exact',
|
|
1035
|
+
path: TEMPLATE_FAVORITES_API.add,
|
|
1036
|
+
handler: async (req, res) => {
|
|
1037
|
+
if (!guard(req, res, 'POST')) return
|
|
1038
|
+
const body = await readJsonBody(req)
|
|
1039
|
+
const sourceId = templateSourceOf(body)
|
|
1040
|
+
const rawCase = body?.case
|
|
1041
|
+
if (sourceId === undefined || rawCase === null || typeof rawCase !== 'object') {
|
|
1042
|
+
writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '收藏请求缺少有效的来源或模板数据' })
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
const record = rawCase as Record<string, unknown>
|
|
1046
|
+
const id = Number(record.id)
|
|
1047
|
+
const title = typeof record.title === 'string' ? record.title.trim() : ''
|
|
1048
|
+
const prompt = typeof record.prompt === 'string' ? record.prompt.trim() : ''
|
|
1049
|
+
if (!Number.isInteger(id) || title === '' || prompt === '') {
|
|
1050
|
+
writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '收藏请求缺少有效的模板数据' })
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
try {
|
|
1054
|
+
writeJson(res, 200, { ok: true, favorites: await favorites.add(sourceId, rawCase as TemplateFavorite['case']) })
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
|
|
1057
|
+
}
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
// ---------------------------------------- template favorites: remove
|
|
1061
|
+
{
|
|
1062
|
+
kind: 'exact',
|
|
1063
|
+
path: TEMPLATE_FAVORITES_API.remove,
|
|
1064
|
+
handler: async (req, res) => {
|
|
1065
|
+
if (!guard(req, res, 'POST')) return
|
|
1066
|
+
const body = await readJsonBody(req)
|
|
1067
|
+
const key = typeof body?.key === 'string' ? body.key : ''
|
|
1068
|
+
if (key === '') {
|
|
1069
|
+
writeJson(res, 200, { ok: false, code: 'template-favorite-invalid', message: '取消收藏请求缺少模板标识' })
|
|
1070
|
+
return
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
writeJson(res, 200, { ok: true, favorites: await favorites.remove(key) })
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
writeJson(res, 200, { ok: false, code: 'template-favorites-failed', message: messageOf(error) })
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
},
|
|
965
1079
|
]
|
|
966
1080
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favorites store for the prompt-template library.
|
|
3
|
+
*
|
|
4
|
+
* The user's starred templates persist host-side as full case snapshots under
|
|
5
|
+
* ~/.dsh/dsh-imagegen/templates/favorites.json, keyed by
|
|
6
|
+
* `${sourceId}:${caseId}` — the snapshot means a favorite stays usable even
|
|
7
|
+
* after the upstream list drops or renumbers the case. Framework-free
|
|
8
|
+
* (node:fs only) so the route layer and tests can drive it directly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fs } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { isTemplateSourceId, type TemplateCase, type TemplateFavorite } from './protocol.ts'
|
|
15
|
+
|
|
16
|
+
const DATA_DIR = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
17
|
+
const FAVORITES_PATH = path.join(DATA_DIR, 'templates', 'favorites.json')
|
|
18
|
+
|
|
19
|
+
/** Refuse to grow the file without bound; the user curates this list. */
|
|
20
|
+
const MAX_FAVORITES = 1000
|
|
21
|
+
|
|
22
|
+
/** In-memory memo of the persisted list. */
|
|
23
|
+
let memo: TemplateFavorite[] | undefined
|
|
24
|
+
|
|
25
|
+
/** Build the stable key of one case within a source. */
|
|
26
|
+
export function templateFavoriteKey(sourceId: string, caseId: number): string {
|
|
27
|
+
return `${sourceId}:${caseId}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Validate + normalize one raw stored favorite; undefined when unusable. */
|
|
31
|
+
function normalizeFavorite(raw: unknown): TemplateFavorite | undefined {
|
|
32
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
33
|
+
const record = raw as Record<string, unknown>
|
|
34
|
+
if (typeof record.key !== 'string' || typeof record.savedAt !== 'string') return undefined
|
|
35
|
+
const sourceId = typeof record.sourceId === 'string' ? record.sourceId : ''
|
|
36
|
+
if (!isTemplateSourceId(sourceId)) return undefined
|
|
37
|
+
if (record.key !== templateFavoriteKey(sourceId, Number(record.case && (record.case as TemplateCase).id))) return undefined
|
|
38
|
+
const rawCase = record.case
|
|
39
|
+
if (rawCase === null || typeof rawCase !== 'object') return undefined
|
|
40
|
+
const item = rawCase as Record<string, unknown>
|
|
41
|
+
const id = Number(item.id)
|
|
42
|
+
const title = typeof item.title === 'string' ? item.title : ''
|
|
43
|
+
const prompt = typeof item.prompt === 'string' ? item.prompt : ''
|
|
44
|
+
if (!Number.isInteger(id) || title === '' || prompt === '') return undefined
|
|
45
|
+
// Keep only the wire fields so hand-edited files cannot smuggle extras.
|
|
46
|
+
const snapshot: TemplateCase = {
|
|
47
|
+
id,
|
|
48
|
+
title,
|
|
49
|
+
prompt,
|
|
50
|
+
category: typeof item.category === 'string' ? item.category : '',
|
|
51
|
+
categoryZh: typeof item.categoryZh === 'string' ? item.categoryZh : '',
|
|
52
|
+
styles: Array.isArray(item.styles) ? item.styles.map(String) : [],
|
|
53
|
+
scenes: Array.isArray(item.scenes) ? item.scenes.map(String) : [],
|
|
54
|
+
sourceLabel: typeof item.sourceLabel === 'string' ? item.sourceLabel : '',
|
|
55
|
+
sourceUrl: typeof item.sourceUrl === 'string' ? item.sourceUrl : '',
|
|
56
|
+
githubUrl: typeof item.githubUrl === 'string' ? item.githubUrl : '',
|
|
57
|
+
image: typeof item.image === 'string' ? item.image : '',
|
|
58
|
+
featured: item.featured === true,
|
|
59
|
+
}
|
|
60
|
+
return { key: record.key, sourceId, savedAt: record.savedAt, case: snapshot }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Read + parse the favorites file (memoized). */
|
|
64
|
+
export async function listTemplateFavorites(): Promise<TemplateFavorite[]> {
|
|
65
|
+
if (memo !== undefined) return memo
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(await fs.readFile(FAVORITES_PATH, 'utf8'))
|
|
68
|
+
memo = Array.isArray(parsed)
|
|
69
|
+
? parsed.map(normalizeFavorite).filter((entry): entry is TemplateFavorite => entry !== undefined)
|
|
70
|
+
: []
|
|
71
|
+
} catch {
|
|
72
|
+
memo = []
|
|
73
|
+
}
|
|
74
|
+
return memo
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Persist the list atomically and update the memo. */
|
|
78
|
+
async function writeFavorites(entries: TemplateFavorite[]): Promise<void> {
|
|
79
|
+
memo = entries
|
|
80
|
+
await fs.mkdir(path.dirname(FAVORITES_PATH), { recursive: true })
|
|
81
|
+
const tmp = `${FAVORITES_PATH}.tmp-${process.pid}`
|
|
82
|
+
await fs.writeFile(tmp, JSON.stringify(entries, null, 2), 'utf8')
|
|
83
|
+
await fs.rename(tmp, FAVORITES_PATH)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Star one template. Re-starring refreshes the snapshot and is idempotent. */
|
|
87
|
+
export async function addTemplateFavorite(sourceId: string, item: TemplateCase): Promise<TemplateFavorite[]> {
|
|
88
|
+
if (!isTemplateSourceId(sourceId)) throw new Error(`未知的模板库来源:${sourceId}`)
|
|
89
|
+
const key = templateFavoriteKey(sourceId, item.id)
|
|
90
|
+
const rest = (await listTemplateFavorites()).filter(entry => entry.key !== key)
|
|
91
|
+
const entry: TemplateFavorite = { key, sourceId, savedAt: new Date().toISOString(), case: item }
|
|
92
|
+
const next = [entry, ...rest].slice(0, MAX_FAVORITES)
|
|
93
|
+
await writeFavorites(next)
|
|
94
|
+
return next
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Unstar one template by key; unknown keys are a no-op. */
|
|
98
|
+
export async function removeTemplateFavorite(key: string): Promise<TemplateFavorite[]> {
|
|
99
|
+
const next = (await listTemplateFavorites()).filter(entry => entry.key !== key)
|
|
100
|
+
if (next.length === memo?.length) return next
|
|
101
|
+
await writeFavorites(next)
|
|
102
|
+
return next
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Drop the in-memory memo (tests). */
|
|
106
|
+
export function clearTemplateFavoritesMemo(): void {
|
|
107
|
+
memo = undefined
|
|
108
|
+
}
|