@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
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.5.0'
11
+ export const PLUGIN_VERSION = '1.5.2'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -92,17 +92,68 @@ export const GALLERY_API = {
92
92
  export const HISTORY_MAX = 50
93
93
 
94
94
  /**
95
- * Same-origin route family for the bundled prompt-template library
96
- * (awesome-gpt-image-2 mirror). The case list ships inside the package and is
97
- * served by the host; reference images are proxied through the `image` prefix
98
- * route and cached on disk so repeated views never hit the network again.
95
+ * Same-origin route family for the prompt-template libraries. The library is
96
+ * multi-source: every request names a source id from {@link TEMPLATE_SOURCES},
97
+ * each source keeps an independent snapshot/image cache host-side, and
98
+ * reference images are proxied through the source-scoped `image` prefix route
99
+ * (`…/image/<sourceId>/<file>`) and cached on disk so repeated views never hit
100
+ * the network again.
99
101
  */
100
102
  export const TEMPLATES_API = {
101
103
  list: '/api/dsh-imagegen/templates/list',
102
104
  refresh: '/api/dsh-imagegen/templates/refresh',
105
+ sample: '/api/dsh-imagegen/templates/sample',
103
106
  image: '/api/dsh-imagegen/templates/image',
104
107
  } as const
105
108
 
109
+ /** Same-origin route family for the user's saved (favorited) templates. */
110
+ export const TEMPLATE_FAVORITES_API = {
111
+ list: '/api/dsh-imagegen/templates/favorites/list',
112
+ add: '/api/dsh-imagegen/templates/favorites/add',
113
+ remove: '/api/dsh-imagegen/templates/favorites/remove',
114
+ } as const
115
+
116
+ /** One prompt-template library source (a tab in the library overlay). */
117
+ export interface TemplateSourceMeta {
118
+ /** Stable source id: snapshot dir name, image-cache dir, and request key. */
119
+ id: string
120
+ /** Tab label shown in the library overlay. */
121
+ label: string
122
+ /** Source homepage linked in the overlay footer. */
123
+ homepage: string
124
+ /** One-line description of the source (tab tooltip). */
125
+ description: string
126
+ }
127
+
128
+ /**
129
+ * The template-library source registry. Each entry is fully independent (own
130
+ * upstream JSON, own image pool, own refresh state) and renders as its own
131
+ * tab; adding a source later means appending an entry here plus a host-side
132
+ * fetch definition in templates-store.ts and an optional bundled snapshot.
133
+ */
134
+ export const TEMPLATE_SOURCES: TemplateSourceMeta[] = [
135
+ {
136
+ id: 'vibeui',
137
+ label: '精选案例库',
138
+ homepage: 'https://vibeui.top/',
139
+ description: 'awesome-gpt-image-2 精选提示词案例(vibeui.top 镜像)',
140
+ },
141
+ {
142
+ id: 'canghe',
143
+ label: '沧河案例库',
144
+ homepage: 'https://gpt-image2.canghe.ai/',
145
+ description: 'GPT-Image2 Prompt Gallery(gpt-image2.canghe.ai,定期更新)',
146
+ },
147
+ ]
148
+
149
+ /** Default source id when a request does not name one (legacy clients). */
150
+ export const DEFAULT_TEMPLATE_SOURCE_ID = TEMPLATE_SOURCES[0]!.id
151
+
152
+ /** True when the id names a registered template source. */
153
+ export function isTemplateSourceId(id: string): boolean {
154
+ return TEMPLATE_SOURCES.some(source => source.id === id)
155
+ }
156
+
106
157
  /** One prompt-library case as the browser consumes it. */
107
158
  export interface TemplateCase {
108
159
  /** Upstream case number (stable across refreshes). */
@@ -131,8 +182,10 @@ export interface TemplateCase {
131
182
  featured: boolean
132
183
  }
133
184
 
134
- /** Template-library list payload. */
185
+ /** Template-library list payload (one source). */
135
186
  export interface TemplateListResult {
187
+ /** The source this list belongs to. */
188
+ sourceId: string
136
189
  cases: TemplateCase[]
137
190
  total: number
138
191
  /** Where the served list came from. */
@@ -143,12 +196,33 @@ export interface TemplateListResult {
143
196
  fetchedAt: string
144
197
  }
145
198
 
146
- /** Template-library refresh outcome. */
199
+ /** Template-library refresh outcome (one source). */
147
200
  export interface TemplateRefreshResult {
201
+ sourceId: string
148
202
  total: number
149
203
  fetchedAt: string
150
204
  }
151
205
 
206
+ /** One random inspiration pick served to the studio's empty state. */
207
+ export interface TemplateSample {
208
+ /** Source the case came from (drives the image proxy URL). */
209
+ sourceId: string
210
+ /** The sampled case (full prompt is handed to the form on use). */
211
+ case: TemplateCase
212
+ }
213
+
214
+ /** One favorited template as persisted host-side and served to the browser. */
215
+ export interface TemplateFavorite {
216
+ /** Stable key: `${sourceId}:${caseId}`. */
217
+ key: string
218
+ /** Source the case came from. */
219
+ sourceId: string
220
+ /** ISO time the favorite was saved. */
221
+ savedAt: string
222
+ /** Full case snapshot, so favorites survive upstream list churn. */
223
+ case: TemplateCase
224
+ }
225
+
152
226
  /** Generation modes. */
153
227
  export type GenerateMode = 'text' | 'edit'
154
228
 
package/src/routes.ts CHANGED
@@ -9,17 +9,18 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
11
  import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
12
- import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
12
+ import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
13
13
  import type { UpstreamConfig } from './engine.ts'
14
14
  import { enhancePrompt, listImageModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
15
15
  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
 
@@ -563,7 +584,9 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
563
584
  }
564
585
  const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
565
586
  try {
566
- await deps.settings.mutate(settingsNamespace(ns), body.ops, expectedRevision)
587
+ // The alpha.2 settings package no longer exports settingsNamespace;
588
+ // the bridge already checked this value against our fixed namespace.
589
+ await deps.settings.mutate(ns, body.ops, expectedRevision)
567
590
  } catch (error) {
568
591
  writeJson(res, 200, failureOf(error))
569
592
  return
@@ -906,8 +929,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
906
929
  path: TEMPLATES_API.list,
907
930
  handler: async (req, res) => {
908
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
+ }
909
938
  try {
910
- const result = await templates.list()
939
+ const result = await templates.list(sourceId)
911
940
  writeJson(res, 200, { ok: true, ...result })
912
941
  } catch (error) {
913
942
  writeJson(res, 200, { ok: false, code: 'templates-failed', message: messageOf(error) })
@@ -920,14 +949,36 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
920
949
  path: TEMPLATES_API.refresh,
921
950
  handler: async (req, res) => {
922
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
+ }
923
958
  try {
924
- const result = await templates.refresh()
959
+ const result = await templates.refresh(sourceId)
925
960
  writeJson(res, 200, { ok: true, ...result })
926
961
  } catch (error) {
927
962
  writeJson(res, 200, { ok: false, code: 'templates-refresh-failed', message: messageOf(error) })
928
963
  }
929
964
  },
930
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
+ },
931
982
  // -------------------------------------- templates image (prefix, proxied)
932
983
  {
933
984
  kind: 'prefix',
@@ -941,12 +992,17 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
941
992
  writeJson(res, 405, { error: `method not allowed: ${req.method}` })
942
993
  return
943
994
  }
944
- const file = imageFileFrom(req.url, TEMPLATES_API.image)
945
- 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 === '') {
946
1002
  writeJson(res, 404, { error: 'not found' })
947
1003
  return
948
1004
  }
949
- const found = await templates.readImage(file)
1005
+ const found = await templates.readImage(sourceId, file)
950
1006
  if (found === undefined) {
951
1007
  writeJson(res, 404, { error: 'not found' })
952
1008
  return
@@ -960,5 +1016,65 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
960
1016
  res.end(found.data)
961
1017
  },
962
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
+ },
963
1079
  ]
