@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/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
- readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
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
- const file = imageFileFrom(req.url, TEMPLATES_API.image)
947
- if (file === undefined) {
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
+ }