@dickpy/dsh-imagegen 1.5.2 → 1.5.4
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 +23 -2
- package/lib/client.js +5803 -1942
- package/lib/client.js.map +1 -1
- package/lib/index.js +1151 -33
- package/package.json +1 -1
- package/src/canvas-store.ts +376 -0
- package/src/client/CanvasWorkspace.tsx +1569 -0
- package/src/client/ImageGenPanel.tsx +164 -96
- package/src/client/SettingsCard.tsx +182 -4
- package/src/client/api.ts +48 -1
- package/src/client/canvas-workspace.module.css +929 -0
- package/src/client/helpers.ts +71 -33
- package/src/client/index.ts +59 -7
- package/src/client/locales.ts +1452 -772
- package/src/client/panel.module.css +83 -33
- package/src/client/use-language.ts +14 -0
- package/src/engine.ts +302 -12
- package/src/gallery-store.ts +5 -0
- package/src/generation-runtime.ts +1 -0
- package/src/history-store.ts +5 -0
- package/src/index.ts +71 -4
- package/src/model-catalog.ts +10 -1
- package/src/presets.ts +15 -0
- package/src/protocol.ts +121 -1
- package/src/routes.ts +231 -1
- package/src/storage-sync.ts +105 -0
package/src/routes.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { mkdir as fsMkdir } from 'node:fs/promises'
|
|
9
11
|
import { randomUUID } from 'node:crypto'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
10
14
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
11
15
|
import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
|
|
12
16
|
import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
|
|
@@ -16,11 +20,13 @@ import { normalizeImageModels } from './image-models.ts'
|
|
|
16
20
|
import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
|
|
17
21
|
import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
|
|
18
22
|
import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
|
|
23
|
+
import { canvasStore, CanvasConflictError, type CanvasImageInput, type CanvasStore } from './canvas-store.ts'
|
|
19
24
|
import { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates } from './templates-store.ts'
|
|
20
25
|
import { addTemplateFavorite, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
|
|
26
|
+
import { testStorage, type StorageSyncConfig } from './storage-sync.ts'
|
|
21
27
|
import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
|
|
22
28
|
import { IMAGE_PRESETS } from './presets.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'
|
|
29
|
+
import { AGENT_IMAGE_API, CANVAS_API, CONVERSATION_IMAGE_API, DATA_FOLDER_API, DEFAULT_TEMPLATE_SOURCE_ID, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, STORAGE_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, USAGE_API, isTemplateSourceId, type CanvasDocument, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
|
|
24
30
|
|
|
25
31
|
/** Cap on JSON request bodies (settings ops and generate payloads are small). */
|
|
26
32
|
const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
|
|
@@ -73,6 +79,8 @@ export interface ImageGenRoutesDeps {
|
|
|
73
79
|
updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
|
|
74
80
|
readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
|
|
75
81
|
}
|
|
82
|
+
/** Overrideable canvas backend, primarily for host integration tests. */
|
|
83
|
+
canvas?: CanvasBackend
|
|
76
84
|
/** Overrideable template-library backend, primarily for host integration tests. */
|
|
77
85
|
templates?: {
|
|
78
86
|
list: (sourceId: string) => Promise<TemplateListResult>
|
|
@@ -86,10 +94,23 @@ export interface ImageGenRoutesDeps {
|
|
|
86
94
|
add: (sourceId: string, item: TemplateFavorite['case']) => Promise<TemplateFavorite[]>
|
|
87
95
|
remove: (key: string) => Promise<TemplateFavorite[]>
|
|
88
96
|
}
|
|
97
|
+
/** Resolve the object-storage sync settings (with the real secret). */
|
|
98
|
+
resolveStorage?: () => StorageSyncConfig
|
|
89
99
|
/** Shared host queue, used by Agent tools and browser task endpoints. */
|
|
90
100
|
runtime?: ImageGenerationRuntime
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/** Minimal canvas store contract so hosts can inject an isolated test backend. */
|
|
104
|
+
export interface CanvasBackend {
|
|
105
|
+
list: () => Promise<Awaited<ReturnType<CanvasStore['list']>>>
|
|
106
|
+
create: (title?: string) => Promise<Awaited<ReturnType<CanvasStore['create']>>>
|
|
107
|
+
read: (id: string) => Promise<Awaited<ReturnType<CanvasStore['read']>>>
|
|
108
|
+
save: (document: CanvasDocument, expectedRevision?: number) => Promise<Awaited<ReturnType<CanvasStore['save']>>>
|
|
109
|
+
remove: (id: string) => Promise<Awaited<ReturnType<CanvasStore['remove']>>>
|
|
110
|
+
putImage: (input: CanvasImageInput) => Promise<Awaited<ReturnType<CanvasStore['putImage']>>>
|
|
111
|
+
readAsset: (file: string) => Promise<Awaited<ReturnType<CanvasStore['readAsset']>>>
|
|
112
|
+
}
|
|
113
|
+
|
|
93
114
|
/** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
|
|
94
115
|
function isLoopbackRequest(request: IncomingMessage): boolean {
|
|
95
116
|
const address = request.socket.remoteAddress
|
|
@@ -156,6 +177,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
|
|
|
156
177
|
const comparisonModels = Array.isArray(body.comparisonModels)
|
|
157
178
|
? [...new Set(body.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
|
|
158
179
|
: []
|
|
180
|
+
const canvas = parseCanvasMeta(body.canvas)
|
|
159
181
|
return {
|
|
160
182
|
mode: body.mode === 'edit' ? 'edit' : 'text',
|
|
161
183
|
model: typeof body.model === 'string' ? body.model : '',
|
|
@@ -169,6 +191,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
|
|
|
169
191
|
...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
|
|
170
192
|
...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
|
|
171
193
|
...comparisonModels.length > 1 ? { comparisonModels } : {},
|
|
194
|
+
...canvas === undefined ? {} : { canvas },
|
|
172
195
|
...body.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
|
|
173
196
|
...typeof body.projectId === 'string' && body.projectId !== '' ? { projectId: body.projectId } : {},
|
|
174
197
|
...typeof body.projectName === 'string' && body.projectName !== '' ? { projectName: body.projectName } : {},
|
|
@@ -177,6 +200,19 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
|
|
|
177
200
|
}
|
|
178
201
|
}
|
|
179
202
|
|
|
203
|
+
function parseCanvasMeta(value: unknown): GenerateRequest['canvas'] {
|
|
204
|
+
if (value === null || typeof value !== 'object') return undefined
|
|
205
|
+
const raw = value as Record<string, unknown>
|
|
206
|
+
if (typeof raw.canvasId !== 'string' || raw.canvasId.trim() === '') return undefined
|
|
207
|
+
return {
|
|
208
|
+
canvasId: raw.canvasId.trim(),
|
|
209
|
+
...typeof raw.sourceNodeId === 'string' && raw.sourceNodeId.trim() !== '' ? { sourceNodeId: raw.sourceNodeId.trim() } : {},
|
|
210
|
+
...typeof raw.annotationNodeId === 'string' && raw.annotationNodeId.trim() !== '' ? { annotationNodeId: raw.annotationNodeId.trim() } : {},
|
|
211
|
+
...typeof raw.parentNodeId === 'string' && raw.parentNodeId.trim() !== '' ? { parentNodeId: raw.parentNodeId.trim() } : {},
|
|
212
|
+
...raw.placement === 'right' || raw.placement === 'below' ? { placement: raw.placement } : {},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
180
216
|
/** Validate a submitted history entry (images carry base64). */
|
|
181
217
|
function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
|
|
182
218
|
const raw = body.entry
|
|
@@ -202,6 +238,7 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
|
|
|
202
238
|
const comparisonModels = Array.isArray(entry.comparisonModels)
|
|
203
239
|
? [...new Set(entry.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
|
|
204
240
|
: []
|
|
241
|
+
const canvas = parseCanvasMeta(entry.canvas)
|
|
205
242
|
return {
|
|
206
243
|
id: entry.id,
|
|
207
244
|
createdAt: entry.createdAt,
|
|
@@ -218,6 +255,7 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
|
|
|
218
255
|
...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
|
|
219
256
|
...typeof entry.comparisonId === 'string' ? { comparisonId: entry.comparisonId } : {},
|
|
220
257
|
...comparisonModels.length > 1 ? { comparisonModels } : {},
|
|
258
|
+
...canvas === undefined ? {} : { canvas },
|
|
221
259
|
...entry.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
|
|
222
260
|
...typeof entry.projectId === 'string' ? { projectId: entry.projectId } : {},
|
|
223
261
|
...typeof entry.projectName === 'string' ? { projectName: entry.projectName } : {},
|
|
@@ -323,6 +361,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
323
361
|
updateTags: updateGalleryTags,
|
|
324
362
|
readImage: readGalleryImage,
|
|
325
363
|
}
|
|
364
|
+
const canvas = deps.canvas ?? canvasStore
|
|
326
365
|
const templates = deps.templates ?? {
|
|
327
366
|
list: listTemplates,
|
|
328
367
|
refresh: refreshTemplates,
|
|
@@ -923,6 +962,148 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
923
962
|
res.end(found.data)
|
|
924
963
|
},
|
|
925
964
|
},
|
|
965
|
+
// ------------------------------------------------------ canvas list
|
|
966
|
+
{
|
|
967
|
+
kind: 'exact',
|
|
968
|
+
path: CANVAS_API.list,
|
|
969
|
+
handler: async (req, res) => {
|
|
970
|
+
if (!guard(req, res, 'POST')) return
|
|
971
|
+
try { writeJson(res, 200, { ok: true, projects: await canvas.list() }) }
|
|
972
|
+
catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
// ---------------------------------------------------- canvas create
|
|
976
|
+
{
|
|
977
|
+
kind: 'exact',
|
|
978
|
+
path: CANVAS_API.create,
|
|
979
|
+
handler: async (req, res) => {
|
|
980
|
+
if (!guard(req, res, 'POST')) return
|
|
981
|
+
const body = await readJsonBody(req)
|
|
982
|
+
const title = typeof body?.title === 'string' ? body.title : '未命名画布'
|
|
983
|
+
try { writeJson(res, 200, { ok: true, document: await canvas.create(title) }) }
|
|
984
|
+
catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
// ------------------------------------------------------ canvas read
|
|
988
|
+
{
|
|
989
|
+
kind: 'exact',
|
|
990
|
+
path: CANVAS_API.read,
|
|
991
|
+
handler: async (req, res) => {
|
|
992
|
+
if (!guard(req, res, 'POST')) return
|
|
993
|
+
const body = await readJsonBody(req)
|
|
994
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
995
|
+
const document = id === '' ? undefined : await canvas.read(id)
|
|
996
|
+
if (document === undefined) writeJson(res, 200, { ok: false, code: 'not-found', message: '画布不存在' })
|
|
997
|
+
else writeJson(res, 200, { ok: true, document })
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
// ------------------------------------------------------ canvas save
|
|
1001
|
+
{
|
|
1002
|
+
kind: 'exact',
|
|
1003
|
+
path: CANVAS_API.save,
|
|
1004
|
+
handler: async (req, res) => {
|
|
1005
|
+
if (!guard(req, res, 'POST')) return
|
|
1006
|
+
const body = await readJsonBody(req)
|
|
1007
|
+
const document = body?.document as CanvasDocument | undefined
|
|
1008
|
+
const expectedRevision = typeof body?.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
1009
|
+
if (document === undefined || typeof document !== 'object') {
|
|
1010
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'canvas document is required' })
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
try { writeJson(res, 200, { ok: true, document: await canvas.save(document, expectedRevision) }) }
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
const code = error instanceof CanvasConflictError ? error.code : 'canvas-failed'
|
|
1016
|
+
writeJson(res, 200, { ok: false, code, message: messageOf(error) })
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
// ---------------------------------------------------- canvas remove
|
|
1021
|
+
{
|
|
1022
|
+
kind: 'exact',
|
|
1023
|
+
path: CANVAS_API.remove,
|
|
1024
|
+
handler: async (req, res) => {
|
|
1025
|
+
if (!guard(req, res, 'POST')) return
|
|
1026
|
+
const body = await readJsonBody(req)
|
|
1027
|
+
const id = typeof body?.id === 'string' ? body.id : ''
|
|
1028
|
+
if (id === '') { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'canvas id is required' }); return }
|
|
1029
|
+
try { writeJson(res, 200, { ok: true, projects: await canvas.remove(id) }) }
|
|
1030
|
+
catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
// ------------------------------------------------ canvas asset upload
|
|
1034
|
+
{
|
|
1035
|
+
kind: 'exact',
|
|
1036
|
+
path: CANVAS_API.assetUpload,
|
|
1037
|
+
handler: async (req, res) => {
|
|
1038
|
+
if (!guard(req, res, 'POST')) return
|
|
1039
|
+
const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
|
|
1040
|
+
const parsed = typeof body?.dataUrl === 'string' ? imageDataUrl(body.dataUrl) : undefined
|
|
1041
|
+
const width = Number(body?.width)
|
|
1042
|
+
const height = Number(body?.height)
|
|
1043
|
+
if (parsed === undefined || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
|
|
1044
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'image data and dimensions are required' })
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
const asset = await canvas.putImage({
|
|
1049
|
+
data: parsed.data,
|
|
1050
|
+
mime: parsed.mediaType,
|
|
1051
|
+
width,
|
|
1052
|
+
height,
|
|
1053
|
+
origin: body?.origin === 'history' || body?.origin === 'gallery' || body?.origin === 'generated' ? body.origin : 'upload',
|
|
1054
|
+
...typeof body?.originId === 'string' ? { originId: body.originId } : {},
|
|
1055
|
+
...typeof body?.entryId === 'string' ? { entryId: body.entryId } : {},
|
|
1056
|
+
...Number.isSafeInteger(Number(body?.imageIndex)) ? { imageIndex: Number(body?.imageIndex) } : {},
|
|
1057
|
+
})
|
|
1058
|
+
writeJson(res, 200, { ok: true, asset })
|
|
1059
|
+
} catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-asset-failed', message: messageOf(error) }) }
|
|
1060
|
+
},
|
|
1061
|
+
},
|
|
1062
|
+
// ----------------------------------------------- canvas asset import
|
|
1063
|
+
{
|
|
1064
|
+
kind: 'exact',
|
|
1065
|
+
path: CANVAS_API.assetImport,
|
|
1066
|
+
handler: async (req, res) => {
|
|
1067
|
+
if (!guard(req, res, 'POST')) return
|
|
1068
|
+
const body = await readJsonBody(req)
|
|
1069
|
+
const source = body?.source === 'history' || body?.source === 'gallery' ? body.source : undefined
|
|
1070
|
+
const entryId = typeof body?.entryId === 'string' ? body.entryId : ''
|
|
1071
|
+
const imageIndex = Number(body?.imageIndex)
|
|
1072
|
+
const width = Number(body?.width)
|
|
1073
|
+
const height = Number(body?.height)
|
|
1074
|
+
if (source === undefined || entryId === '' || !Number.isSafeInteger(imageIndex) || imageIndex < 0
|
|
1075
|
+
|| !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
|
|
1076
|
+
writeJson(res, 200, { ok: false, code: 'bad-request', message: 'source, entryId, imageIndex and dimensions are required' })
|
|
1077
|
+
return
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const backend = source === 'history' ? history : gallery
|
|
1081
|
+
const entry = (await backend.list()).find(item => item.id === entryId)
|
|
1082
|
+
const image = entry?.images[imageIndex]
|
|
1083
|
+
if (image === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'image not found' }); return }
|
|
1084
|
+
const base = source === 'history' ? HISTORY_API.image : GALLERY_API.image
|
|
1085
|
+
const file = imageFileFrom(image.url, base)
|
|
1086
|
+
const found = file === undefined ? undefined : await backend.readImage(file)
|
|
1087
|
+
if (found === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'image not found' }); return }
|
|
1088
|
+
const asset = await canvas.putImage({ data: found.data, mime: found.mime, width, height, origin: source, originId: entryId, entryId, imageIndex })
|
|
1089
|
+
writeJson(res, 200, { ok: true, asset })
|
|
1090
|
+
} catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-asset-failed', message: messageOf(error) }) }
|
|
1091
|
+
},
|
|
1092
|
+
},
|
|
1093
|
+
// ------------------------------------------- canvas asset (prefix)
|
|
1094
|
+
{
|
|
1095
|
+
kind: 'prefix',
|
|
1096
|
+
path: CANVAS_API.asset,
|
|
1097
|
+
handler: async (req, res) => {
|
|
1098
|
+
if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
|
|
1099
|
+
if (req.method !== 'GET') { writeJson(res, 405, { error: `method not allowed: ${req.method}` }); return }
|
|
1100
|
+
const file = imageFileFrom(req.url, CANVAS_API.asset)
|
|
1101
|
+
const found = file === undefined ? undefined : await canvas.readAsset(file)
|
|
1102
|
+
if (found === undefined) { writeJson(res, 404, { error: 'not found' }); return }
|
|
1103
|
+
res.writeHead(200, { 'content-type': found.mime, 'content-length': found.data.length, 'cache-control': 'private, max-age=3600' })
|
|
1104
|
+
res.end(found.data)
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
926
1107
|
// --------------------------------------------------- templates list
|
|
927
1108
|
{
|
|
928
1109
|
kind: 'exact',
|
|
@@ -1076,5 +1257,54 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
|
|
|
1076
1257
|
}
|
|
1077
1258
|
},
|
|
1078
1259
|
},
|
|
1260
|
+
// ------------------------------------------- data folder: reveal in OS
|
|
1261
|
+
{
|
|
1262
|
+
kind: 'exact',
|
|
1263
|
+
path: DATA_FOLDER_API,
|
|
1264
|
+
handler: async (req, res) => {
|
|
1265
|
+
if (!guard(req, res, 'POST')) return
|
|
1266
|
+
const body = await readJsonBody(req)
|
|
1267
|
+
const dir = path.join(homedir(), '.dsh', 'dsh-imagegen')
|
|
1268
|
+
try {
|
|
1269
|
+
await fsMkdir(dir, { recursive: true })
|
|
1270
|
+
} catch { /* reveal still works when the directory already exists */ }
|
|
1271
|
+
if (body?.open === false) {
|
|
1272
|
+
// Wiring probe for tests: resolve the path without spawning a shell.
|
|
1273
|
+
writeJson(res, 200, { ok: true, path: dir })
|
|
1274
|
+
return
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
const command = process.platform === 'win32'
|
|
1278
|
+
? 'explorer.exe'
|
|
1279
|
+
: process.platform === 'darwin'
|
|
1280
|
+
? 'open'
|
|
1281
|
+
: 'xdg-open'
|
|
1282
|
+
const child = spawn(command, [dir], { detached: true, stdio: 'ignore' })
|
|
1283
|
+
child.unref()
|
|
1284
|
+
writeJson(res, 200, { ok: true, path: dir })
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
writeJson(res, 200, { ok: false, code: 'data-folder-failed', message: messageOf(error) })
|
|
1287
|
+
}
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
// ------------------------------------------------- storage: probe upload
|
|
1291
|
+
{
|
|
1292
|
+
kind: 'exact',
|
|
1293
|
+
path: STORAGE_API.test,
|
|
1294
|
+
handler: async (req, res) => {
|
|
1295
|
+
if (!guard(req, res, 'POST')) return
|
|
1296
|
+
const storage = deps.resolveStorage?.()
|
|
1297
|
+
if (storage === undefined) {
|
|
1298
|
+
writeJson(res, 200, { ok: false, code: 'storage-unavailable', message: '存储配置不可用' })
|
|
1299
|
+
return
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
const result = await testStorage(storage)
|
|
1303
|
+
writeJson(res, 200, { ok: true, ms: result.ms, key: result.key })
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
writeJson(res, 200, { ok: false, code: 'storage-test-failed', message: messageOf(error) })
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1079
1309
|
]
|
|
1080
1310
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Object-storage sync for saved images: one S3-compatible uploader (SigV4,
|
|
3
|
+
* zero dependencies) that covers Tencent COS / Alibaba OSS / Qiniu S3 /
|
|
4
|
+
* MinIO / R2 style endpoints, plus a fire-and-forget hook the image stores
|
|
5
|
+
* call after a file lands on disk. The handler is registered by the plugin
|
|
6
|
+
* root (it owns the live settings), so framework-free stores stay decoupled
|
|
7
|
+
* from the settings seam.
|
|
8
|
+
*
|
|
9
|
+
* Object keys: `${prefix}/gallery/<file>` and `${prefix}/images/<file>` —
|
|
10
|
+
* content-addressed file names dedupe re-uploads naturally.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createHash, createHmac } from 'node:crypto'
|
|
14
|
+
|
|
15
|
+
/** The storage section of the plugin settings document. */
|
|
16
|
+
export interface StorageSyncConfig {
|
|
17
|
+
endpoint: string
|
|
18
|
+
region: string
|
|
19
|
+
accessKey: string
|
|
20
|
+
secretKey: string
|
|
21
|
+
prefix: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Registered by index.ts: uploads `filePath` when the settings allow it. */
|
|
25
|
+
export type StorageUploadHandler = (kind: 'gallery' | 'history', filePath: string) => void
|
|
26
|
+
|
|
27
|
+
let uploadHandler: StorageUploadHandler | undefined
|
|
28
|
+
|
|
29
|
+
/** Register the live uploader (index.ts apply). Pass undefined to clear. */
|
|
30
|
+
export function setStorageSyncHandler(handler: StorageUploadHandler | undefined): void {
|
|
31
|
+
uploadHandler = handler
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fire-and-forget notification from the image stores after a file write. */
|
|
35
|
+
export function notifyImageSaved(kind: 'gallery' | 'history', filePath: string): void {
|
|
36
|
+
try {
|
|
37
|
+
uploadHandler?.(kind, filePath)
|
|
38
|
+
} catch {
|
|
39
|
+
// A sync failure must never break the save path.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** URL-encode per RFC 3986 (AWS SigV4 canonical forms). */
|
|
44
|
+
function uriEncode(value: string, encodeSlash = true): string {
|
|
45
|
+
return value.replace(/[^A-Za-z0-9-_.~]/g, char => {
|
|
46
|
+
const hex = char.charCodeAt(0).toString(16).toUpperCase()
|
|
47
|
+
return `%${hex.padStart(2, '0')}`
|
|
48
|
+
}).replace(/%2F/g, encodeSlash ? '%2F' : '/')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** HMAC-SHA256 helper. */
|
|
52
|
+
function hmac(key: Buffer | string, data: string): Buffer {
|
|
53
|
+
return createHmac('sha256', key).update(data, 'utf8').digest()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* PUT one object to an S3-compatible endpoint (SigV4, virtual-hosted or
|
|
58
|
+
* path-style — the endpoint URL already includes the bucket). Returns the
|
|
59
|
+
* elapsed milliseconds so the settings card can show a latency reading.
|
|
60
|
+
*/
|
|
61
|
+
export async function putObject(config: StorageSyncConfig, key: string, data: Buffer, contentType = 'application/octet-stream'): Promise<{ ms: number }> {
|
|
62
|
+
const endpoint = config.endpoint.trim().replace(/\/+$/, '')
|
|
63
|
+
if (endpoint === '' || config.accessKey.trim() === '' || config.secretKey.trim() === '') {
|
|
64
|
+
throw new Error('对象存储配置不完整:请填写接口地址与密钥')
|
|
65
|
+
}
|
|
66
|
+
const url = new URL(`${endpoint}/${key.split('/').map(part => uriEncode(part)).join('/')}`)
|
|
67
|
+
const payloadHash = createHash('sha256').update(data).digest('hex')
|
|
68
|
+
const now = new Date()
|
|
69
|
+
const amzDate = `${now.toISOString().replace(/[:-]|\.\d{3}/g, '')}` // YYYYMMDDTHHMMSSZ
|
|
70
|
+
const dateStamp = amzDate.slice(0, 8)
|
|
71
|
+
const host = url.host
|
|
72
|
+
const canonicalUri = url.pathname
|
|
73
|
+
const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
|
|
74
|
+
const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
|
|
75
|
+
const canonicalRequest = `PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
|
|
76
|
+
const scope = `${dateStamp}/${config.region.trim() || 'us-east-1'}/s3/aws4_request`
|
|
77
|
+
const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash('sha256').update(canonicalRequest, 'utf8').digest('hex')}`
|
|
78
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${config.secretKey.trim()}`, dateStamp), config.region.trim() || 'us-east-1'), 's3'), 'aws4_request')
|
|
79
|
+
const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex')
|
|
80
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKey.trim()}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
|
|
81
|
+
|
|
82
|
+
const started = Date.now()
|
|
83
|
+
const response = await fetch(url, {
|
|
84
|
+
method: 'PUT',
|
|
85
|
+
headers: {
|
|
86
|
+
'content-type': contentType,
|
|
87
|
+
'x-amz-content-sha256': payloadHash,
|
|
88
|
+
'x-amz-date': amzDate,
|
|
89
|
+
authorization,
|
|
90
|
+
},
|
|
91
|
+
body: new Uint8Array(data),
|
|
92
|
+
})
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const text = await response.text().catch(() => '')
|
|
95
|
+
throw new Error(`对象存储拒绝上传(HTTP ${response.status})${text !== '' ? `:${text.slice(0, 200)}` : ''}`)
|
|
96
|
+
}
|
|
97
|
+
return { ms: Date.now() - started }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Upload a small probe object; used by the settings card's test button. */
|
|
101
|
+
export async function testStorage(config: StorageSyncConfig): Promise<{ ms: number; key: string }> {
|
|
102
|
+
const key = `${config.prefix.trim() || 'dsh-imagegen'}/ping.txt`
|
|
103
|
+
const { ms } = await putObject(config, key, Buffer.from('dsh-imagegen storage ok', 'utf8'), 'text/plain')
|
|
104
|
+
return { ms, key }
|
|
105
|
+
}
|