964
1080
  }
@@ -0,0 +1,60 @@
1
+ /** Compatibility helpers for the dsh-settings API transition. */
2
+
3
+ import type { Context } from '@deepseek-ai/cordis'
4
+ import * as settingsModule from '@deepseek-ai/dsh-settings'
5
+ import type { SettingsNamespace, SettingsSectionHooks } from '@deepseek-ai/dsh-settings'
6
+ import type z from 'schemastery'
7
+
8
+ type SettingsModuleCompat = {
9
+ settingsNamespace?: (value: string) => SettingsNamespace
10
+ installSettingsSection?: <T>(
11
+ ctx: Context,
12
+ ns: SettingsNamespace,
13
+ schema: z<T>,
14
+ entry: T,
15
+ hooks: SettingsSectionHooks<T>,
16
+ ) => void
17
+ }
18
+
19
+ interface SettingsProviderCompat {
20
+ installSection?: <T>(
21
+ owner: Context,
22
+ ns: SettingsNamespace,
23
+ schema: z<T>,
24
+ entry: T,
25
+ hooks: SettingsSectionHooks<T>,
26
+ ) => void
27
+ }
28
+
29
+ const compatModule = settingsModule as unknown as SettingsModuleCompat
30
+
31
+ /** Brand namespaces where the installed settings package still exposes it. */
32
+ export function settingsNamespaceCompat(value: string): SettingsNamespace {
33
+ return compatModule.settingsNamespace?.(value) ?? value as SettingsNamespace
34
+ }
35
+
36
+ /**
37
+ * Register an optional settings section across the rc.7 and alpha.2 APIs.
38
+ * rc.7 exposes a module helper; alpha.2 moves the helper onto the provider.
39
+ */
40
+ export function installSettingsSectionCompat<T>(
41
+ ctx: Context,
42
+ ns: SettingsNamespace,
43
+ schema: z<T>,
44
+ entry: T,
45
+ hooks: SettingsSectionHooks<T>,
46
+ ): void {
47
+ const legacyInstaller = compatModule.installSettingsSection
48
+ if (legacyInstaller !== undefined) {
49
+ legacyInstaller(ctx, ns, schema, entry, hooks)
50
+ return
51
+ }
52
+
53
+ ctx.inject(['settings'], (sctx) => {
54
+ const provider = sctx.get('settings') as unknown as SettingsProviderCompat
55
+ if (provider.installSection === undefined) {
56
+ throw new TypeError('dsh-settings does not expose installSection')
57
+ }
58
+ provider.installSection(ctx, ns, schema, entry, hooks)
59
+ })
60
+ }
@@ -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
+ }