@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/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.
|
|
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
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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
|
+
}
|