@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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dickpy/dsh-imagegen",
3
3
  "description": "AI image generation plugin for the dsh web GUI: text-to-image and image-to-image through configurable provider channels (gpt-image-2 / grok-imagine-image / nanobanana series / seedream-5.0-pro / dall-e-3, with native xAI Grok Imagine, Google Nano Banana and ByteDance Seedream request shaping), with per-channel model catalogs and a New Session / Image Generation tab entry opening a three-column studio beside the native conversation.",
4
- "version": "1.5.2",
4
+ "version": "1.5.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -0,0 +1,376 @@
1
+ /** Host-persisted infinite canvas documents and content-addressed assets. */
2
+
3
+ import { promises as fs } from 'node:fs'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { homedir } from 'node:os'
6
+ import path from 'node:path'
7
+ import type { CanvasAssetRef, CanvasDocument, CanvasNode, CanvasSummary } from './protocol.ts'
8
+
9
+ const DATA_ROOT = (process.env.DSH_HOME?.trim() || path.join(homedir(), '.dsh'))
10
+ const CANVAS_ROOT = path.join(DATA_ROOT, 'dsh-imagegen', 'canvas')
11
+ const PAGES_DIR = path.join(CANVAS_ROOT, 'pages')
12
+ const ASSETS_DIR = path.join(CANVAS_ROOT, 'assets')
13
+ const INDEX_PATH = path.join(CANVAS_ROOT, 'index.json')
14
+
15
+ export interface CanvasImageInput {
16
+ data: Uint8Array
17
+ mime: string
18
+ width: number
19
+ height: number
20
+ origin: CanvasAssetRef['origin']
21
+ originId?: string
22
+ entryId?: string
23
+ imageIndex?: number
24
+ name?: string
25
+ }
26
+
27
+ export class CanvasConflictError extends Error {
28
+ readonly code = 'canvas-conflict'
29
+ constructor(message = '画布已在其他窗口更新,请重新加载后再保存。') {
30
+ super(message)
31
+ this.name = 'CanvasConflictError'
32
+ }
33
+ }
34
+
35
+ interface IndexFile {
36
+ projects: CanvasSummary[]
37
+ }
38
+
39
+ function extensionOf(mime: string): string {
40
+ switch (mime.split(';')[0]!.trim().toLowerCase()) {
41
+ case 'image/jpeg': return 'jpg'
42
+ case 'image/webp': return 'webp'
43
+ case 'image/gif': return 'gif'
44
+ default: return 'png'
45
+ }
46
+ }
47
+
48
+ function mimeOf(file: string): string {
49
+ switch (path.extname(file).toLowerCase()) {
50
+ case '.jpg':
51
+ case '.jpeg': return 'image/jpeg'
52
+ case '.webp': return 'image/webp'
53
+ case '.gif': return 'image/gif'
54
+ default: return 'image/png'
55
+ }
56
+ }
57
+
58
+ function safeId(value: string): string {
59
+ const id = value.replace(/[^a-zA-Z0-9_-]/g, '-')
60
+ return id === '' ? randomUUID() : id
61
+ }
62
+
63
+ function pagePath(id: string): string {
64
+ return path.join(PAGES_DIR, `${safeId(id)}.json`)
65
+ }
66
+
67
+ function assetFilePath(id: string): string | undefined {
68
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
69
+ const file = path.join(ASSETS_DIR, id)
70
+ const relative = path.relative(ASSETS_DIR, file)
71
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return undefined
72
+ return file
73
+ }
74
+
75
+ async function ensureDirs(): Promise<void> {
76
+ await fs.mkdir(PAGES_DIR, { recursive: true })
77
+ await fs.mkdir(ASSETS_DIR, { recursive: true })
78
+ }
79
+
80
+ async function writeJsonAtomic(file: string, value: unknown): Promise<void> {
81
+ await fs.mkdir(path.dirname(file), { recursive: true })
82
+ const temp = `${file}.tmp-${process.pid}-${randomUUID()}`
83
+ await fs.writeFile(temp, `${JSON.stringify(value)}\n`, 'utf8')
84
+ await fs.rename(temp, file)
85
+ }
86
+
87
+ async function readJson(file: string): Promise<unknown | undefined> {
88
+ try { return JSON.parse(await fs.readFile(file, 'utf8')) as unknown } catch { return undefined }
89
+ }
90
+
91
+ function defaultDocument(id: string, title: string): CanvasDocument {
92
+ const now = Date.now()
93
+ return {
94
+ version: 2,
95
+ id,
96
+ title,
97
+ revision: 1,
98
+ viewport: { x: 0, y: 0, k: 1 },
99
+ background: 'dots',
100
+ nodes: [],
101
+ connections: [],
102
+ createdAt: now,
103
+ updatedAt: now,
104
+ }
105
+ }
106
+
107
+ function isAssetRef(value: unknown): value is CanvasAssetRef {
108
+ if (value === null || typeof value !== 'object') return false
109
+ const asset = value as Record<string, unknown>
110
+ return typeof asset.assetId === 'string' && typeof asset.url === 'string' && typeof asset.mime === 'string'
111
+ && typeof asset.width === 'number' && typeof asset.height === 'number'
112
+ }
113
+
114
+ function isNode(value: unknown): value is CanvasNode {
115
+ if (value === null || typeof value !== 'object') return false
116
+ const node = value as Record<string, unknown>
117
+ if (typeof node.id !== 'string' || typeof node.title !== 'string' || typeof node.x !== 'number'
118
+ || typeof node.y !== 'number' || typeof node.width !== 'number' || typeof node.height !== 'number') return false
119
+ if (node.type !== 'image' && node.type !== 'text' && node.type !== 'config') return false
120
+ const metadata = node.metadata
121
+ if (metadata !== undefined && (metadata === null || typeof metadata !== 'object')) return false
122
+ const state = (metadata ?? {}) as Record<string, unknown>
123
+ if (node.type === 'image') {
124
+ if (state.asset !== undefined && !isAssetRef(state.asset)) return false
125
+ return state.status === undefined || state.status === 'idle' || state.status === 'generating' || state.status === 'success' || state.status === 'error'
126
+ }
127
+ if (node.type === 'config') {
128
+ return state.prompt === undefined || typeof state.prompt === 'string'
129
+ }
130
+ return state.text === undefined || typeof state.text === 'string'
131
+ }
132
+
133
+ function isDocument(value: unknown): value is CanvasDocument {
134
+ if (value === null || typeof value !== 'object') return false
135
+ const document = value as Record<string, unknown>
136
+ return document.version === 2 && typeof document.id === 'string' && typeof document.title === 'string'
137
+ && typeof document.revision === 'number' && document.viewport !== null && typeof document.viewport === 'object'
138
+ && typeof (document.viewport as { x?: unknown }).x === 'number'
139
+ && typeof (document.viewport as { y?: unknown }).y === 'number'
140
+ && typeof (document.viewport as { k?: unknown }).k === 'number'
141
+ && (document.background === 'dots' || document.background === 'lines' || document.background === 'blank')
142
+ && Array.isArray(document.nodes) && document.nodes.every(isNode)
143
+ && Array.isArray(document.connections)
144
+ }
145
+
146
+ /** Upgrade a v1 (image/text/annotation + edges) document to the v2 node-graph model.
147
+ * Images and text notes keep their geometry; annotation prompt cards become plain
148
+ * text notes carrying the prompt, and old edges survive only between surviving nodes. */
149
+ function migrateLegacyDocument(input: Record<string, unknown>): CanvasDocument {
150
+ const now = Date.now()
151
+ const legacyNodes = Array.isArray(input.nodes) ? input.nodes : []
152
+ const nodes: CanvasNode[] = []
153
+ const annotationIds = new Set<string>()
154
+ for (const raw of legacyNodes) {
155
+ if (raw === null || typeof raw !== 'object') continue
156
+ const node = raw as Record<string, unknown>
157
+ if (typeof node.id !== 'string' || typeof node.x !== 'number' || typeof node.y !== 'number') continue
158
+ const base = {
159
+ id: node.id,
160
+ title: typeof node.title === 'string' ? node.title : '未命名节点',
161
+ x: node.x,
162
+ y: node.y,
163
+ width: typeof node.width === 'number' ? node.width : 300,
164
+ height: typeof node.height === 'number' ? node.height : 220,
165
+ }
166
+ if (node.type === 'image' && isAssetRef(node.asset)) {
167
+ const generation = (node.generation ?? {}) as Record<string, unknown>
168
+ nodes.push({
169
+ ...base,
170
+ type: 'image',
171
+ metadata: {
172
+ asset: node.asset,
173
+ status: (typeof node.status === 'string' && ['idle', 'generating', 'success', 'error'].includes(node.status)
174
+ ? node.status
175
+ : 'success') as 'idle' | 'generating' | 'success' | 'error',
176
+ ...(typeof node.error === 'string' ? { error: node.error } : {}),
177
+ ...(typeof generation.prompt === 'string' ? { prompt: generation.prompt } : {}),
178
+ ...(typeof generation.model === 'string' ? { model: generation.model } : {}),
179
+ ...(typeof generation.taskId === 'string' ? { taskId: generation.taskId } : {}),
180
+ ...(typeof generation.sourceNodeId === 'string' ? { sourceNodeId: generation.sourceNodeId } : {}),
181
+ },
182
+ })
183
+ } else if (node.type === 'text') {
184
+ nodes.push({ ...base, type: 'text', metadata: { text: typeof node.text === 'string' ? node.text : '', ...(typeof node.fontSize === 'number' ? { fontSize: node.fontSize } : {}) } })
185
+ } else if (node.type === 'annotation') {
186
+ annotationIds.add(node.id)
187
+ const prompt = typeof node.prompt === 'string' && node.prompt.trim() !== '' ? node.prompt : '(旧版标注,提示词见此)'
188
+ nodes.push({ ...base, type: 'text', title: '旧版标注', metadata: { text: prompt } })
189
+ }
190
+ }
191
+ nodes.sort((a, b) => {
192
+ const za = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === a.id) as Record<string, unknown> | undefined)?.zIndex
193
+ const zb = (legacyNodes.find(item => (item as Record<string, unknown>)?.id === b.id) as Record<string, unknown> | undefined)?.zIndex
194
+ return (typeof za === 'number' ? za : 0) - (typeof zb === 'number' ? zb : 0)
195
+ })
196
+ const validIds = new Set(nodes.map(node => node.id))
197
+ const seen = new Set<string>()
198
+ const connections = (Array.isArray(input.edges) ? input.edges : []).flatMap(raw => {
199
+ if (raw === null || typeof raw !== 'object') return []
200
+ const edge = raw as Record<string, unknown>
201
+ if (typeof edge.fromNodeId !== 'string' || typeof edge.toNodeId !== 'string') return []
202
+ if (annotationIds.has(edge.fromNodeId) || annotationIds.has(edge.toNodeId)) return []
203
+ if (!validIds.has(edge.fromNodeId) || !validIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) return []
204
+ const key = `${edge.fromNodeId}->${edge.toNodeId}`
205
+ if (seen.has(key)) return []
206
+ seen.add(key)
207
+ return [{ id: typeof edge.id === 'string' ? edge.id : `edge-${randomUUID()}`, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId }]
208
+ })
209
+ const legacyViewport = (input.viewport ?? {}) as Record<string, unknown>
210
+ const background = input.background === 'grid' ? 'lines' : input.background === 'blank' ? 'blank' : 'dots'
211
+ return {
212
+ version: 2,
213
+ id: typeof input.id === 'string' ? input.id : randomUUID(),
214
+ title: typeof input.title === 'string' ? input.title : '未命名画布',
215
+ revision: typeof input.revision === 'number' ? input.revision : 1,
216
+ viewport: {
217
+ x: typeof legacyViewport.x === 'number' ? legacyViewport.x : 0,
218
+ y: typeof legacyViewport.y === 'number' ? legacyViewport.y : 0,
219
+ k: typeof legacyViewport.scale === 'number' && legacyViewport.scale > 0 ? legacyViewport.scale : 1,
220
+ },
221
+ background,
222
+ nodes,
223
+ connections,
224
+ createdAt: typeof input.createdAt === 'number' ? input.createdAt : now,
225
+ updatedAt: typeof input.updatedAt === 'number' ? input.updatedAt : now,
226
+ }
227
+ }
228
+
229
+ /** Accept either the v2 document or a legacy v1 payload and return v2. */
230
+ function coerceDocument(value: unknown): CanvasDocument | undefined {
231
+ if (value === null || typeof value !== 'object') return undefined
232
+ const document = value as Record<string, unknown>
233
+ if (document.version === 1) {
234
+ const migrated = migrateLegacyDocument(document)
235
+ return isDocument(migrated) ? migrated : undefined
236
+ }
237
+ return isDocument(value) ? value : undefined
238
+ }
239
+
240
+ let mutation: Promise<void> = Promise.resolve()
241
+ function serialize<T>(operation: () => Promise<T>): Promise<T> {
242
+ const next = mutation.then(operation, operation)
243
+ mutation = next.then(() => undefined, () => undefined)
244
+ return next
245
+ }
246
+
247
+ export class CanvasStore {
248
+ constructor(private readonly root = CANVAS_ROOT) {}
249
+
250
+ private pagesDir(): string { return path.join(this.root, 'pages') }
251
+ private assetsDir(): string { return path.join(this.root, 'assets') }
252
+ private indexPath(): string { return path.join(this.root, 'index.json') }
253
+
254
+ private async ensure(): Promise<void> {
255
+ await fs.mkdir(this.pagesDir(), { recursive: true })
256
+ await fs.mkdir(this.assetsDir(), { recursive: true })
257
+ }
258
+
259
+ private pagePath(id: string): string { return path.join(this.pagesDir(), `${safeId(id)}.json`) }
260
+ private assetPath(id: string): string | undefined {
261
+ if (!/^[a-f0-9]{64}\.(png|jpg|jpeg|webp|gif)$/.test(id)) return undefined
262
+ const target = path.join(this.assetsDir(), id)
263
+ const relative = path.relative(this.assetsDir(), target)
264
+ return relative.startsWith('..') || path.isAbsolute(relative) ? undefined : target
265
+ }
266
+
267
+ private async readIndex(): Promise<CanvasSummary[]> {
268
+ const value = await readJson(this.indexPath())
269
+ if (value === undefined || typeof value !== 'object' || !Array.isArray((value as { projects?: unknown }).projects)) return []
270
+ return (value as { projects: unknown[] }).projects.filter(item => {
271
+ if (item === null || typeof item !== 'object') return false
272
+ const project = item as Record<string, unknown>
273
+ return typeof project.id === 'string' && typeof project.title === 'string' && typeof project.revision === 'number'
274
+ && typeof project.nodeCount === 'number' && typeof project.createdAt === 'number' && typeof project.updatedAt === 'number'
275
+ }) as CanvasSummary[]
276
+ }
277
+
278
+ private async writeIndex(projects: CanvasSummary[]): Promise<void> {
279
+ await this.ensure()
280
+ await writeJsonAtomic(this.indexPath(), { projects })
281
+ }
282
+
283
+ async list(): Promise<CanvasSummary[]> {
284
+ return this.readIndex()
285
+ }
286
+
287
+ async create(title = '未命名画布'): Promise<CanvasDocument> {
288
+ return serialize(async () => {
289
+ await this.ensure()
290
+ const id = randomUUID()
291
+ const document = defaultDocument(id, title.trim() || '未命名画布')
292
+ await writeJsonAtomic(this.pagePath(id), document)
293
+ const projects = await this.readIndex()
294
+ await this.writeIndex([this.summaryOf(document), ...projects])
295
+ return document
296
+ })
297
+ }
298
+
299
+ async read(id: string): Promise<CanvasDocument | undefined> {
300
+ return coerceDocument(await readJson(this.pagePath(id)))
301
+ }
302
+
303
+ async save(document: CanvasDocument, expectedRevision?: number): Promise<CanvasDocument> {
304
+ return serialize(async () => {
305
+ const incoming = coerceDocument(document)
306
+ if (incoming === undefined) throw new Error('malformed canvas document')
307
+ const current = await this.read(incoming.id)
308
+ if (current !== undefined && expectedRevision !== undefined && current.revision !== expectedRevision) {
309
+ throw new CanvasConflictError()
310
+ }
311
+ const next: CanvasDocument = {
312
+ ...incoming,
313
+ revision: Math.max(current?.revision ?? 0, incoming.revision) + 1,
314
+ updatedAt: Date.now(),
315
+ }
316
+ await this.ensure()
317
+ await writeJsonAtomic(this.pagePath(next.id), next)
318
+ const projects = (await this.readIndex()).filter(item => item.id !== next.id)
319
+ await this.writeIndex([this.summaryOf(next), ...projects])
320
+ return next
321
+ })
322
+ }
323
+
324
+ async remove(id: string): Promise<CanvasSummary[]> {
325
+ return serialize(async () => {
326
+ try { await fs.rm(this.pagePath(id), { force: true }) } catch { /* best effort */ }
327
+ const projects = (await this.readIndex()).filter(item => item.id !== id)
328
+ await this.writeIndex(projects)
329
+ return projects
330
+ })
331
+ }
332
+
333
+ async putImage(input: CanvasImageInput): Promise<CanvasAssetRef> {
334
+ if (!input.data.byteLength) throw new Error('image data is empty')
335
+ if (!/^image\/(png|jpeg|webp|gif)$/.test(input.mime)) throw new Error('unsupported image type')
336
+ if (!Number.isSafeInteger(input.width) || input.width < 1 || !Number.isSafeInteger(input.height) || input.height < 1) {
337
+ throw new Error('image dimensions are invalid')
338
+ }
339
+ await this.ensure()
340
+ const hash = createHash('sha256').update(input.data).digest('hex')
341
+ const file = `${hash}.${extensionOf(input.mime)}`
342
+ const target = path.join(this.assetsDir(), file)
343
+ try { await fs.access(target) } catch { await fs.writeFile(target, input.data) }
344
+ return {
345
+ assetId: file,
346
+ url: `/api/dsh-imagegen/canvas/asset/${file}`,
347
+ mime: input.mime,
348
+ bytes: input.data.byteLength,
349
+ width: input.width,
350
+ height: input.height,
351
+ origin: input.origin,
352
+ ...input.originId === undefined ? {} : { originId: input.originId },
353
+ ...input.entryId === undefined ? {} : { entryId: input.entryId },
354
+ ...input.imageIndex === undefined ? {} : { imageIndex: input.imageIndex },
355
+ }
356
+ }
357
+
358
+ async readAsset(file: string): Promise<{ data: Buffer; mime: string } | undefined> {
359
+ const target = this.assetPath(file)
360
+ if (target === undefined) return undefined
361
+ try { return { data: await fs.readFile(target), mime: mimeOf(file) } } catch { return undefined }
362
+ }
363
+
364
+ private summaryOf(document: CanvasDocument): CanvasSummary {
365
+ return {
366
+ id: document.id,
367
+ title: document.title,
368
+ revision: document.revision,
369
+ nodeCount: document.nodes.length,
370
+ createdAt: document.createdAt,
371
+ updatedAt: document.updatedAt,
372
+ }
373
+ }
374
+ }
375
+
376
+ export const canvasStore = new CanvasStore()